From 1679825ae2e2ef5da2d02c9e24885f711838ae5d Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Tue, 5 May 2026 21:02:12 +0330 Subject: [PATCH] UPDATE --- .env.example | 8 +- API_RELIABILITY_AUDIT_FA.md | 591 +++------------------- APPS_URLS_AUDIT.md | 402 ++------------- Dockerfile | 7 - Schemas | 2 +- config/integration_contract.py | 39 ++ config/rag_config.yaml | 2 +- config/settings.py | 36 +- config/urls.py | 4 + crop_simulation/growth_simulation.py | 9 +- crop_simulation/harvest_prediction.py | 8 +- crop_simulation/services.py | 22 +- crop_simulation/tests.py | 58 ++- crop_simulation/views.py | 88 +++- crop_simulation/water_stress.py | 8 +- crop_simulation/yield_harvest_summary.py | 16 +- drf_spectacular/__init__.py | 19 - drf_spectacular/types.py | 8 - drf_spectacular/utils.py | 60 --- drf_spectacular/views.py | 19 - entrypoint.sh | 4 + farm_data/models.py | 2 +- farm_data/services.py | 84 ++- farm_data/tests/test_farm_detail_api.py | 26 +- farm_data/views.py | 45 +- location_data/apps.py | 4 +- logs/app.log.2026-03-18 | 67 --- logs/app.log.2026-03-19 | 179 ------- pest_disease/views.py | 11 + rag/embedding.py | 55 +- rag/failure_contract.py | 55 ++ rag/ingest.py | 64 ++- rag/observability.py | 129 +++++ rag/retrieve.py | 65 ++- rag/services/pest_disease.py | 95 +++- rag/services/soil_anomaly.py | 65 ++- rag/services/water_need_prediction.py | 64 ++- rag/services/yield_harvest.py | 46 +- rag/tests/test_failure_contracts.py | 88 ++++ rag/tests/test_observability.py | 51 ++ rag/tests/test_recommendation_services.py | 71 ++- requirements.txt | 3 + soile/test_soil_moisture_heatmap_api.py | 27 + soile/views.py | 6 + weather/adapters.py | 4 +- weather/test_farm_weather_api.py | 28 + weather/views.py | 6 + 47 files changed, 1347 insertions(+), 1403 deletions(-) create mode 100644 config/integration_contract.py delete mode 100644 drf_spectacular/__init__.py delete mode 100644 drf_spectacular/types.py delete mode 100644 drf_spectacular/utils.py delete mode 100644 drf_spectacular/views.py delete mode 100644 logs/app.log.2026-03-18 delete mode 100644 logs/app.log.2026-03-19 create mode 100644 rag/failure_contract.py create mode 100644 rag/observability.py create mode 100644 rag/tests/test_failure_contracts.py create mode 100644 rag/tests/test_observability.py diff --git a/.env.example b/.env.example index 7e0967f..20a00a5 100644 --- a/.env.example +++ b/.env.example @@ -24,11 +24,11 @@ WEATHER_API_BASE_URL=https://api.open-meteo.com/v1/forecast WEATHER_API_KEY= -# Soil data provider: mock | soilgrids -SOIL_DATA_PROVIDER=mock +# Soil data provider: soilgrids | mock +SOIL_DATA_PROVIDER=soilgrids SOIL_MOCK_DELAY_SECONDS=0.8 SOILGRIDS_TIMEOUT_SECONDS=60 -WEATHER_DATA_PROVIDER=mock +WEATHER_DATA_PROVIDER=open-meteo WEATHER_MOCK_DELAY_SECONDS=0.8 -WEATHER_TIMEOUT_SECONDS=60 \ No newline at end of file +WEATHER_TIMEOUT_SECONDS=60 diff --git a/API_RELIABILITY_AUDIT_FA.md b/API_RELIABILITY_AUDIT_FA.md index 57f2495..ef40fa2 100644 --- a/API_RELIABILITY_AUDIT_FA.md +++ b/API_RELIABILITY_AUDIT_FA.md @@ -1,522 +1,69 @@ -# ممیزی ضعف‌ها و میزان اعتماد APIها - -این سند بر اساس بررسی کد فعلی پروژه نوشته شده است و هدفش این است که برای هر API مشخص کند: - -- خروجی روی چه داده یا فرمولی تکیه دارد -- کجاها خروجی صرفا تقریبی، heuristic، mock یا وابسته به LLM است -- برای چه نوع استفاده‌ای قابل اتکا هست و برای چه نوع تصمیمی نیست -- سطح اعتماد عملی به پاسخ خروجی چقدر است - -توجه: - -- این سند درباره `قابلیت اتکای عملیاتی` است، نه فقط درست بودن ساختار JSON. -- ممکن است یک API از نظر فنی همیشه JSON درست برگرداند، اما از نظر agronomy یا تصمیم‌سازی هنوز کم‌اعتماد باشد. -- سطح اعتماد به معنی اعتماد به `محتوای پاسخ` است، نه availability سرویس. - -## معیار سطح اعتماد - -- `زیاد`: عمدتا داده واقعی یا ذخیره شده برمی‌گرداند؛ فرمول یا منطق ساده ولی قابل توضیح است؛ برای نمایش و گزارش مناسب است. -- `متوسط`: داده واقعی دارد اما بخش مهمی از نتیجه وابسته به تقریب، پیش‌فرض، شبیه‌سازی، یا LLM است. -- `کم`: نتیجه بیشتر heuristic، fallback، برآورد تقریبی، یا تلفیق داده ناقص است. -- `خیلی کم`: داده mock، stub، یا خروجی عمدتا غیرقابل اتکا برای تصمیم واقعی. - -## خلاصه سریع APIهای پرریسک - -- `POST /api/economy/overview/`: داده کاملا mock است؛ برای تصمیم واقعی نباید استفاده شود. -- `POST /api/plants/fetch-info/`: عملا پیاده‌سازی نشده و `503` برمی‌گرداند. -- `POST /api/irrigation/water-stress/`: اگر engine شبیه‌سازی خطا بدهد هنوز به فرمول ساده سنسور fallback می‌کند. -- `POST /api/pest-disease/detect/` و `POST /api/pest-disease/risk/`: وابستگی مستقیم به RAG/LLM دارند؛ برای KPI قطعی یا تصمیم سم‌پاشی خودکار مناسب نیستند. -- `POST /api/farm-alerts/tracker/` و `POST /api/farm-alerts/timeline/`: ترکیب rule-based + LLM هستند؛ برای آگاهی خوب‌اند ولی برای اتوماسیون بحرانی نه. -- `POST /api/soile/moisture-heatmap/`: visualization خوبی می‌دهد اما هنوز geostatistics دقیق یا history واقعی سنسورها را مدل نمی‌کند. - ---- - -## 1) Soil Data API - -### `GET|POST /api/soil-data/` - -- منبع داده: - - اگر رکورد کامل موجود باشد از DB برمی‌گردد. - - اگر موجود نباشد فقط task صف می‌شود و `202` می‌دهد. -- ضعف‌ها: - - در حالت `202` هنوز خود داده خاک برنگشته، فقط شروع فرآیند واکشی اعلام می‌شود. - - تطابق location بر اساس `lat/lon` گرد شده تا 6 رقم است؛ برای نقاط خیلی نزدیک ممکن است حساسیت مکانی محدود شود. - - کیفیت نهایی کاملا وابسته به سرویس بیرونی خاک و کامل شدن depthهاست. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - این API خودش مدل پیش‌بینی ندارد؛ ریسک اصلی از ناقص بودن داده منبع یا incomplete بودن رکورد است. -- مناسب برای: - - واکشی داده خام خاک و persistence. -- نامناسب برای: - - استنتاج agronomy بدون بررسی منبع و completeness. -- سطح اعتماد: - - `زیاد` وقتی `source=database` و location کامل است. - - `کم` وقتی فقط `task_id` گرفته‌اید و هنوز داده واقعی ندارید. - -### `GET /api/soil-data/tasks/{task_id}/status/` - -- منبع داده: وضعیت Celery + نتیجه ذخیره شده در DB. -- ضعف‌ها: - - ممکن است task موفق شده باشد ولی رکورد location هنوز کامل نباشد. - - خروجی `SUCCESS` لزوما به معنی کیفیت agronomic داده نیست؛ فقط completion فنی را نشان می‌دهد. -- سطح اعتماد: - - `متوسط` برای وضعیت فنی task. - - `کم تا متوسط` برای اعتماد به خود محتوای خاک، بسته به کامل بودن رکورد. - -### `POST /api/soil-data/ndvi-health/` - -- منبع داده: observation ماهواره‌ای NDVI در `location_data.ndvi`. -- ضعف‌ها: - - اگر observation موجود نباشد، کارت عملا به وضعیت `Unavailable` می‌رسد. - - تفسیر NDVI ساده است و به تنهایی برای تشخیص علت تنش کافی نیست. - - NDVI میانگین مزرعه است؛ heterogeneity داخل مزرعه را کامل نشان نمی‌دهد. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - NDVI فقط شاخص پوشش گیاهی است، نه تشخیص علت؛ کمبود آب، بیماری، تغذیه یا خطای سنجش ممکن است با هم اشتباه شوند. -- سطح اعتماد: - - `متوسط` برای پایش کلی وضعیت پوشش گیاهی. - - `کم` برای تصمیم‌گیری علّی یا درمانی. - ---- - -## 2) Soile API - -### `POST /api/soile/moisture-heatmap/` - -- منبع داده: - - latest measurement هر سنسور - - وزن‌دهی تازگی داده - - جریمه outlier - - interpolation از نوع weighted IDW - - mask مرز مزرعه -- ضعف‌ها: - - history واقعی سنسورها هنوز مدل نمی‌شود. - - drift سنسور، calibration uncertainty و quality assurance واقعی لحاظ نشده‌اند. - - depth-specific map به صورت measured واقعی نیست و بخشی از لایه‌ها از soil profile مشتق می‌شوند. - - uncertainty به صورت heuristic برآورد می‌شود. - - با تراکم سنسور پایین یا farm نامتقارن، heatmap بیشتر visualization است تا اندازه‌گیری دقیق. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - مدل interpolation علمی پیشرفته مثل kriging یا مدل uncertainty مکانی ندارد. - - خروجی برای zoning دقیق agronomy کافی نیست. -- سطح اعتماد: - - `متوسط` برای visualization و درک trend کلی. - - `کم` برای تصمیم آبیاری دقیق نقطه‌ای یا نسخه agronomy. - -### `POST /api/soile/health-summary/` - -- منبع داده: داده سنسور + خلاصه‌ساز سلامت خاک. -- ضعف‌ها: - - خلاصه سلامت ذاتا compression از چند متریک است و همه context مزرعه را در بر نمی‌گیرد. - - اگر سنسورهای مزرعه کم‌تعداد یا داده‌هایشان قدیمی باشد، summary گمراه‌کننده می‌شود. -- سطح اعتماد: - - `متوسط` برای dashboard summary. - - `کم تا متوسط` برای تصمیم اصلاح خاک بدون نمونه‌برداری میدانی. - -### `POST /api/soile/anomaly-detection/` - -- منبع داده: anomaly آماری + تفسیر RAG. -- ضعف‌ها: - - anomaly detection بر اساس context موجود است و به history و baseline کامل چندفصلی متکی نیست. - - تفسیر نهایی توسط RAG تولید می‌شود و deterministic نیست. - - اگر anomaly آماری واقعی نباشد، LLM ممکن است فقط آن را با متن تخصصی بسط دهد. -- سطح اعتماد: - - `متوسط` برای پیدا کردن موارد مشکوک. - - `کم` برای diagnosis نهایی بدون تایید کارشناس. - ---- - -## 3) Weather API - -### `POST /api/weather/farm-card/` - -- منبع داده: forecastهای ذخیره‌شده در DB از سرویس هواشناسی خارجی. -- ضعف‌ها: - - docstring سرویس هنوز misleading است، ولی کد واقعا `requests.get` می‌زند. - - اگر forecast تازه نباشد، کارت weather کهنه می‌شود. - - کارت فقط summary ساده از forecast است و uncertainty پیش‌بینی هوا را منتقل نمی‌کند. - - اگر forecast موجود نباشد، خروجی عملا صفر/نامشخص می‌شود. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - مشکل اصلی فرمول نیست؛ dependency به forecast خارجی و freshness داده است. -- سطح اعتماد: - - `متوسط تا زیاد` وقتی forecast تازه و کامل موجود باشد. - - `کم` وقتی داده weather ناقص یا قدیمی باشد. - -### `POST /api/weather/water-need-prediction/` - -- منبع داده: - - محاسبات ET/crop profile از `irrigation.evapotranspiration` - - forecast هفت‌روزه - - تفسیر تکمیلی از RAG -- ضعف‌ها: - - دقت خروجی به کیفیت crop profile، growth stage و راندمان روش آبیاری وابسته است. - - horizon فقط کوتاه‌مدت است. - - insight متنی توسط LLM تولید می‌شود و fallback هم دارد. - - اگر forecast ضعیف باشد، کل محاسبه نیاز آبی ضعیف می‌شود. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - برای برنامه‌ریزی کوتاه‌مدت خوب است، اما irrigation scheduling دقیق در مزرعه واقعی هنوز به سنسور و بازخورد عملی نیاز دارد. -- سطح اعتماد: - - `متوسط` برای برنامه‌ریزی 3 تا 7 روزه. - - `کم تا متوسط` برای تصمیم اجرایی بدون کنترل میدانی. - ---- - -## 4) Economy API - -### `POST /api/economy/overview/` - -- منبع داده: `mock` صریح در سرویس. -- ضعف‌ها: - - کل خروجی ساختگی است. - - اعداد هزینه آب، صرفه‌جویی، درآمد و هزینه کود از مزرعه واقعی استخراج نمی‌شوند. - - trend chart نیز mock است. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - این API فعلا برای عملیات واقعی قابل اتکا نیست. -- سطح اعتماد: - - `خیلی کم` - ---- - -## 5) Plant API - -### `GET|POST /api/plants/` و `GET|PUT|PATCH|DELETE /api/plants/{id}/` - -- منبع داده: رکوردهای DB. -- ضعف‌ها: - - اعتماد به محتوای agronomic رکوردها بستگی به کسی دارد که داده را وارد کرده است. - - validation دامنه‌ای یا علمی عمیق روی محتوا دیده نمی‌شود. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - مشکل اصلی از جنس data governance است، نه الگوریتم. -- سطح اعتماد: - - `زیاد` برای این‌که رکوردی که ذخیره شده همان برگردد. - - `متوسط` برای اعتماد علمی به محتوای متنی رکوردها. - -### `POST /api/plants/fetch-info/` - -- منبع داده: قرار بوده API خارجی باشد، ولی فعلا `None` برمی‌گرداند. -- ضعف‌ها: - - عملا پیاده‌سازی نشده است. - - در حالت فعلی `503` می‌دهد. -- سطح اعتماد: - - `خیلی کم` - ---- - -## 6) Farm Data API - -### `POST /api/farm-data/` - -- منبع داده: - - boundary مزرعه - - sensor payload - - واکشی خودکار location و weather -- نقاط قوت فعلی: - - مرکز مزرعه با centroid هندسی polygon محاسبه می‌شود، نه average ساده. - - merge سنسورها flat و overwrite خاموش ندارد؛ payload هر sensor جدا می‌ماند. -- ضعف‌ها: - - centroid هندسی هنوز برای همه use-caseها بهترین نقطه نمونه‌برداری نیست؛ مخصوصا در مزرعه‌های خیلی کشیده یا چندبخشی. - - health داده به شدت به درست بودن boundary ورودی وابسته است. - - location و weather فقط برای یک نقطه مرکزی resolve می‌شوند؛ variability در سطح مزرعه از بین می‌رود. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - این API بیشتر data-ingestion است؛ ضعف اصلی spatial representativeness است. -- سطح اعتماد: - - `زیاد` برای ذخیره‌سازی و linkage داده. - - `متوسط` برای این فرض که یک center point نماینده کل مزرعه است. - -### `GET /api/farm-data/{farm_uuid}/detail/` - -- منبع داده: - - sensor payload - - soil depth data - - weather - - plants و irrigation method - - resolved_metrics با aggregation deterministic -- نقاط قوت فعلی: - - conflict چند سنسور silent overwrite نمی‌شود. - - source هر metric در `metric_sources` مشخص می‌شود. -- ضعف‌ها: - - aggregation متریک‌های عددی در تعارض، به average ختم می‌شود؛ این روش همیشه از نظر agronomy بهترین strategy نیست. - - sensor locality و timestamp تفکیک‌شده در resolved_metrics نهایی از بین می‌رود. - - برای farm چندناحیه‌ای، یک resolved metric ممکن است variance مهم را پنهان کند. -- سطح اعتماد: - - `متوسط تا زیاد` برای unified context. - - `متوسط` برای تصمیم نقطه‌ای داخل مزرعه. - -### `POST /api/farm-data/parameters/` - -- منبع داده: DB و log تغییرات. -- ضعف‌ها: - - افزودن پارامتر جدید به معنی معتبر بودن agronomic آن نیست. - - کیفیت metadata وابسته به داده ورودی کاربر است. -- سطح اعتماد: - - `زیاد` برای ثبت و نگهداری metadata. - - `متوسط` برای معنی علمی metadata. - ---- - -## 7) Irrigation API - -### `GET|POST /api/irrigation/` و `GET /api/irrigation/{id}/` - -- منبع داده: رکوردهای روش آبیاری در DB. -- ضعف‌ها: - - درصد راندمان و توضیحات روش آبیاری اگر دستی وارد شده باشند، لزوما calibrated برای مزرعه خاص نیستند. -- سطح اعتماد: - - `زیاد` برای بازگرداندن داده ذخیره‌شده. - - `متوسط` برای کاربرد agronomic عمومی. - -### `POST /api/irrigation/recommend/` - -- منبع داده: - - sensor/farm context - - plant data - - irrigation method - - محاسبات FAO-56 و optimizer - - متن نهایی از RAG -- نقاط قوت فعلی: - - پاسخ نهایی provenance دارد و مشخص می‌کند کدام فیلد از LLM و کدام از fallback آمده است. -- ضعف‌ها: - - merge نهایی هنوز deterministic است و برای پایداری UI fallback را نگه می‌دارد. - - اگر crop profile، weather یا irrigation method درست نباشد، recommendation قابل اتکا نیست. - - optimizer و LLM هر دو وابسته به کیفیت ورودی هستند. - - خروجی ممکن است از نظر ظاهر کامل باشد، حتی وقتی بخشی از آن fallback است. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - برای تصمیم نهایی آبیاری در مزرعه، باید provenance و fallback بودن بخش‌ها دیده شود. -- سطح اعتماد: - - `متوسط` وقتی داده مزرعه کامل باشد و fallback محدود باشد. - - `کم تا متوسط` وقتی بخش‌های زیادی fallback شده باشند. - -### `POST /api/irrigation/water-stress/` - -- منبع داده: - - ابتدا سرویس crop simulation - - در صورت خطا fallback به فرمول ساده `clamp(round(35 - (soil_moisture / 2)), 0, 100)` -- ضعف‌ها: - - اگر engine اصلی خطا بدهد هنوز به فرمول ساده سنسوری fallback می‌شود. - - fallback فقط از `soil_moisture` استفاده می‌کند و ET0، بارش، root depth، نوع گیاه، مرحله رشد و روند زمانی را نادیده می‌گیرد. - - بنابراین ظاهر endpoint ممکن است simulation-based باشد اما در failure عملا heuristic برگرداند. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - هر خروجی‌ای که `sourceMetric.engine = sensor_fallback` داشته باشد برای KPI دقیق تنش آبی قابل اتکا نیست. -- سطح اعتماد: - - `متوسط` اگر واقعا از simulation engine آمده باشد. - - `کم` اگر fallback سنسوری فعال شده باشد. - ---- - -## 8) Fertilization API - -### `POST /api/fertilization/recommend/` - -- منبع داده: - - sensor/farm context - - plant data - - optimizer - - RAG -- نقاط قوت فعلی: - - مانند irrigation، provenance بخش‌ها اضافه شده و تشخیص fallback بهتر شده است. -- ضعف‌ها: - - merge نهایی هنوز deterministic است و ساختار UI را حتی در خروجی ضعیف حفظ می‌کند. - - وضعیت واقعی عناصر غذایی مزرعه به sample quality و coverage محدود است. - - مدل crop-specific کامل و region-specific صریح دیده نمی‌شود. - - پاسخ متنی نهایی LLM-based است. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - برای نسخه کود نهایی بدون آزمون خاک و تایید agronomist کافی نیست. -- سطح اعتماد: - - `متوسط` برای پیشنهاد اولیه. - - `کم تا متوسط` برای prescription نهایی. - ---- - -## 9) Pest & Disease API - -### `POST /api/pest-disease/detect/` - -- منبع داده: - - تصویر ارسالی - - context مزرعه - - knowledge base - - LLM vision / RAG -- ضعف‌ها: - - دقت شدیدا به کیفیت، زاویه، نور و فاصله تصویر وابسته است. - - خروجی deterministic نیست. - - confidence هم از خود مدل می‌آید و لزوما calibrated نیست. - - ممکن است علائم تغذیه‌ای، abiotic stress و بیماری با هم اشتباه شوند. -- سطح اعتماد: - - `کم تا متوسط` برای triage اولیه. - - `کم` برای تصمیم سم‌پاشی یا تشخیص قطعی. - -### `POST /api/pest-disease/risk/` - -- منبع داده: - - context مزرعه - - weather - - knowledge base - - RAG -- ضعف‌ها: - - هرچند intent آن RAG-based است، در لایه سرویس هنوز fallback heuristic نیز وجود دارد. - - risk forecast region-specific و crop-specific calibrated model ندارد. - - خروجی نهایی به retrieval quality و model behavior وابسته است. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - این endpoint باید فعلا به عنوان expert-assist دیده شود، نه predictive model قطعی. -- سطح اعتماد: - - `متوسط` برای آگاهی از ریسک کلی. - - `کم` برای عملیات خودکار یا قطعی. - -## 10) Crop Simulation API - -### `POST /api/crop-simulation/growth/` - -- منبع داده: - - weather - - soil - - crop parameters - - agromanagement - - PCSE engine -- ضعف‌ها: - - اگر پارامترهای واقعی مزرعه ناقص باشند، سیستم از defaultهای زیادی استفاده می‌کند. - - بخشی از پارامترها مثل `ELEV`, `IRRAD`, `RDMSOL`, `WAV`, `NAVAILI` پیش‌فرض‌محور هستند. - - کیفیت شبیه‌سازی به کیفیت profile گیاه و agromanagement وابسته است. -- نکته مهم: - - در failure فعلی، خطا propagate می‌شود و دیگر fallback projection در مسیر اصلی برگردانده نمی‌شود؛ این از نظر شفافیت خوب است. -- سطح اعتماد: - - `متوسط` وقتی ورودی‌ها خوب باشند و PCSE درست اجرا شود. - - `کم تا متوسط` وقتی بخش زیادی از ورودی‌ها default باشند. - -### `GET /api/crop-simulation/growth/{task_id}/status/` - -- منبع داده: status تسک + نتیجه شبیه‌سازی. -- ضعف‌ها: - - وضعیت `SUCCESS` به معنی خوب بودن ورودی‌های مدل نیست. - - page‌بندی timeline فقط presentation است و تضمین کیفیت علمی مدل نمی‌دهد. -- سطح اعتماد: - - `زیاد` برای وضعیت فنی task. - - `متوسط` برای محتوای simulation output. - -### `POST /api/crop-simulation/current-farm-chart/` - -- منبع داده: simulation engine + context فعلی مزرعه. -- ضعف‌ها: - - chart از شبیه‌سازی می‌آید، نه مشاهده واقعی برگ/بیوماس/weight. - - `leaf_count_estimate` و برخی شاخص‌ها derived هستند، نه measured. - - اگر weather forecast یا plant profile ضعیف باشد، chart گمراه‌کننده می‌شود. -- سطح اعتماد: - - `متوسط` برای KPI بصری و trend. - - `کم تا متوسط` برای تصمیم agronomy دقیق. - -### `POST /api/crop-simulation/harvest-prediction/` - -- منبع داده: - - growth simulation - - GDD profile - - extrapolation تا رسیدگی -- ضعف‌ها: - - اگر maturity داخل بازه forecast رخ ندهد، تاریخ برداشت با extrapolation از میانگین رشد برآورد می‌شود. - - بنابراین بخش‌هایی از تاریخ برداشت پیش‌بینی‌شده ممکن است فراتر از خروجی واقعی engine باشند. - - وابستگی زیاد به `required_gdd_for_maturity` و current profile گیاه دارد. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - برای برنامه‌ریزی برداشت به عنوان estimate مناسب است، نه تعهد تقویمی قطعی. -- سطح اعتماد: - - `متوسط` - -### `POST /api/crop-simulation/yield-prediction/` - -- منبع داده: خروجی شبیه‌سازی رشد و تبدیل آن به yield KPI. -- ضعف‌ها: - - عملکرد نهایی به کیفیت شبیه‌سازی upstream وابسته است. - - اگر داده soil fertility، weather یا management واقعی نباشند، yield estimate فقط تقریبی است. - - حمایت از uncertainty interval یا confidence band دیده نمی‌شود. -- سطح اعتماد: - - `متوسط رو به کم` - ---- - -## 11) Farm Alerts API - -### `POST /api/farm-alerts/tracker/` - -- منبع داده: - - rule-based tracker - - thresholdهای hard-coded - - خلاصه و notification با RAG/LLM -- ضعف‌ها: - - alert tracker پایه، heuristic و threshold-driven است. - - آستانه‌های moisture، pH، EC، fungal risk و ... hard-coded هستند. - - region-specific و crop-specific calibration محدود است. - - اگر LLM JSON بد برگرداند، fallback rule-based جای آن را می‌گیرد. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - برای هشدارآگاهی مناسب است، نه برای auto-actuation. -- سطح اعتماد: - - `متوسط` برای شناسایی موارد مشکوک. - - `کم تا متوسط` برای اقدام خودکار. - -### `POST /api/farm-alerts/timeline/` - -- منبع داده: همان tracker + LLM timeline. -- ضعف‌ها: - - timeline بیشتر narration است تا سنجش مستقل. - - اگر مبنای alertها heuristic باشد، timeline فقط همان heuristic را بازبسته‌بندی می‌کند. -- سطح اعتماد: - - `کم تا متوسط` - ---- - -## 12) RAG API - -### `POST /api/rag/chat/` - -- منبع داده: - - farm context - - history - - images احتمالی - - retrieval از knowledge base - - LLM -- ضعف‌ها: - - پاسخ stream می‌شود و structure قراردادی سختی ندارد. - - hallucination، retrieval miss، و dependence به prompt/tone وجود دارد. - - برای سوال‌های policy/diagnosis/operation ممکن است confident but wrong باشد. -- اگر فرمول/اطلاعات قابل اتکا نباشد: - - این endpoint باید دستیار تحلیلی در نظر گرفته شود، نه source of truth. -- سطح اعتماد: - - `کم تا متوسط` - - ---- - -## 13) جمع‌بندی نهایی بر اساس کاربرد - -### APIهای مناسب برای استفاده عملی‌تر - -- `POST /api/farm-data/` -- `GET /api/farm-data/{farm_uuid}/detail/` -- `GET|POST /api/soil-data/` -- `POST /api/weather/farm-card/` - -این‌ها بیشتر data-centric هستند و اگر داده منبع خوب باشد، قابل اتکاترند. - -### APIهای مناسب برای dashboard و insight، نه تصمیم قطعی - -- `POST /api/soile/moisture-heatmap/` -- `POST /api/soile/health-summary/` -- `POST /api/weather/water-need-prediction/` -- `POST /api/crop-simulation/current-farm-chart/` -- `POST /api/crop-simulation/harvest-prediction/` -- `POST /api/crop-simulation/yield-prediction/` -- `POST /api/farm-alerts/tracker/` -- `POST /api/farm-alerts/timeline/` - -### APIهای کم‌اعتماد یا نیازمند بازطراحی - -- `POST /api/economy/overview/` -- `POST /api/plants/fetch-info/` -- `POST /api/irrigation/water-stress/` در حالت fallback -- `POST /api/pest-disease/detect/` برای diagnosis قطعی -- `POST /api/pest-disease/risk/` -- `POST /api/rag/chat/` برای پاسخ‌های حساس و اجرایی - ---- - -## 14) پیشنهاد اولویت اصلاح - -1. حذف fallback ساده از `water-stress` یا حداقل expose کردن صریح نوع engine در top-level response -2. جایگزینی mock در `economy` با data pipeline واقعی -3. پیاده‌سازی واقعی `plants/fetch-info` -4. افزودن uncertainty و freshness صریح به heatmap و water-need outputs -5. crop-specific و region-specific کردن alert/risk thresholds -6. اضافه کردن confidence واقعی و source provenance استاندارد به تمام endpointهای RAG-based +# ممیزی وضعیت واقعی APIها + +این سند فقط درباره reliability نیست؛ به‌عنوان یک مرجع فشرده برای `وضعیت واقعی routeها` و semantics فعلی هم استفاده می‌شود. + +## قانون runtime در برابر seed + +- seed/fixture/bootstrap data مجاز است و باید برای bootstrap، dev و test باقی بماند. +- mock/sample/demo data نباید در runtime application code به عنوان fallback موفق استفاده شود. +- اگر داده واقعی موجود نیست، پاسخ باید `empty state` یا `failure contract` صریح باشد. + +## جدول مرجع وضعیت + +| Endpoint | وضعیت | semantics | توضیح کوتاه | +|---|---:|---|---| +| `POST /api/rag/chat/` | `implemented` | `live AI` | route واقعی AI | +| `POST /api/farm-alerts/tracker/` | `implemented` | `live AI` | route واقعی AI؛ معادل backend آن cached است | +| `GET|POST /api/soil-data/` | `implemented` | `provider-backed / task-backed` | route واقعی AI | +| `GET /api/soil-data/tasks/{task_id}/status/` | `implemented` | `async status` | route واقعی AI | +| `POST /api/soil-data/ndvi-health/` | `implemented` | `provider-backed` | route واقعی AI | +| `POST /api/soile/*` | `implemented` | `AI-owned derived output` | routeهای واقعی AI | +| `POST /api/farm-data/` | `implemented` | `AI-owned derived write-model` | route واقعی AI | +| `GET /api/farm-data/{farm_uuid}/detail/` | `implemented` | `AI-owned derived read-model` | route واقعی AI | +| `POST /api/farm-data/parameters/` | `implemented` | `AI-owned config` | route واقعی AI | +| `POST /api/weather/farm-card/` | `implemented` | `provider-backed` | route واقعی AI | +| `POST /api/weather/water-need-prediction/` | `implemented` | `derived output` | route واقعی AI | +| `POST /api/economy/overview/` | `implemented` | `provider-backed / persisted` | route واقعی AI | +| `GET|POST /api/plants/` | `implemented` | `canonical AI plant service` | route واقعی AI | +| `GET|PUT|PATCH|DELETE /api/plants/{pk}/` | `implemented` | `canonical AI plant service` | route واقعی AI | +| `POST /api/plants/fetch-info/` | `implemented` | `provider-backed enrichment` | route واقعی AI | +| `POST /api/pest-disease/detect/` | `implemented` | `live AI` | route واقعی AI | +| `POST /api/pest-disease/risk/` | `implemented` | `derived output` | route واقعی AI | +| `GET|POST /api/irrigation/` | `implemented` | `AI-owned config + live recommendation support` | route واقعی AI | +| `GET|PUT|PATCH|DELETE /api/irrigation/{pk}/` | `implemented` | `AI-owned config` | route واقعی AI | +| `POST /api/irrigation/recommend/` | `implemented` | `live AI + deterministic context` | route واقعی AI | +| `POST /api/irrigation/plan-from-text/` | `implemented` | `live AI parsing` | route واقعی AI | +| `POST /api/irrigation/water-stress/` | `implemented` | `AI-owned derived output` | route واقعی AI | +| `POST /api/fertilization/recommend/` | `implemented` | `live AI + optimizer context` | route واقعی AI | +| `POST /api/fertilization/plan-from-text/` | `implemented` | `live AI parsing` | route واقعی AI | +| `POST /api/crop-simulation/current-farm-chart/` | `implemented` | `live AI inference` | route واقعی AI | +| `POST /api/crop-simulation/harvest-prediction/` | `implemented` | `live AI inference` | route واقعی AI | +| `GET /api/crop-simulation/yield-harvest-summary/` | `implemented` | `AI-owned derived output` | route واقعی AI | +| `POST /api/crop-simulation/yield-prediction/` | `implemented` | `live AI inference` | route واقعی AI | +| `POST /api/crop-simulation/growth/` | `implemented` | `async live AI inference` | route واقعی AI | +| `GET /api/crop-simulation/growth/{task_id}/status/` | `implemented` | `async status` | route واقعی AI | + +## مواردی که نباید به‌عنوان route واقعی AI معرفی شوند + +| Endpoint | تصمیم | +|---|---| +| `POST /api/farm-alerts/timeline/` | `missing` | +| `GET /api/fertilization/recommend/{task_id}/status/` | `stub/contract-only` | +| `GET /api/irrigation/recommend/{task_id}/status/` | `stub/contract-only` | +| هر route موجود فقط در `Backend/external_api_adapter/json/ai/index.json` و بدون registration واقعی | `stub/contract-only` | + +## توضیح مهم درباره mock/spec + +فایل `Backend/external_api_adapter/json/ai/index.json` باید به‌عنوان `contract/mock catalog` دیده شود، نه لیست endpointهای تضمین‌شده‌ی production. +اگر endpoint فقط در آن فایل وجود دارد ولی در `Ai/config/urls.py` و routeهای اپ‌ها ثبت نشده، وضعیت آن `stub/contract-only` است. + +## Ownership مهم + +- plant catalog canonical در Backend شروع می‌شود و AI snapshot/read-model آن را ingest می‌کند. +- `farm_data` در AI facade canonical برای مصرف AI روی farm/sensor/plant assignment است. +- relation قدیمی `SensorData.plants` transitional است و نباید به‌عنوان source-of-truth جدید مستند شود. + +## Known Gaps / Follow-up + +- schema UI غیرفعال است؛ audit docs منبع فعلی truth هستند. +- بعضی endpointها در backend و AI هر دو وجود دارند اما semantics آن‌ها متفاوت است؛ همیشه live/cached/proxy بودن را جداگانه مستند کنید. diff --git a/APPS_URLS_AUDIT.md b/APPS_URLS_AUDIT.md index 852dc97..1a17930 100644 --- a/APPS_URLS_AUDIT.md +++ b/APPS_URLS_AUDIT.md @@ -1,347 +1,55 @@ -# گزارش کامل اپ‌ها، URLها و بررسی ضعف‌های پیاده‌سازی - -## خلاصه اجرایی - -این مخزن یک پروژه Django با چند اپ API مستقل است. از نظر مسیرها، ساختار کلی مناسب است، اما چند نقطه مهم در پیاده‌سازی دیده می‌شود: - -- بعضی endpointها مستقیما `mock` یا `stub` هستند و خروجی واقعی تولید نمی‌کنند. -- بعضی endpointها در صورت خطای LLM یا نبود سرویس بیرونی، خروجی fallback می‌دهند که واقعی است ولی کاملا مدل‌محور/تقریبی است. -- چند ضعف ساختاری وجود دارد که می‌تواند باعث خروجی ناقص، 405 ناخواسته، یا داده ظاهرا واقعی اما غیرقابل اتکا شود. -- بخش Weather و Plant هنوز به سرویس بیرونی واقعی کامل وصل نشده‌اند. -- در چند بخش، محاسبات ساده‌سازی شده‌اند و برای KPI عملیاتی یا تصمیم‌گیری دقیق کشاورزی کافی نیستند. - ---- - -## URLهای اصلی پروژه - -### URLهای عمومی - -| Method | URL | توضیح | -|---|---|---| -| GET | `/admin/` | پنل ادمین Django | -| GET | `/api/schema/` | OpenAPI schema | -| GET | `/api/docs/` | Swagger UI | -| GET | `/api/redoc/` | ReDoc | - -### App: RAG - -Base: `/api/rag/` - -| Method | URL | توضیح | -|---|---|---| -| POST | `/api/rag/chat/` | چت RAG به صورت stream | - -### App: Farm Alerts - -Base: `/api/farm-alerts/` - -| Method | URL | توضیح | -|---|---|---| -| POST | `/api/farm-alerts/tracker/` | تحلیل tracker هشدارها | -| POST | `/api/farm-alerts/timeline/` | ساخت timeline هشدارها | - -### App: Location Data / Soil Data - -Base: `/api/soil-data/` - -| Method | URL | توضیح | -|---|---|---| -| GET | `/api/soil-data/` | واکشی داده خاک با `lat` و `lon` | -| POST | `/api/soil-data/` | واکشی داده خاک با body | -| GET | `/api/soil-data/tasks//status/` | وضعیت تسک خاک | -| POST | `/api/soil-data/ndvi-health/` | کارت NDVI مزرعه | - -### App: Soile - -Base: `/api/soile/` - -| Method | URL | توضیح | -|---|---|---| -| POST | `/api/soile/anomaly-detection/` | تحلیل ناهنجاری خاک | -| POST | `/api/soile/health-summary/` | خلاصه سلامت خاک | -| POST | `/api/soile/moisture-heatmap/` | heatmap رطوبت خاک | - -### App: Farm Data - -Base: `/api/farm-data/` - -| Method | URL | توضیح | -|---|---|---| -| POST | `/api/farm-data/` | ایجاد/آپدیت داده مزرعه | -| GET | `/api/farm-data//detail/` | جزئیات تجمیعی مزرعه | -| POST | `/api/farm-data/parameters/` | ایجاد/ویرایش پارامتر سنسور | - -### App: Weather - -Base: `/api/weather/` - -| Method | URL | توضیح | -|---|---|---| -| POST | `/api/weather/farm-card/` | کارت وضعیت آب‌وهوا | -| POST | `/api/weather/water-need-prediction/` | پیش‌بینی نیاز آبی 7 روز آینده | - -### App: Economy - -Base: `/api/economy/` - -| Method | URL | توضیح | -|---|---|---| -| POST | `/api/economy/overview/` | نمای اقتصادی مزرعه | - -### App: Plant - -Base: `/api/plants/` - -| Method | URL | توضیح | -|---|---|---| -| GET | `/api/plants/` | لیست گیاهان | -| POST | `/api/plants/` | ایجاد گیاه | -| GET | `/api/plants//` | جزئیات گیاه | -| PUT | `/api/plants//` | ویرایش کامل گیاه | -| PATCH | `/api/plants//` | ویرایش جزئی گیاه | -| DELETE | `/api/plants//` | حذف گیاه | -| POST | `/api/plants/fetch-info/` | دریافت اطلاعات گیاه از API بیرونی | - -### App: Pest & Disease - -Base: `/api/pest-disease/` - -| Method | URL | توضیح | -|---|---|---| -| POST | `/api/pest-disease/detect/` | تشخیص آفت/بیماری از تصویر | -| POST | `/api/pest-disease/risk/` | پیش‌بینی ریسک آفات و بیماری | - -### App: Irrigation - -Base: `/api/irrigation/` - -| Method | URL | توضیح | -|---|---|---| -| GET | `/api/irrigation/` | لیست روش‌های آبیاری | -| POST | `/api/irrigation/` | ایجاد روش آبیاری | -| GET | `/api/irrigation//` | جزئیات روش آبیاری | -| POST | `/api/irrigation/recommend/` | توصیه آبیاری | -| POST | `/api/irrigation/water-stress/` | شاخص تنش آبی | - -> نکته: در کد متدهای `PUT/PATCH/DELETE` برای روش آبیاری نوشته شده‌اند، اما به کلاس اشتباه وصل شده‌اند و عملا روی route جزئیات اعمال نمی‌شوند. - -### App: Fertilization - -Base: `/api/fertilization/` - -| Method | URL | توضیح | -|---|---|---| -| POST | `/api/fertilization/recommend/` | توصیه کودهی | - -### App: Crop Simulation - -Base: `/api/crop-simulation/` - -| Method | URL | توضیح | -|---|---|---| -| POST | `/api/crop-simulation/current-farm-chart/` | نمودار شبیه‌سازی وضعیت فعلی مزرعه | -| POST | `/api/crop-simulation/harvest-prediction/` | پیش‌بینی برداشت | -| POST | `/api/crop-simulation/yield-prediction/` | پیش‌بینی عملکرد | -| POST | `/api/crop-simulation/growth/` | شروع شبیه‌سازی رشد | -| GET | `/api/crop-simulation/growth//status/` | وضعیت شبیه‌سازی رشد | - ---- - -## خروجی‌های Mock / Stub / Sample قطعی - -### 1) Economy کاملا mock است -- سرویس `EconomicOverviewService` مستقیم `source: "mock"` برمی‌گرداند و همه داده‌ها ثابت هستند: `economy/services.py:7` -- خود description ویو هم صراحتا می‌گوید فعلا mock است: `economy/views.py:31` -- نتیجه: endpoint `POST /api/economy/overview/` فعلا برای استفاده واقعی قابل اتکا نیست. - -### 2) Plant Fetch Info هنوز پیاده‌سازی نشده -- تابع اتصال بیرونی عملا `None` برمی‌گرداند: `plant/services.py:10` -- endpoint هم در این حالت `503` می‌دهد: `plant/views.py:292` -- نتیجه: `POST /api/plants/fetch-info/` فعلا هیچ داده واقعی از سرویس خارجی نمی‌گیرد. - -### 3) Weather داده نمونه seeded دارد -- migration داده آب‌وهوای نمونه 7 روزه برای همه `SoilLocation`ها می‌سازد: `weather/migrations/0003_seed_weather_forecasts.py:1` -- نتیجه: در محیطی که migration اجرا شده باشد، بخشی از خروجی‌های Weather ممکن است از sample data بیایند نه API واقعی. - ---- - -## خروجی‌هایی که mock مستقیم نیستند ولی fallback / تقریبی می‌دهند - -### 1) Weather API از نظر مستندات و رفتار واقعی کد ناهماهنگ است -- داک‌استرینگ هنوز نوشته `TODO: پیاده‌سازی اتصال واقعی به API`: `weather/services.py:23` -- اما خود تابع در عمل `requests.get(...)` می‌زند و `response.json()` برمی‌گرداند: `weather/services.py:67` -- مسیر `no_data` در کد وجود دارد، ولی با پیاده‌سازی فعلی بیشتر یک branch دفاعی/قدیمی است تا رفتار اصلی: `weather/services.py:149` -- در `farm_data` اگر نتیجه weather برابر `no_data` باشد، خطا محسوب نمی‌شود و فرایند ادامه پیدا می‌کند: `farm_data/services.py:143` -- نتیجه: طراحی فعلی هنوز اجازه می‌دهد مزرعه بدون weather قابل اتکا ثبت/آپدیت شود، و این ابهام با وجود داده‌های seed شده شدیدتر می‌شود. - -### 2) NDVI در نبود تنظیمات ماهواره‌ای خروجی unavailable می‌دهد -- اگر `SATELLITE_NDVI_ENDPOINT` و `SATELLITE_NDVI_API_KEY` تنظیم نشده باشد، client عملا داده نمی‌آورد: `location_data/remote_sensing.py:77` -- در این حالت کارت NDVI با `vegetation_health_class = "Unavailable"` و پیام نبود داده ماهواره‌ای برمی‌گردد: `location_data/ndvi.py:33` -- نتیجه: `POST /api/soil-data/ndvi-health/` ممکن است پاسخ موفق بدهد ولی داده واقعی NDVI نداشته باشد. - -### 3) Farm Alerts در خطای LLM fallback می‌سازد -- اگر LLM خطا بدهد، خروجی خالی برمی‌گردد: `farm_alerts/services.py:353` -- سپس tracker و timeline از fallback داخلی ساخته می‌شوند: `farm_alerts/services.py:376`, `farm_alerts/services.py:413` -- نتیجه: خروجی این endpointها همیشه ممکن است LLM-native نباشد و از هشدارهای ساختاریافته داخلی ساخته شده باشد. - -### 4) Soil Anomaly در خطای LLM fallback تحلیلی می‌دهد -- در exception خروجی fallback بازگردانده می‌شود: `rag/services/soil_anomaly.py:181` -- حتی اگر JSON مدل نامعتبر باشد، fallback جایگزین می‌شود: `rag/services/soil_anomaly.py:192` -- نتیجه: `POST /api/soile/anomaly-detection/` ممکن است تحلیل واقعی مدل زبانی نباشد. - -### 5) Pest & Disease detect/risk در خطای LLM fallback دارند -- تشخیص تصویر در failure به fallback برمی‌گردد: `rag/services/pest_disease.py:321` -- ریسک آفات/بیماری هم در failure به fallback برمی‌گردد: `rag/services/pest_disease.py:388` -- نتیجه: پاسخ ممکن است ساختاری و معتبر باشد، اما برآمده از rule/fallback باشد نه inference کامل مدل. - -### 6) Water Need Prediction insight در failure fallback می‌دهد -- در خطای LLM fallback summary برمی‌گردد: `rag/services/water_need_prediction.py:165` -- نتیجه: لایه insight توضیحی همیشه تضمین نمی‌کند که از مدل آمده باشد. - -### 7) توصیه‌های آبیاری و کودهی merge با fallback می‌شوند -- پاسخ آبیاری با fallback merge می‌شود: `rag/services/irrigation.py:147` -- پاسخ کودهی هم با fallback merge می‌شود: `rag/services/fertilization.py:130` -- نتیجه: حتی وقتی LLM جواب می‌دهد، بخش‌هایی از خروجی ممکن است از template/fallback آمده باشد. - -### 8) Crop Simulation در failure از projection fallback استفاده می‌کند -- اگر engine اصلی خطا بدهد، `_run_projection_engine` استفاده می‌شود: `crop_simulation/growth_simulation.py:404` -- نتیجه: بعضی نتایج crop simulation ممکن است تقریبی باشند نه خروجی engine اصلی. - ---- - -## ضعف‌های مهم پیاده‌سازی - -### 1) باگ واضح در route جزئیات Irrigation -- route جزئیات به `IrrigationMethodDetailView` وصل است: `irrigation/urls.py:12` -- اما متدهای `put/patch/delete` داخل `WaterStressView` تعریف شده‌اند، نه داخل `IrrigationMethodDetailView`: `irrigation/views.py:231`, `irrigation/views.py:287`, `irrigation/views.py:326`, `irrigation/views.py:360` -- علاوه بر این، `WaterStressView` اصلا `_get_method` ندارد و این کد از نظر ساختاری اشتباه است. -- اثر عملی: `PUT/PATCH/DELETE /api/irrigation//` به احتمال زیاد `405 Method Not Allowed` می‌دهند و CRUD کامل عملا شکسته است. - -### 2) محاسبه تنش آبی بیش از حد ساده‌سازی شده -- فرمول فقط از `soil_moisture` استفاده می‌کند: `irrigation/indicators.py:8` -- فرمول هم یک clamp ساده است: `clamp(round(35 - (soil_moisture / 2)), 0, 100)`: `irrigation/indicators.py:16` -- عوامل مهمی مثل ET0، نوع گیاه، مرحله رشد، ظرفیت مزرعه، بافت خاک، بارش، عمق ریشه و روند زمانی لحاظ نشده‌اند. -- اثر عملی: `POST /api/irrigation/water-stress/` برای KPI واقعی یا تصمیم آبیاری دقیق کافی نیست. - -### 3) مرکز مزرعه با average ساده محاسبه می‌شود، نه centroid هندسی دقیق -- مرکز boundary با میانگین نقاط محاسبه می‌شود: `farm_data/services.py:100` -- برای polygonهای نامتقارن یا concave، این روش می‌تواند مرکز واقعی زمین را اشتباه بدهد. -- اثر عملی: داده خاک و هوا ممکن است برای نقطه‌ای غیرواقعی از مزرعه واکشی شوند. - -### 4) ادغام داده چند سنسور باعث overwrite خاموش می‌شود -- در `farm_data`, متریک‌های همه sensorها flat می‌شوند و کلیدهای تکراری روی هم overwrite می‌شوند: `farm_data/services.py:155` -- هیچ تفکیک زمانی/مکانی/اولویت‌بندی بین سنسورها وجود ندارد. -- اثر عملی: در مزرعه چند سنسوره، `resolved_metrics` ممکن است فقط آخرین سنسور iterate شده را منعکس کند. - -### 5) Weather card و Weather need کاملا وابسته به داده forecast موجود هستند -- اگر forecast نباشد، card خروجی صفر/نامشخص می‌دهد: `weather/farm_weather.py:42` -- build payload پیش‌بینی نیاز آبی هم در نبود forecast خروجی صفر می‌دهد: `weather/water_need_prediction.py:19` -- اثر عملی: endpoint ممکن است 200 بدهد اما محتوای عملیاتی نداشته باشد. - -### 6) NDVI بدون boundary واقعی از bbox پیش‌فرض استفاده می‌کند -- اگر boundary وجود نداشته باشد، bbox کوچک پیش‌فرض تولید می‌شود: `location_data/remote_sensing.py:57` -- اثر عملی: NDVI ممکن است برای محدوده تقریبی اطراف center محاسبه شود، نه مرز واقعی مزرعه. - -### 7) Heatmap رطوبت خاک مدل مکانی ساده دارد -- فقط latest measurement هر sensor استفاده می‌شود: `soile/services.py:22`, `soile/services.py:32` -- درون‌یابی از نوع IDW ساده است: `soile/services.py:46` -- history واقعی، drift سنسور، عدم قطعیت، zoning مزرعه یا depth-specific map در آن لحاظ نشده‌اند. -- اثر عملی: heatmap برای visualization خوب است ولی برای تصمیم agronomy دقیق کافی نیست. - -### 8) توصیه‌های RAG در لایه نهایی deterministic merge می‌شوند -- برای irrigation/fertilization، fallback همیشه ساختار نهایی را پر می‌کند: `rag/services/irrigation.py:153`, `rag/services/fertilization.py:135` -- اثر عملی: خروجی از نظر UI پایدار است، اما تشخیص اینکه کدام بخش واقعا از LLM آمده سخت می‌شود. - -### 9) Crop Simulation ممکن است از engine اصلی به projection تقریبی سقوط کند -- fallback projection در خطا فعال می‌شود: `crop_simulation/growth_simulation.py:404` -- اگر consumer فقط status 200 ببیند و `simulation_warning` را ignore کند، ممکن است خروجی تقریبی را واقعی فرض کند. - ---- - -## وضعیت هر اپ از نظر اتکا به داده واقعی - -| App | وضعیت کلی | توضیح | -|---|---|---| -| `economy` | ضعیف | کاملا mock | -| `plant` | متوسط رو به ضعیف | CRUD واقعی است، ولی `fetch-info` پیاده‌سازی نشده | -| `weather` | متوسط | ساختار خوب است، ولی API بیرونی/seed sample هنوز ریسک دارد | -| `farm_data` | متوسط | هسته aggregation خوب است، ولی center/merge چند سنسور ساده‌سازی شده | -| `location_data` | متوسط | SoilGrids واقعی است، NDVI وابسته به تنظیمات بیرونی است | -| `soile` | متوسط | داده واقعی دارد، اما مدل تحلیلی و interpolation ساده است | -| `pest_disease` | متوسط | fallback زیاد در endpointهای تشخیص و ریسک دارد | -| `farm_alerts` | متوسط | خروجی قابل استفاده است، ولی در failure به fallback داخلی می‌رود | -| `irrigation` | متوسط رو به ضعیف | recommendation خوب، اما water-stress ساده و CRUD detail معیوب | -| `fertilization` | متوسط | recommendation موجود است ولی heavily fallback-assisted | -| `rag` | متوسط | کارکرد خوب، اما بخشی از خروجی‌ها merge/fallback هستند | -| `crop_simulation` | متوسط | ساختار خوب، ولی fallback projection باید شفاف‌تر expose شود | - ---- - -## endpointهایی که فعلا نباید به عنوان «داده قطعی واقعی» در نظر گرفته شوند - -1. `POST /api/economy/overview/` -2. `POST /api/plants/fetch-info/` -3. `POST /api/weather/farm-card/` در محیطی که forecast نمونه/seed شده باشد -4. `POST /api/weather/water-need-prediction/` وقتی forecast واقعی موجود نیست -5. `POST /api/soil-data/ndvi-health/` وقتی satellite config ست نشده -6. `POST /api/farm-alerts/tracker/` و `POST /api/farm-alerts/timeline/` در failureهای LLM -7. `POST /api/soile/anomaly-detection/` در failureهای LLM -8. `POST /api/pest-disease/detect/` و `POST /api/pest-disease/risk/` در fallback mode -9. `POST /api/irrigation/water-stress/` برای تصمیم agronomy دقیق -10. endpointهای crop simulation وقتی `simulation_warning` نشان‌دهنده fallback engine باشد - ---- - -## اولویت اصلاح پیشنهادی - -### اولویت خیلی بالا -1. اصلاح باگ `PUT/PATCH/DELETE` در `irrigation/views.py` -2. حذف/mock-flag شفاف برای `economy` -3. تکمیل واقعی `plant/services.py` -4. شفاف‌سازی منبع forecast در weather (real / seeded / unavailable) - -### اولویت بالا -1. جلوگیری از پذیرش silent `no_data` برای weather در بعضی flowهای حساس -2. اصلاح aggregation چند سنسور در `farm_data` -3. استفاده از centroid واقعی polygon به‌جای average ساده نقاط -4. اضافه‌کردن source flag به خروجی‌های fallback در RAG/Farm Alerts/Pest Disease/Soil Anomaly - -### اولویت متوسط -1. بهبود مدل water stress -2. بهبود IDW/heatmap و استفاده از time-series -3. اضافه‌کردن flag صریح برای crop-simulation fallback - ---- - -## فایل‌های کلیدی که این گزارش بر اساس آن‌ها ساخته شده - -- `config/urls.py` -- `rag/urls.py` -- `farm_alerts/urls.py` -- `location_data/urls.py` -- `soile/urls.py` -- `farm_data/urls.py` -- `weather/urls.py` -- `economy/urls.py` -- `plant/urls.py` -- `pest_disease/urls.py` -- `irrigation/urls.py` -- `fertilization/urls.py` -- `crop_simulation/urls.py` -- `economy/services.py:7` -- `plant/services.py:10` -- `weather/services.py:19` -- `weather/migrations/0003_seed_weather_forecasts.py:1` -- `farm_data/services.py:123` -- `location_data/remote_sensing.py:75` -- `location_data/ndvi.py:21` -- `soile/services.py:98` -- `pest_disease/services.py:25` -- `irrigation/views.py:196` -- `irrigation/indicators.py:8` -- `rag/services/soil_anomaly.py:137` -- `rag/services/pest_disease.py:350` -- `rag/services/water_need_prediction.py:129` -- `rag/services/irrigation.py:147` -- `rag/services/fertilization.py:130` -- `crop_simulation/growth_simulation.py:404` +# AI Apps URL Audit + +This document lists the actual AI-service routes registered today and labels their readiness accurately. + +## Canonical AI Route Inventory + +| App | Method | Route | Status | Notes | +|---|---|---|---:|---| +| `rag` | `POST` | `/api/rag/chat/` | `implemented` | Live AI chat route. | +| `farm_alerts` | `POST` | `/api/farm-alerts/tracker/` | `implemented` | Live AI tracker route. | +| `location_data` | `GET` | `/api/soil-data/` | `implemented` | Live soil-data fetch route. | +| `location_data` | `POST` | `/api/soil-data/` | `implemented` | Live soil-data fetch route. | +| `location_data` | `GET` | `/api/soil-data/tasks//status/` | `implemented` | Live task status route. | +| `location_data` | `POST` | `/api/soil-data/ndvi-health/` | `implemented` | Live AI NDVI route. | +| `soile` | `POST` | `/api/soile/anomaly-detection/` | `implemented` | Live AI route. | +| `soile` | `POST` | `/api/soile/health-summary/` | `implemented` | Live AI route. | +| `soile` | `POST` | `/api/soile/moisture-heatmap/` | `implemented` | Live AI route. | +| `farm_data` | `POST` | `/api/farm-data/` | `implemented` | Upsert route. | +| `farm_data` | `GET` | `/api/farm-data//detail/` | `implemented` | Farm detail route. | +| `farm_data` | `POST` | `/api/farm-data/parameters/` | `implemented` | Sensor parameter create route. | +| `farm_data` | `POST` | `/api/farm-data/plants/sync/` | `implemented` | Internal sync route. | +| `weather` | `POST` | `/api/weather/farm-card/` | `implemented` | Live weather card route. | +| `weather` | `POST` | `/api/weather/water-need-prediction/` | `implemented` | Live water need prediction route. | +| `economy` | `POST` | `/api/economy/overview/` | `implemented` | Live economy route. | +| `plant` | `GET` | `/api/plants/` | `implemented` | Live route. | +| `plant` | `POST` | `/api/plants/` | `implemented` | Live route. | +| `plant` | `GET` | `/api/plants/names/` | `implemented` | Extra route not always reflected in older audits. | +| `plant` | `GET` | `/api/plants//` | `implemented` | Live route. | +| `plant` | `POST` | `/api/plants/fetch-info/` | `implemented` | Live route, but operational reliability may still be limited. | +| `pest_disease` | `POST` | `/api/pest-disease/detect/` | `implemented` | Live route. | +| `pest_disease` | `POST` | `/api/pest-disease/risk/` | `implemented` | Live route. | +| `irrigation` | `GET` | `/api/irrigation/` | `implemented` | Live route. | +| `irrigation` | `POST` | `/api/irrigation/` | `implemented` | Live route on AI service. | +| `irrigation` | `GET` | `/api/irrigation//` | `implemented` | Live route on AI service. | +| `irrigation` | `PUT` | `/api/irrigation//` | `implemented` | Live route on AI service. | +| `irrigation` | `PATCH` | `/api/irrigation//` | `implemented` | Live route on AI service. | +| `irrigation` | `DELETE` | `/api/irrigation//` | `implemented` | Live route on AI service. | +| `irrigation` | `POST` | `/api/irrigation/recommend/` | `implemented` | Live route. | +| `irrigation` | `POST` | `/api/irrigation/plan-from-text/` | `implemented` | Live route. | +| `irrigation` | `POST` | `/api/irrigation/water-stress/` | `implemented` | Live route. | +| `fertilization` | `POST` | `/api/fertilization/recommend/` | `implemented` | Live route. | +| `fertilization` | `POST` | `/api/fertilization/plan-from-text/` | `implemented` | Live route. | +| `crop_simulation` | `POST` | `/api/crop-simulation/current-farm-chart/` | `implemented` | Live route. | +| `crop_simulation` | `POST` | `/api/crop-simulation/harvest-prediction/` | `implemented` | Live route. | +| `crop_simulation` | `GET` | `/api/crop-simulation/yield-harvest-summary/` | `implemented` | Live route. | +| `crop_simulation` | `POST` | `/api/crop-simulation/yield-prediction/` | `implemented` | Live route. | +| `crop_simulation` | `POST` | `/api/crop-simulation/growth/` | `implemented` | Live route. | +| `crop_simulation` | `GET` | `/api/crop-simulation/growth//status/` | `implemented` | Live route. | + +## Important Corrections + +- `farm-alerts/timeline` is not an AI route and must not be listed as one. +- `risk-summary` belongs to backend aliasing in `Backend/pest_detection`, not to the AI `pest_disease` app. +- `plant` and `irrigation` have richer real route coverage than older audits claimed. +- `location_data`, `farm_data`, and `crop_simulation` routes are real service routes and should not be described as mock-only. diff --git a/Dockerfile b/Dockerfile index 2a8e42f..2106f74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,14 +26,7 @@ COPY requirements.txt constraints.txt ./ RUN PIP_CONSTRAINT=/app/constraints.txt \ pip install \ --prefer-binary \ - --index-url https://mirror-pypi.runflare.com/simple \ - --extra-index-url https://package-mirror.liara.ir/repository/pypi/simple \ - --extra-index-url https://mirror.cdn.ir/repository/pypi/simple \ - --extra-index-url https://mirror2.chabokan.net/pypi/simple \ --trusted-host mirror-pypi.runflare.com \ - --trusted-host package-mirror.liara.ir \ - --trusted-host mirror.cdn.ir \ - --trusted-host mirror2.chabokan.net \ -r requirements.txt COPY entrypoint.sh /app/entrypoint.sh diff --git a/Schemas b/Schemas index 78acb55..536deea 160000 --- a/Schemas +++ b/Schemas @@ -1 +1 @@ -Subproject commit 78acb5510d8535cbf7faaebeb7435623cb136b35 +Subproject commit 536deea4b23a04757686e814cc57f8fc393355ca diff --git a/config/integration_contract.py b/config/integration_contract.py new file mode 100644 index 0000000..933879c --- /dev/null +++ b/config/integration_contract.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + + +def _isoformat(value: Any) -> Any: + if isinstance(value, datetime): + return value.isoformat() + return value + + +def build_integration_meta( + *, + flow_type: str, + source_type: str, + source_service: str, + ownership: str, + live: bool, + cached: bool, + generated_at: Any = None, + snapshot_at: Any = None, + notes: list[str] | None = None, +) -> dict[str, Any]: + meta = { + "flow_type": flow_type, + "source_type": source_type, + "source_service": source_service, + "ownership": ownership, + "live": live, + "cached": cached, + } + if generated_at is not None: + meta["generated_at"] = _isoformat(generated_at) + if snapshot_at is not None: + meta["snapshot_at"] = _isoformat(snapshot_at) + if notes: + meta["notes"] = notes + return meta diff --git a/config/rag_config.yaml b/config/rag_config.yaml index ef9dc8a..4d94292 100644 --- a/config/rag_config.yaml +++ b/config/rag_config.yaml @@ -226,7 +226,7 @@ services: use_user_embeddings: true description: "سرویس روایت داشبورد عملکرد و برداشت" fallback_behavior: - on_invalid_json: "return_mocked_narrative" + on_invalid_json: "raise_validation_error" on_missing_context: "use_only_deterministic_data" on_number_conflict: "prefer_deterministic_data" prompt_template: "config/tones/yield_harvest_tone.txt" diff --git a/config/settings.py b/config/settings.py index 426b45f..325b80c 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,6 +1,7 @@ import os import importlib.util from pathlib import Path +from django.core.exceptions import ImproperlyConfigured try: from dotenv import load_dotenv @@ -30,6 +31,7 @@ FILE_LOGGING_ENABLED = _can_use_file_logging(LOG_DIR) SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only") DEBUG = os.environ.get("DEBUG", "0") == "1" +DEVELOP = os.environ.get("DEVELOP", "false").strip().lower() in {"1", "true", "yes", "on"} ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") INSTALLED_APPS = [ @@ -56,6 +58,8 @@ INSTALLED_APPS = [ for optional_app in [ "rest_framework", "corsheaders", + "drf_spectacular", + "drf_spectacular_sidecar", ]: if importlib.util.find_spec(optional_app): INSTALLED_APPS.insert(6, optional_app) @@ -73,6 +77,9 @@ MIDDLEWARE = [ if importlib.util.find_spec("corsheaders"): MIDDLEWARE.insert(1, "corsheaders.middleware.CorsMiddleware") +if importlib.util.find_spec("whitenoise"): + MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware") + ROOT_URLCONF = "config.urls" WSGI_APPLICATION = "config.wsgi.application" @@ -121,6 +128,9 @@ USE_TZ = True STATIC_URL = "static/" STATIC_ROOT = BASE_DIR / "staticfiles" +if importlib.util.find_spec("whitenoise"): + STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" CACHES = { @@ -134,6 +144,22 @@ REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.AllowAny", ], + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", +} + +SPECTACULAR_SETTINGS = { + "TITLE": "CropLogic AI API", + "DESCRIPTION": "Swagger/OpenAPI documentation for all CropLogic AI API endpoints.", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "SWAGGER_UI_DIST": "SIDECAR", + "SWAGGER_UI_FAVICON_HREF": "SIDECAR", + "REDOC_DIST": "SIDECAR", + "SCHEMA_PATH_PREFIX": r"/api/", + "SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"], + "SWAGGER_UI_SETTINGS": { + "persistAuthorization": True, + }, } CORS_ALLOW_ALL_ORIGINS = DEBUG @@ -161,16 +187,22 @@ WEATHER_API_BASE_URL = os.environ.get( "WEATHER_API_BASE_URL", "https://api.open-meteo.com/v1/forecast" ) WEATHER_API_KEY = os.environ.get("WEATHER_API_KEY", "") -WEATHER_DATA_PROVIDER = os.environ.get("WEATHER_DATA_PROVIDER", "mock").strip().lower() +WEATHER_DATA_PROVIDER = os.environ.get("WEATHER_DATA_PROVIDER", "open-meteo").strip().lower() WEATHER_MOCK_DELAY_SECONDS = float(os.environ.get("WEATHER_MOCK_DELAY_SECONDS", "0.8")) WEATHER_TIMEOUT_SECONDS = float(os.environ.get("WEATHER_TIMEOUT_SECONDS", "60")) -SOIL_DATA_PROVIDER = os.environ.get("SOIL_DATA_PROVIDER", "mock").strip().lower() +SOIL_DATA_PROVIDER = os.environ.get("SOIL_DATA_PROVIDER", "soilgrids").strip().lower() SOIL_MOCK_DELAY_SECONDS = float(os.environ.get("SOIL_MOCK_DELAY_SECONDS", "0.8")) SOILGRIDS_TIMEOUT_SECONDS = float(os.environ.get("SOILGRIDS_TIMEOUT_SECONDS", "60")) BACKEND_PLANT_SYNC_BASE_URL = os.environ.get("BACKEND_PLANT_SYNC_BASE_URL", "") BACKEND_PLANT_SYNC_API_KEY = os.environ.get("BACKEND_PLANT_SYNC_API_KEY", "") BACKEND_PLANT_SYNC_TIMEOUT = int(os.environ.get("BACKEND_PLANT_SYNC_TIMEOUT", "20")) +if not (DEBUG or DEVELOP): + if WEATHER_DATA_PROVIDER == "mock": + raise ImproperlyConfigured("WEATHER_DATA_PROVIDER=mock is allowed only in dev/test environments.") + if SOIL_DATA_PROVIDER == "mock": + raise ImproperlyConfigured("SOIL_DATA_PROVIDER=mock is allowed only in dev/test environments.") + LOGGING = { "version": 1, "disable_existing_loggers": False, diff --git a/config/urls.py b/config/urls.py index 11e90cb..d1b3561 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,8 +1,12 @@ from django.contrib import admin from django.urls import include, path +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView urlpatterns = [ path("admin/", admin.site.urls), + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path("api/docs/swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("api/docs/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), # --- App APIs --- path("api/rag/", include("rag.urls")), path("api/farm-alerts/", include("farm_alerts.urls")), diff --git a/crop_simulation/growth_simulation.py b/crop_simulation/growth_simulation.py index 0b179ea..8adb98f 100644 --- a/crop_simulation/growth_simulation.py +++ b/crop_simulation/growth_simulation.py @@ -10,6 +10,7 @@ import logging from django.core.paginator import EmptyPage, Paginator from farm_data.models import SensorData +from farm_data.services import get_canonical_farm_record, get_runtime_plant_for_farm from plant.gdd import calculate_daily_gdd, resolve_growth_profile from weather.models import WeatherForecast @@ -775,14 +776,10 @@ class CurrentFarmChartSimulator: resolved_plant_name = plant_name if not resolved_plant_name: - sensor = ( - SensorData.objects.prefetch_related("plants") - .filter(farm_uuid=farm_uuid) - .first() - ) + sensor = get_canonical_farm_record(farm_uuid) if sensor is None: raise GrowthSimulationError("مزرعه پیدا نشد.") - plant = sensor.plants.first() + plant = get_runtime_plant_for_farm(sensor) if plant is None: raise GrowthSimulationError("گیاهی برای مزرعه انتخاب شده پیدا نشد.") resolved_plant_name = plant.name diff --git a/crop_simulation/harvest_prediction.py b/crop_simulation/harvest_prediction.py index 5ad1b8e..155deb5 100644 --- a/crop_simulation/harvest_prediction.py +++ b/crop_simulation/harvest_prediction.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import date, timedelta from typing import Any -from farm_data.models import SensorData +from farm_data.services import get_canonical_farm_record, get_runtime_plant_for_farm from plant.gdd import resolve_growth_profile from .growth_simulation import ( @@ -55,10 +55,10 @@ def build_harvest_prediction_payload( ) -> dict[str, Any]: resolved_plant_name = plant_name if not resolved_plant_name: - sensor = SensorData.objects.prefetch_related("plants").filter(farm_uuid=farm_uuid).first() - if sensor is None: + farm = get_canonical_farm_record(farm_uuid) + if farm is None: raise GrowthSimulationError("مزرعه پیدا نشد.") - plant = sensor.plants.first() + plant = get_runtime_plant_for_farm(farm) if plant is None: raise GrowthSimulationError("گیاهی برای مزرعه انتخاب شده پیدا نشد.") resolved_plant_name = plant.name diff --git a/crop_simulation/services.py b/crop_simulation/services.py index 692794c..8e06ed6 100644 --- a/crop_simulation/services.py +++ b/crop_simulation/services.py @@ -445,23 +445,18 @@ def build_simulation_payload_from_farm( agromanagement: Any | None = None, site_parameters: dict[str, Any] | None = None, ) -> dict[str, Any]: - from farm_data.models import SensorData + from farm_data.services import ( + get_canonical_farm_record, + get_runtime_plant_for_farm, + list_runtime_plants_for_farm, + ) from weather.models import WeatherForecast - farm = ( - SensorData.objects.select_related("center_location", "irrigation_method") - .prefetch_related("plants", "center_location__depths") - .filter(farm_uuid=farm_uuid) - .first() - ) + farm = get_canonical_farm_record(farm_uuid) if farm is None: raise CropSimulationError(f"Farm with uuid={farm_uuid} not found.") - plant = None - if plant_name: - plant = farm.plants.filter(name=plant_name).first() - if plant is None: - plant = farm.plants.first() + plant = get_runtime_plant_for_farm(farm, plant_name=plant_name) if weather is not None: resolved_weather = _normalize_weather_records(weather) @@ -569,6 +564,7 @@ def build_simulation_payload_from_farm( return { "farm": farm, + "runtime_plants": list_runtime_plants_for_farm(farm), "plant": plant, "weather": resolved_weather, "soil": resolved_soil, @@ -1052,7 +1048,7 @@ class CropSimulationService: if not crops and farm_uuid: base = build_simulation_payload_from_farm(farm_uuid=str(farm_uuid)) crops = [] - for plant in base["farm"].plants.all(): + for plant in base["runtime_plants"]: simulation_profile = _extract_plant_simulation_profile(plant) crop_payload = ( deepcopy(simulation_profile.get("crop_parameters")) diff --git a/crop_simulation/tests.py b/crop_simulation/tests.py index 4c0f4df..01a2631 100644 --- a/crop_simulation/tests.py +++ b/crop_simulation/tests.py @@ -8,9 +8,11 @@ from unittest.mock import patch from unittest import skipUnless from django.test import TestCase +from rest_framework.test import APIRequestFactory from .models import SimulationRun, SimulationScenario from .services import CropSimulationService, CropSimulationError, PcseSimulationManager +from .views import PlantGrowthSimulationView def build_weather(days: int = 5) -> list[dict]: @@ -108,7 +110,61 @@ class CropSimulationServiceTests(TestCase): crop_parameters=self.crop, strategies=[{"label": "only", "agromanagement": build_agromanagement()}], site_parameters=self.site, - ) + ) + + +class CropSimulationViewContractTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + + @patch("crop_simulation.views.run_growth_simulation_task.delay") + def test_growth_queue_response_includes_live_ai_metadata(self, mock_delay): + mock_delay.return_value.id = "task-123" + request = self.factory.post( + "/api/crop-simulation/growth/", + { + "plant_name": "wheat", + "dynamic_parameters": ["DVS"], + "weather": [ + { + "DAY": "2026-04-01", + "LAT": 35.7, + "LON": 51.4, + "TMIN": 12, + "TMAX": 24, + "RAIN": 0.0, + "ET0": 0.32, + } + ], + "soil_parameters": {"SMFCF": 0.34, "SMW": 0.14, "RDMSOL": 120.0}, + "site_parameters": {"WAV": 40.0}, + "agromanagement": [ + { + "2026-04-01": { + "CropCalendar": { + "crop_name": "wheat", + "variety_name": "winter-wheat", + "crop_start_date": "2026-04-05", + "crop_start_type": "sowing", + "crop_end_date": "2026-09-01", + "crop_end_type": "harvest", + "max_duration": 180, + }, + "TimedEvents": [], + "StateEvents": [], + } + }, + {}, + ], + }, + format="json", + ) + + response = PlantGrowthSimulationView.as_view()(request) + + self.assertEqual(response.status_code, 202) + self.assertEqual(response.data["meta"]["flow_type"], "live_ai_inference") + self.assertEqual(response.data["meta"]["source_service"], "ai_crop_simulation") def test_recommend_best_crop_returns_best_candidate(self): with patch.object( diff --git a/crop_simulation/views.py b/crop_simulation/views.py index 970edac..6f4ad5e 100644 --- a/crop_simulation/views.py +++ b/crop_simulation/views.py @@ -7,6 +7,7 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView +from config.integration_contract import build_integration_meta from config.openapi import ( build_envelope_serializer, build_response, @@ -151,6 +152,14 @@ class PlantGrowthSimulationView(APIView): "status_url": f"/api/crop-simulation/growth/{task.id}/status/", "plant_name": serializer.validated_data["plant_name"], }, + "meta": build_integration_meta( + flow_type="live_ai_inference", + source_type="provider", + source_service="ai_crop_simulation", + ownership="ai", + live=True, + cached=False, + ), }, status=status.HTTP_202_ACCEPTED, ) @@ -202,7 +211,19 @@ class PlantGrowthSimulationStatusView(APIView): payload["error"] = str(result.result) return Response( - {"code": 200, "msg": "موفق", "data": payload}, + { + "code": 200, + "msg": "موفق", + "data": payload, + "meta": build_integration_meta( + flow_type="live_ai_inference", + source_type="provider", + source_service="ai_crop_simulation", + ownership="ai", + live=result.state in {"PENDING", "PROGRESS", "SUCCESS"}, + cached=False, + ), + }, status=status.HTTP_200_OK, ) @@ -281,7 +302,19 @@ class CurrentFarmSimulationChartView(APIView): ) return Response( - {"code": 200, "msg": "موفق", "data": result}, + { + "code": 200, + "msg": "موفق", + "data": result, + "meta": build_integration_meta( + flow_type="ai_owned_derived_output", + source_type="provider", + source_service="ai_crop_simulation_chart", + ownership="ai", + live=True, + cached=False, + ), + }, status=status.HTTP_200_OK, ) @@ -342,7 +375,19 @@ class HarvestPredictionView(APIView): ) return Response( - {"code": 200, "msg": "موفق", "data": result}, + { + "code": 200, + "msg": "موفق", + "data": result, + "meta": build_integration_meta( + flow_type="ai_owned_derived_output", + source_type="provider", + source_service="ai_crop_simulation_harvest_prediction", + ownership="ai", + live=True, + cached=False, + ), + }, status=status.HTTP_200_OK, ) @@ -388,7 +433,22 @@ class YieldPredictionView(APIView): {"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) + return Response( + { + "code": 200, + "msg": "موفق", + "data": result, + "meta": build_integration_meta( + flow_type="ai_owned_derived_output", + source_type="provider", + source_service="ai_crop_simulation_yield_prediction", + ownership="ai", + live=True, + cached=False, + ), + }, + status=status.HTTP_200_OK, + ) class YieldHarvestSummaryView(APIView): @@ -397,7 +457,7 @@ class YieldHarvestSummaryView(APIView): summary="خلاصه عملکرد و برداشت", description=( "خروجی داشبورد Yield & Harvest Summary را با اتکا به داده های قطعی شبیه سازی برمی گرداند. " - "فعلا پاسخ به صورت mock با کارت های خالی بازگردانده می شود." + "این endpoint خروجی derived واقعی تولید می کند و پاسخ آن mock نیست." ), parameters=[ OpenApiParameter( @@ -492,4 +552,20 @@ class YieldHarvestSummaryView(APIView): irrigation_recommendation=validated.get("irrigation_recommendation"), fertilization_recommendation=validated.get("fertilization_recommendation"), ) - return Response({"code": 200, "msg": "موفق", "data": payload}, status=status.HTTP_200_OK) + return Response( + { + "code": 200, + "msg": "موفق", + "data": payload, + "meta": build_integration_meta( + flow_type="ai_owned_derived_output", + source_type="provider", + source_service="ai_crop_simulation_yield_harvest_summary", + ownership="ai", + live=True, + cached=False, + ), + }, + status=status.HTTP_200_OK, + ) + diff --git a/crop_simulation/water_stress.py b/crop_simulation/water_stress.py index 0954ed6..fe9c90b 100644 --- a/crop_simulation/water_stress.py +++ b/crop_simulation/water_stress.py @@ -3,7 +3,7 @@ from __future__ import annotations from statistics import mean from typing import Any -from farm_data.models import SensorData +from farm_data.services import get_canonical_farm_record, get_runtime_plant_for_farm from .growth_simulation import GrowthSimulationError, _run_simulation, _safe_float, build_growth_context @@ -94,11 +94,11 @@ class WaterStressSimulationService: if plant_name: return plant_name - sensor = SensorData.objects.prefetch_related("plants").filter(farm_uuid=farm_uuid).first() - if sensor is None: + farm = get_canonical_farm_record(farm_uuid) + if farm is None: raise GrowthSimulationError("Farm not found.") - plant = sensor.plants.first() + plant = get_runtime_plant_for_farm(farm) if plant is None: raise GrowthSimulationError("Plant not found for the selected farm.") return plant.name diff --git a/crop_simulation/yield_harvest_summary.py b/crop_simulation/yield_harvest_summary.py index 02eb42d..2910b8f 100644 --- a/crop_simulation/yield_harvest_summary.py +++ b/crop_simulation/yield_harvest_summary.py @@ -13,6 +13,7 @@ from farm_data.models import SensorData from farm_data.services import get_farm_details from location_data.models import NdviObservation, SoilLocation +from rag.failure_contract import RAGServiceError from rag.services.yield_harvest import YieldHarvestRAGService logger = logging.getLogger(__name__) @@ -119,13 +120,17 @@ class YieldHarvestSummaryService: try: rag_service = YieldHarvestRAGService() narrative_data = rag_service.generate_narrative(context_payload) - except Exception as exc: + except RAGServiceError as exc: logger.warning( "Yield harvest narrative generation failed for farm_uuid=%s: %s", farm_uuid, exc, ) - narrative_data = {} + narrative_data = { + "status": "error", + "source": "llm", + "narrative_error": exc.to_dict(), + } return self._merge_narrative(deterministic_payload, narrative_data) def _build_yield_prediction( @@ -703,7 +708,7 @@ class YieldHarvestSummaryService: ) -> dict[str, Any]: farm = ( SensorData.objects.select_related("center_location", "weather_forecast") - .prefetch_related("center_location__depths", "plants") + .prefetch_related("center_location__depths", "plant_assignments__plant") .filter(farm_uuid=farm_uuid) .first() ) @@ -949,6 +954,11 @@ class YieldHarvestSummaryService: fallback_note, ) + merged["narrative_status"] = narratives.get("status", "success") + merged["narrative_source"] = narratives.get("source", "deterministic") + if isinstance(narratives.get("narrative_error"), dict): + merged["narrative_error"] = narratives["narrative_error"] + return merged def _coalesce_text(self, *values: Any) -> str: diff --git a/drf_spectacular/__init__.py b/drf_spectacular/__init__.py deleted file mode 100644 index 0adda54..0000000 --- a/drf_spectacular/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from .types import OpenApiTypes -from .utils import ( - OpenApiExample, - OpenApiParameter, - OpenApiResponse, - extend_schema, - extend_schema_view, - inline_serializer, -) - -__all__ = [ - "OpenApiExample", - "OpenApiParameter", - "OpenApiResponse", - "OpenApiTypes", - "extend_schema", - "extend_schema_view", - "inline_serializer", -] diff --git a/drf_spectacular/types.py b/drf_spectacular/types.py deleted file mode 100644 index 15e4ea2..0000000 --- a/drf_spectacular/types.py +++ /dev/null @@ -1,8 +0,0 @@ -class OpenApiTypes: - STR = str - INT = int - BOOL = bool - UUID = str - DATE = str - DATETIME = str - OBJECT = dict diff --git a/drf_spectacular/utils.py b/drf_spectacular/utils.py deleted file mode 100644 index 71a0048..0000000 --- a/drf_spectacular/utils.py +++ /dev/null @@ -1,60 +0,0 @@ -from dataclasses import dataclass - -from rest_framework import serializers - - -@dataclass -class OpenApiExample: - name: str - value: object = None - request_only: bool = False - response_only: bool = False - summary: str | None = None - description: str | None = None - - -@dataclass -class OpenApiResponse: - response: object = None - description: str = "" - - -class OpenApiParameter: - QUERY = "query" - PATH = "path" - HEADER = "header" - - def __init__( - self, - name, - type=None, - location=None, - required=False, - description="", - default=None, - ): - self.name = name - self.type = type - self.location = location - self.required = required - self.description = description - self.default = default - - -def extend_schema(*args, **kwargs): - def decorator(target): - return target - - return decorator - - -def extend_schema_view(**kwargs): - def decorator(target): - return target - - return decorator - - -def inline_serializer(*, name, fields): - serializer_fields = {"__module__": __name__, **fields} - return type(name, (serializers.Serializer,), serializer_fields) diff --git a/drf_spectacular/views.py b/drf_spectacular/views.py deleted file mode 100644 index 70d1233..0000000 --- a/drf_spectacular/views.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.http import HttpResponseNotFound -from django.views import View - - -class _DisabledSchemaView(View): - def dispatch(self, request, *args, **kwargs): - return HttpResponseNotFound("Schema endpoint is disabled.") - - -class SpectacularAPIView(_DisabledSchemaView): - pass - - -class SpectacularSwaggerView(_DisabledSchemaView): - pass - - -class SpectacularRedocView(_DisabledSchemaView): - pass diff --git a/entrypoint.sh b/entrypoint.sh index a223195..2b4247b 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -43,5 +43,9 @@ if [ -n "${DEVELOP}" ] && [ "${SKIP_MIGRATE}" != "1" ]; then echo "Demo seeders done." fi +echo "Collecting static files..." +python manage.py collectstatic --noinput +echo "Static files ready." + echo "Starting command: $*" exec "$@" diff --git a/farm_data/models.py b/farm_data/models.py index 676560f..d41ab65 100644 --- a/farm_data/models.py +++ b/farm_data/models.py @@ -126,7 +126,7 @@ class SensorData(SensorPayloadMixin, models.Model): blank=True, db_table="farm_data_sensordata_plants", related_name="farm_data", - help_text="گیاهان مرتبط با این farm", + help_text="مسیر legacy برای گیاهان farm. برای خواندن canonical از plant_assignments/plant_snapshots استفاده شود.", ) irrigation_method = models.ForeignKey( "irrigation.IrrigationMethod", diff --git a/farm_data/services.py b/farm_data/services.py index 391c8d3..4d5654d 100644 --- a/farm_data/services.py +++ b/farm_data/services.py @@ -3,8 +3,10 @@ from __future__ import annotations from decimal import Decimal, ROUND_HALF_UP from numbers import Number import logging +import warnings from django.conf import settings +from django.apps import apps from django.db import transaction from django.utils.dateparse import parse_datetime @@ -40,6 +42,10 @@ class BackendSyncError(Exception): """خطا در همگام سازی کاتالوگ گیاه و assignmentها از Backend.""" +class LegacyFarmPlantRelationWarning(DeprecationWarning): + """هشدار برای relation قدیمی SensorData.plants.""" + + PARAMETER_LABEL_OVERRIDES = { "soil_moisture": "رطوبت خاک", "soil_temperature": "دمای خاک", @@ -187,7 +193,9 @@ def assign_farm_plants_from_backend_ids(farm: SensorData, backend_plant_ids: lis plant=snapshot_by_backend_id[backend_plant_id], defaults={"position": position}, ) - return [snapshot_by_backend_id[backend_plant_id] for backend_plant_id in normalized_ids] + snapshots_in_order = [snapshot_by_backend_id[backend_plant_id] for backend_plant_id in normalized_ids] + reconcile_legacy_farm_plants_relation(farm, snapshots_in_order) + return snapshots_in_order def get_farm_plant_assignments(farm: SensorData) -> list[FarmPlantAssignment]: @@ -200,6 +208,44 @@ def get_farm_plant_snapshots(farm: SensorData) -> list[PlantCatalogSnapshot]: return [assignment.plant for assignment in get_farm_plant_assignments(farm)] +def reconcile_legacy_farm_plants_relation( + farm: SensorData, + snapshots: list[PlantCatalogSnapshot] | None = None, +) -> None: + snapshots = list(snapshots if snapshots is not None else get_farm_plant_snapshots(farm)) + Plant = apps.get_model("plant", "Plant") + if Plant is None: + return + names = [snapshot.name for snapshot in snapshots if snapshot and snapshot.name] + if not names: + farm.plants.clear() + return + legacy_plants = list(Plant.objects.filter(name__in=names).order_by("name", "id")) + farm.plants.set(legacy_plants) + + +def get_canonical_farm_record(farm_uuid: str) -> SensorData | None: + return ( + SensorData.objects.select_related( + "center_location", + "weather_forecast", + "irrigation_method", + ) + .prefetch_related("plant_assignments__plant", "center_location__depths") + .filter(farm_uuid=farm_uuid) + .first() + ) + + +def get_legacy_farm_plants(farm: SensorData): + warnings.warn( + "SensorData.plants is deprecated; use farm_data.services canonical plant snapshot helpers instead.", + LegacyFarmPlantRelationWarning, + stacklevel=2, + ) + return farm.plants.all() + + def get_primary_plant_snapshot(farm: SensorData) -> PlantCatalogSnapshot | None: assignments = get_farm_plant_assignments(farm) return assignments[0].plant if assignments else None @@ -259,6 +305,20 @@ def clone_snapshot_as_runtime_plant( return runtime +def get_runtime_plant_for_farm( + farm: SensorData, + *, + plant_name: str | None = None, + growth_stage: str | None = None, +): + snapshot = get_farm_plant_snapshot_by_name(farm, plant_name) + return clone_snapshot_as_runtime_plant(snapshot, growth_stage=growth_stage) + + +def list_runtime_plants_for_farm(farm: SensorData) -> list[object]: + return [clone_snapshot_as_runtime_plant(snapshot) for snapshot in get_farm_plant_snapshots(farm)] + + def build_plant_text_from_snapshot( plant: PlantCatalogSnapshot | None, growth_stage: str, @@ -290,16 +350,7 @@ def build_plant_text_from_snapshot( def build_farm_plant_context(farm_uuid: str) -> dict | None: - farm = ( - SensorData.objects.select_related( - "center_location", - "weather_forecast", - "irrigation_method", - ) - .prefetch_related("plant_assignments__plant", "center_location__depths") - .filter(farm_uuid=farm_uuid) - .first() - ) + farm = get_canonical_farm_record(farm_uuid) if farm is None: return None assignments = get_farm_plant_assignments(farm) @@ -397,16 +448,7 @@ def get_sensor_parameter_catalog(sensor_payload: dict | None = None) -> dict[str def get_farm_details(farm_uuid: str): - farm = ( - SensorData.objects.select_related( - "center_location", - "weather_forecast", - "irrigation_method", - ) - .prefetch_related("plant_assignments__plant", "center_location__depths") - .filter(farm_uuid=farm_uuid) - .first() - ) + farm = get_canonical_farm_record(farm_uuid) if farm is None: return None diff --git a/farm_data/tests/test_farm_detail_api.py b/farm_data/tests/test_farm_detail_api.py index 9af6a65..f9d4f4d 100644 --- a/farm_data/tests/test_farm_detail_api.py +++ b/farm_data/tests/test_farm_detail_api.py @@ -7,7 +7,12 @@ from rest_framework.test import APIClient from location_data.models import SoilDepthData, SoilLocation from farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter -from farm_data.services import assign_farm_plants_from_backend_ids +from farm_data.services import ( + assign_farm_plants_from_backend_ids, + get_canonical_farm_record, + get_runtime_plant_for_farm, + list_runtime_plants_for_farm, +) from irrigation.models import IrrigationMethod from weather.models import WeatherForecast @@ -77,6 +82,25 @@ class FarmDetailApiTests(TestCase): ) assign_farm_plants_from_backend_ids(self.farm, [self.plant2.backend_plant_id, self.plant1.backend_plant_id]) + def test_canonical_plant_runtime_path_uses_assignments_not_legacy_relation(self): + farm = get_canonical_farm_record(str(self.farm_uuid)) + + self.assertIsNotNone(farm) + self.assertEqual([plant.name for plant in list_runtime_plants_for_farm(farm)], ["خیار", "گوجه‌فرنگی"]) + self.assertEqual(get_runtime_plant_for_farm(farm).name, "خیار") + + def test_assignment_sync_reconciles_legacy_relation_for_transition(self): + self.assertEqual(list(self.farm.plants.values_list("name", flat=True)), ["خیار", "گوجه‌فرنگی"]) + + def test_runtime_plant_lookup_resolves_by_name_from_canonical_assignments(self): + farm = get_canonical_farm_record(str(self.farm_uuid)) + + resolved = get_runtime_plant_for_farm(farm, plant_name="گوجه‌فرنگی") + + self.assertIsNotNone(resolved) + self.assertEqual(resolved.name, "گوجه‌فرنگی") + self.assertEqual(resolved.id, self.plant1.backend_plant_id) + def test_returns_farm_detail_and_prioritizes_sensor_metrics_over_soil(self): response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") diff --git a/farm_data/views.py b/farm_data/views.py index 2a36ccf..5990412 100644 --- a/farm_data/views.py +++ b/farm_data/views.py @@ -12,6 +12,7 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView +from config.integration_contract import build_integration_meta from config.openapi import build_envelope_serializer, build_response from .models import ParameterUpdateLog, SensorData, SensorParameter from .serializers import ( @@ -248,6 +249,16 @@ class FarmDataUpsertView(APIView): "code": 201 if created else 200, "msg": "success", "data": SensorDataResponseSerializer(farm_data).data, + "meta": build_integration_meta( + flow_type="ai_owned_derived_output", + source_type="provider", + source_service="ai_farm_data", + ownership="ai", + live=True, + cached=False, + generated_at=farm_data.updated_at, + notes=["AI farm_data stores a derived read-model enriched with location and weather data."], + ), }, status=response_status, ) @@ -282,7 +293,20 @@ class FarmDetailView(APIView): ) return Response( - {"code": 200, "msg": "success", "data": data}, + { + "code": 200, + "msg": "success", + "data": data, + "meta": build_integration_meta( + flow_type="ai_owned_derived_output", + source_type="db", + source_service="ai_farm_data", + ownership="ai", + live=False, + cached=True, + snapshot_at=getattr(data, "get", lambda *_: None)("updated_at") if isinstance(data, dict) else None, + ), + }, status=status.HTTP_200_OK, ) @@ -327,6 +351,16 @@ class PlantCatalogSyncView(APIView): "count": len(snapshots), "plant_ids": [snapshot.backend_plant_id for snapshot in snapshots], }, + "meta": build_integration_meta( + flow_type="backend_owned_data_with_ai_enrichment", + source_type="db", + source_service="ai_farm_data_plant_catalog", + ownership="backend", + live=False, + cached=False, + generated_at=snapshots[-1].updated_at if snapshots else None, + notes=["Backend is canonical for plant catalog; AI stores snapshots for derived services."], + ), }, status=status.HTTP_200_OK, ) @@ -426,6 +460,15 @@ class SensorParameterCreateView(APIView): "created_at": parameter.created_at, "action": action, }, + "meta": build_integration_meta( + flow_type="ai_owned_derived_output", + source_type="db", + source_service="ai_farm_parameters", + ownership="ai", + live=False, + cached=False, + generated_at=parameter.created_at, + ), }, status=status.HTTP_201_CREATED, ) diff --git a/location_data/apps.py b/location_data/apps.py index b72f050..4160e8b 100644 --- a/location_data/apps.py +++ b/location_data/apps.py @@ -19,12 +19,14 @@ class SoilDataConfig(AppConfig): def soil_data_adapter(self): from .soil_adapters import MockSoilDataAdapter, SoilGridsAdapter - provider = getattr(settings, "SOIL_DATA_PROVIDER", "mock") + provider = getattr(settings, "SOIL_DATA_PROVIDER", "soilgrids") if provider == "soilgrids": return SoilGridsAdapter( timeout=getattr(settings, "SOILGRIDS_TIMEOUT_SECONDS", 60) ) if provider == "mock": + if not (getattr(settings, "DEBUG", False) or getattr(settings, "DEVELOP", False)): + raise RuntimeError("Mock soil provider is disabled outside dev/test environments.") return MockSoilDataAdapter( delay_seconds=getattr(settings, "SOIL_MOCK_DELAY_SECONDS", 0.8) ) diff --git a/logs/app.log.2026-03-18 b/logs/app.log.2026-03-18 deleted file mode 100644 index 245aa87..0000000 --- a/logs/app.log.2026-03-18 +++ /dev/null @@ -1,67 +0,0 @@ -2026-03-18 22:04:32,786 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:04:54,364 [INFO] django.utils.autoreload: /app/rag/chat.py changed, reloading. -2026-03-18 22:04:56,265 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:11:25,490 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:11:26,312 [INFO] django.server: "GET /api/docs/ HTTP/1.1" 200 4633 -2026-03-18 22:11:26,363 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui.css HTTP/1.1" 200 178591 -2026-03-18 22:11:26,364 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui-standalone-preset.js HTTP/1.1" 200 251697 -2026-03-18 22:11:26,367 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui-bundle.js HTTP/1.1" 200 1525208 -2026-03-18 22:11:26,369 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui-standalone-preset.js HTTP/1.1" 200 251697 -2026-03-18 22:11:26,586 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/favicon-32x32.png HTTP/1.1" 200 628 -2026-03-18 22:11:26,611 [INFO] django.server: "GET /api/schema/ HTTP/1.1" 200 36389 -2026-03-18 22:11:27,014 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui-bundle.js HTTP/1.1" 200 1525208 -2026-03-18 22:12:01,496 [WARNING] django.request: Bad Request: /api/rag/chat/ -2026-03-18 22:12:01,496 [WARNING] django.server: "POST /api/rag/chat/ HTTP/1.1" 400 88 -2026-03-18 22:12:46,012 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-18 22:12:47,916 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:12:56,327 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-18 22:12:58,210 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:13:01,536 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-18 22:13:04,493 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:13:25,217 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-18 22:13:27,187 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:13:31,580 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-18 22:13:34,011 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:15:13,685 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-18 22:15:15,628 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:15:17,930 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-18 22:15:19,914 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:15:23,293 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-18 22:15:25,815 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:15:36,399 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-18 22:15:38,979 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:15:48,593 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-18 22:15:51,190 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:15:53,726 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-18 22:15:55,973 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:15:59,401 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-18 22:16:02,173 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:16:04,712 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-18 22:16:07,147 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:16:08,615 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-18 22:16:11,764 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 22:16:15,499 [WARNING] django.request: Bad Request: /api/rag/chat/ -2026-03-18 22:16:15,499 [WARNING] django.server: "POST /api/rag/chat/ HTTP/1.1" 400 88 -2026-03-18 22:20:23,200 [WARNING] django.request: Bad Request: /api/rag/chat/ -2026-03-18 22:20:23,200 [WARNING] django.server: "POST /api/rag/chat/ HTTP/1.1" 400 88 -2026-03-18 22:24:46,060 [WARNING] django.request: Bad Request: /api/rag/chat/ -2026-03-18 22:24:46,060 [WARNING] django.server: "POST /api/rag/chat/ HTTP/1.1" 400 88 -2026-03-18 23:04:06,049 [INFO] root: jhh -2026-03-18 23:04:06,049 [INFO] rag.chat: chat_rag_stream started sensor_uuid=00000000-0000-0000-0000-000000000000 kb_name=None limit=5 query_len=19 -2026-03-18 23:04:06,121 [INFO] rag.chat: Detected KB intent=chat -2026-03-18 23:04:06,121 [INFO] rag.chat: Using knowledge base=chat -2026-03-18 23:04:06,121 [INFO] rag.chat: Building RAG context sensor_uuid=00000000-0000-0000-0000-000000000000 kb_name=chat limit=5 query_len=19 -2026-03-18 23:04:06,736 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 403 Forbidden" -2026-03-18 23:04:06,738 [INFO] django.server: "POST /api/rag/chat/ HTTP/1.1" 200 228 -2026-03-18 23:09:42,790 [INFO] django.utils.autoreload: /app/rag/chat.py changed, reloading. -2026-03-18 23:09:44,745 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 23:09:46,283 [INFO] django.utils.autoreload: /app/rag/chat.py changed, reloading. -2026-03-18 23:09:49,297 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 23:09:53,012 [INFO] django.utils.autoreload: /app/rag/chat.py changed, reloading. -2026-03-18 23:09:56,613 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 23:09:58,271 [INFO] django.utils.autoreload: /app/rag/chat.py changed, reloading. -2026-03-18 23:10:01,739 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 23:10:03,325 [INFO] django.utils.autoreload: /app/rag/chat.py changed, reloading. -2026-03-18 23:10:06,629 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-18 23:10:11,679 [WARNING] django.request: Bad Request: /api/rag/chat/ -2026-03-18 23:10:11,679 [WARNING] django.server: "POST /api/rag/chat/ HTTP/1.1" 400 88 diff --git a/logs/app.log.2026-03-19 b/logs/app.log.2026-03-19 deleted file mode 100644 index c150731..0000000 --- a/logs/app.log.2026-03-19 +++ /dev/null @@ -1,179 +0,0 @@ -2026-03-19 12:58:54,231 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 12:59:02,728 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 12:59:02,735 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK" -2026-03-19 12:59:02,839 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 12:59:02,889 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 12:59:03,731 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 403 Forbidden" -2026-03-19 13:02:27,928 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:02:31,441 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:02:40,266 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:02:42,892 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:02:47,501 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:02:49,706 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:02:58,315 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:03:00,615 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:03:03,127 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:03:06,357 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:03:09,002 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:03:10,997 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:03:13,516 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:03:16,538 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:03:24,247 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:03:27,864 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:03:31,680 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:03:34,971 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:03:36,655 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:03:39,784 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:03:41,312 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:03:44,523 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:03:49,330 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:03:49,339 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK" -2026-03-19 13:03:49,343 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:03:49,380 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:03:50,420 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 403 Forbidden" -2026-03-19 13:05:10,067 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:05:13,538 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:05:17,196 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:05:20,435 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:05:30,221 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:05:33,418 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:05:33,719 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:05:33,731 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK" -2026-03-19 13:05:33,735 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:05:33,768 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:05:33,781 [INFO] rag.api_provider: gapgpt -2026-03-19 13:05:34,560 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 403 Forbidden" -2026-03-19 13:06:36,348 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:06:39,607 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:06:42,287 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading. -2026-03-19 13:06:45,923 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:06:51,918 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:06:51,929 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK" -2026-03-19 13:06:51,941 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:06:51,994 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:06:52,021 [INFO] rag.api_provider: gapgpt -2026-03-19 13:06:52,021 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1 -2026-03-19 13:06:52,588 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 403 Forbidden" -2026-03-19 13:09:58,707 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:09:58,715 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK" -2026-03-19 13:09:58,725 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:09:58,781 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:09:58,815 [INFO] rag.api_provider: gapgpt -2026-03-19 13:09:58,816 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1 -2026-03-19 13:11:29,596 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 13:11:29,596 [INFO] openai._base_client: Retrying request to /embeddings in 0.442594 seconds -2026-03-19 13:13:00,294 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 13:13:00,294 [INFO] openai._base_client: Retrying request to /embeddings in 0.814020 seconds -2026-03-19 13:14:32,330 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 13:25:14,695 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:25:14,706 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK" -2026-03-19 13:25:14,719 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:25:14,783 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:25:14,807 [INFO] rag.api_provider: gapgpt -2026-03-19 13:25:14,808 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1 -2026-03-19 13:26:48,817 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 13:26:48,818 [INFO] openai._base_client: Retrying request to /embeddings in 0.387632 seconds -2026-03-19 13:28:17,856 [INFO] django.utils.autoreload: /app/rag/config.py changed, reloading. -2026-03-19 13:28:19,642 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 13:28:19,642 [INFO] openai._base_client: Retrying request to /embeddings in 0.771092 seconds -2026-03-19 13:28:19,697 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:28:23,262 [INFO] django.utils.autoreload: /app/rag/config.py changed, reloading. -2026-03-19 13:28:26,274 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:29:16,030 [INFO] django.utils.autoreload: /app/rag/config.py changed, reloading. -2026-03-19 13:29:19,297 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 13:29:21,261 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:29:21,275 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK" -2026-03-19 13:29:21,298 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:29:21,350 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 13:29:21,376 [INFO] rag.api_provider: gapgpt -2026-03-19 13:29:21,376 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1 -2026-03-19 13:30:52,295 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 13:30:52,296 [INFO] openai._base_client: Retrying request to /embeddings in 0.465685 seconds -2026-03-19 13:32:23,011 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 13:32:23,011 [INFO] openai._base_client: Retrying request to /embeddings in 0.894773 seconds -2026-03-19 13:33:55,186 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 14:21:23,732 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 14:21:23,742 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK" -2026-03-19 14:21:23,749 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 14:21:23,814 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 14:21:23,841 [INFO] rag.api_provider: gapgpt -2026-03-19 14:21:23,842 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1 -2026-03-19 14:22:54,762 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 14:22:54,763 [INFO] openai._base_client: Retrying request to /embeddings in 0.440965 seconds -2026-03-19 14:24:25,495 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 14:24:25,496 [INFO] openai._base_client: Retrying request to /embeddings in 0.861269 seconds -2026-03-19 14:25:27,028 [INFO] root: jhh -2026-03-19 14:25:27,030 [INFO] rag.chat: chat_rag_stream started sensor_uuid=00000000-0000-0000-0000-000000000000 kb_name=None limit=5 query_len=19 -2026-03-19 14:25:27,035 [INFO] rag.api_provider: gapgpt -2026-03-19 14:25:27,145 [INFO] rag.chat: Detected KB intent=chat -2026-03-19 14:25:27,145 [INFO] rag.chat: Using knowledge base=chat -2026-03-19 14:25:27,145 [INFO] rag.chat: Building RAG context sensor_uuid=00000000-0000-0000-0000-000000000000 kb_name=chat limit=5 query_len=19 -2026-03-19 14:25:27,171 [INFO] rag.api_provider: gapgpt -2026-03-19 14:25:27,171 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1 -2026-03-19 14:25:56,630 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 14:26:58,070 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 14:26:58,070 [INFO] openai._base_client: Retrying request to /embeddings in 0.432490 seconds -2026-03-19 14:28:28,794 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 14:28:28,795 [INFO] openai._base_client: Retrying request to /embeddings in 0.991839 seconds -2026-03-19 14:29:49,207 [INFO] django.utils.autoreload: /app/rag/config.py changed, reloading. -2026-03-19 14:29:52,551 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 14:30:38,193 [INFO] django.utils.autoreload: /app/rag/config.py changed, reloading. -2026-03-19 14:30:39,922 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 14:30:42,504 [INFO] django.utils.autoreload: /app/rag/config.py changed, reloading. -2026-03-19 14:30:45,509 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 14:30:49,190 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 14:30:49,198 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK" -2026-03-19 14:30:49,199 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 14:30:49,256 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK" -2026-03-19 14:30:49,285 [INFO] rag.api_provider: gapgpt -2026-03-19 14:30:49,285 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1 -2026-03-19 14:32:20,003 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 14:32:20,003 [INFO] openai._base_client: Retrying request to /embeddings in 0.419902 seconds -2026-03-19 14:33:50,727 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 14:33:50,728 [INFO] openai._base_client: Retrying request to /embeddings in 0.883938 seconds -2026-03-19 14:35:21,862 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error" -2026-03-19 14:44:25,066 [INFO] django.utils.autoreload: /app/rag/config.py changed, reloading. -2026-03-19 14:44:28,557 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 14:59:19,518 [INFO] django.utils.autoreload: /app/config/settings.py changed, reloading. -2026-03-19 14:59:23,255 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 14:59:26,946 [INFO] django.utils.autoreload: /app/config/urls.py changed, reloading. -2026-03-19 14:59:30,776 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:03:30,274 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:10:06,813 [INFO] django.utils.autoreload: /app/sensor_data/models.py changed, reloading. -2026-03-19 15:10:10,500 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:11:14,729 [INFO] django.utils.autoreload: /app/sensor_data/serializers.py changed, reloading. -2026-03-19 15:11:18,263 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:11:31,277 [INFO] django.utils.autoreload: /app/sensor_data/views.py changed, reloading. -2026-03-19 15:11:34,478 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:11:41,179 [INFO] django.utils.autoreload: /app/sensor_data/views.py changed, reloading. -2026-03-19 15:11:44,628 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:11:53,369 [INFO] django.utils.autoreload: /app/sensor_data/admin.py changed, reloading. -2026-03-19 15:11:56,528 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:22:11,378 [INFO] django.utils.autoreload: /app/config/settings.py changed, reloading. -2026-03-19 15:22:14,686 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:22:35,729 [INFO] django.utils.autoreload: /app/rag/user_data.py changed, reloading. -2026-03-19 15:22:39,043 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:22:44,642 [INFO] django.utils.autoreload: /app/sensor_data/models.py changed, reloading. -2026-03-19 15:22:47,788 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:22:49,335 [INFO] django.utils.autoreload: /app/weather/models.py changed, reloading. -2026-03-19 15:22:52,507 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:22:58,238 [INFO] django.utils.autoreload: /app/location_data/migrations/0002_soildepthdata_refactor.py changed, reloading. -2026-03-19 15:23:01,389 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:23:07,088 [INFO] django.utils.autoreload: /app/location_data/migrations/0001_initial.py changed, reloading. -2026-03-19 15:23:10,608 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:23:44,947 [INFO] django.utils.autoreload: /app/weather/migrations/0001_initial.py changed, reloading. -2026-03-19 15:23:48,160 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:23:51,742 [INFO] django.utils.autoreload: /app/sensor_data/models.py changed, reloading. -2026-03-19 15:23:55,138 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:24:09,044 [INFO] django.utils.autoreload: /app/rag/user_data.py changed, reloading. -2026-03-19 15:24:12,254 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:36:50,309 [INFO] django.utils.autoreload: /app/rag/user_data.py changed, reloading. -2026-03-19 15:36:53,462 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:40:11,372 [INFO] django.utils.autoreload: /app/rag/urls.py changed, reloading. -2026-03-19 15:40:14,580 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 15:40:57,072 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. -2026-03-19 15:41:00,143 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 17:55:08,865 [INFO] django.utils.autoreload: /app/config/settings.py changed, reloading. -2026-03-19 17:55:12,285 [INFO] django.utils.autoreload: Watching for file changes with StatReloader -2026-03-19 20:14:05,386 [INFO] django.utils.autoreload: /app/rag/__init__.py changed, reloading. -2026-03-19 20:14:09,007 [INFO] django.utils.autoreload: Watching for file changes with StatReloader diff --git a/pest_disease/views.py b/pest_disease/views.py index 50f1874..b5008e0 100644 --- a/pest_disease/views.py +++ b/pest_disease/views.py @@ -7,6 +7,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from config.openapi import build_envelope_serializer, build_response +from rag.failure_contract import RAGServiceError from rag.chat import encode_uploaded_image from rag.services import get_pest_disease_detection, get_pest_disease_risk @@ -100,6 +101,11 @@ class PestDiseaseDetectionView(_ImageMixin, APIView): query=validated.get("query"), images=images, ) + except RAGServiceError as exc: + return Response( + {"code": exc.http_status, "msg": exc.contract.message, "data": exc.to_dict()}, + status=exc.http_status, + ) except Exception as exc: return Response( {"code": 500, "msg": f"خطا در تحلیل تصویر گیاه: {exc}", "data": None}, @@ -146,6 +152,11 @@ class PestDiseaseRiskView(APIView): growth_stage=validated.get("growth_stage"), query=validated.get("query"), ) + except RAGServiceError as exc: + return Response( + {"code": exc.http_status, "msg": exc.contract.message, "data": exc.to_dict()}, + status=exc.http_status, + ) except Exception as exc: return Response( {"code": 500, "msg": f"خطا در پیش بینی ریسک آفات و بیماری: {exc}", "data": None}, diff --git a/rag/embedding.py b/rag/embedding.py index 1b4e460..d0fe719 100644 --- a/rag/embedding.py +++ b/rag/embedding.py @@ -1,9 +1,12 @@ """ سرویس تعبیه‌سازی متن — از Adapter Pattern برای سوئیچ بین providers استفاده می‌کند """ +import logging +import time + from .api_provider import get_embedding_client from .config import RAGConfig, load_rag_config -import logging +from .observability import classify_exception, log_event, observe_operation, record_metric logger = logging.getLogger(__name__) @@ -26,12 +29,13 @@ def embed_texts( لیست وکتورها """ if not texts: + record_metric("rag.embedding.empty_input", operation="embed_texts") return [] cfg = config or load_rag_config() client = get_embedding_client(cfg) model_name = model or cfg.embedding.model - logger.info(model_name) + provider = cfg.embedding.provider or "unknown" batch_size = cfg.embedding.batch_size all_embeddings: list[list[float]] = [] @@ -39,15 +43,44 @@ def embed_texts( if dimensions is not None: extra["dimensions"] = dimensions - for i in range(0, len(texts), batch_size): - batch = texts[i : i + batch_size] - resp = client.embeddings.create( - model=model_name, - input=batch, - **extra, - ) - for item in sorted(resp.data, key=lambda x: x.index): - all_embeddings.append(item.embedding) + with observe_operation(source="rag.embedding", provider=provider, operation="embed_texts"): + for i in range(0, len(texts), batch_size): + batch = texts[i : i + batch_size] + started_at = time.monotonic() + try: + resp = client.embeddings.create( + model=model_name, + input=batch, + **extra, + ) + except Exception as exc: + failure = classify_exception(exc) + log_event( + level=logging.ERROR, + message="embedding batch request failed", + source="rag.embedding", + provider=provider, + operation="embed_batch", + result_status="error", + duration_ms=(time.monotonic() - started_at) * 1000, + error_code=failure.error_code, + batch_size=len(batch), + model=model_name, + ) + raise + for item in sorted(resp.data, key=lambda x: x.index): + all_embeddings.append(item.embedding) + log_event( + level=logging.INFO, + message="embedding batch request completed", + source="rag.embedding", + provider=provider, + operation="embed_batch", + result_status="success", + duration_ms=(time.monotonic() - started_at) * 1000, + batch_size=len(batch), + model=model_name, + ) return all_embeddings diff --git a/rag/failure_contract.py b/rag/failure_contract.py new file mode 100644 index 0000000..fb7ce1c --- /dev/null +++ b/rag/failure_contract.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class FailureContract: + status: str = "error" + error_code: str = "internal_error" + message: str = "" + source: str = "application" + warnings: list[str] = field(default_factory=list) + retriable: bool = False + details: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + payload = { + "status": self.status, + "error_code": self.error_code, + "message": self.message, + "source": self.source, + "warnings": list(self.warnings), + "retriable": self.retriable, + } + if self.details: + payload["details"] = self.details + return payload + + +class RAGServiceError(Exception): + def __init__( + self, + *, + error_code: str, + message: str, + source: str, + warnings: list[str] | None = None, + retriable: bool = False, + details: dict[str, Any] | None = None, + http_status: int = 500, + ) -> None: + super().__init__(message) + self.http_status = http_status + self.contract = FailureContract( + error_code=error_code, + message=message, + source=source, + warnings=warnings or [], + retriable=retriable, + details=details or {}, + ) + + def to_dict(self) -> dict[str, Any]: + return self.contract.to_dict() diff --git a/rag/ingest.py b/rag/ingest.py index cf9f8b3..3f5f1e2 100644 --- a/rag/ingest.py +++ b/rag/ingest.py @@ -12,6 +12,7 @@ from pathlib import Path from .chunker import chunk_text, chunk_texts from .config import load_rag_config, RAGConfig from .embedding import embed_texts +from .observability import classify_exception, log_event, observe_operation, record_metric from .user_data import load_user_sources, build_user_weather_text from .vector_store import QdrantVectorStore @@ -36,7 +37,19 @@ def _load_file(path: Path) -> str | None: return None try: return path.read_text(encoding="utf-8").strip() - except Exception: + except Exception as exc: + failure = classify_exception(exc) + log_event( + level=40, + message="rag ingest file load failed", + source="rag.ingest", + provider=None, + operation="load_file", + result_status="error", + error_code=failure.error_code, + path=str(path), + ) + record_metric("rag.ingest.file_load_failure", error_code=failure.error_code) return None @@ -122,12 +135,14 @@ def ingest( """ cfg = config or load_rag_config() store = QdrantVectorStore(config=cfg) - if recreate: - store.ensure_collection(recreate=True) + with observe_operation(source="rag.ingest", provider=cfg.embedding.provider, operation="ingest"): + if recreate: + store.ensure_collection(recreate=True) - sources = load_sources(config=cfg, kb_name=kb_name) - if not sources: - return {"chunks_added": 0, "sources": [], "error": "هیچ منبعی یافت نشد"} + sources = load_sources(config=cfg, kb_name=kb_name) + if not sources: + record_metric("rag.ingest.empty_sources", kb_name=kb_name) + return {"chunks_added": 0, "sources": [], "error": "هیچ منبعی یافت نشد"} all_chunks: list[str] = [] all_metas: list[dict] = [] @@ -146,24 +161,27 @@ def ingest( "kb_name": src_kb, }) - if not all_chunks: - return {"chunks_added": 0, "sources": [s[0] for s in sources], "error": "هیچ چانکی ساخته نشد"} + if not all_chunks: + record_metric("rag.ingest.empty_chunks", kb_name=kb_name) + return {"chunks_added": 0, "sources": [s[0] for s in sources], "error": "هیچ چانکی ساخته نشد"} - embeddings = embed_texts(all_chunks, config=cfg) - if len(embeddings) != len(all_chunks): + embeddings = embed_texts(all_chunks, config=cfg) + if len(embeddings) != len(all_chunks): + record_metric("rag.ingest.embedding_mismatch", kb_name=kb_name) + return { + "chunks_added": 0, + "sources": [s[0] for s in sources], + "error": f"تعداد embed با چانک‌ها مطابقت ندارد: {len(embeddings)} vs {len(all_chunks)}", + } + + store.add_documents( + ids=all_ids, + embeddings=embeddings, + documents=all_chunks, + metadatas=all_metas, + ) + record_metric("rag.ingest.success", kb_name=kb_name, chunks=len(all_chunks)) return { - "chunks_added": 0, + "chunks_added": len(all_chunks), "sources": [s[0] for s in sources], - "error": f"تعداد embed با چانک‌ها مطابقت ندارد: {len(embeddings)} vs {len(all_chunks)}", } - - store.add_documents( - ids=all_ids, - embeddings=embeddings, - documents=all_chunks, - metadatas=all_metas, - ) - return { - "chunks_added": len(all_chunks), - "sources": [s[0] for s in sources], - } diff --git a/rag/observability.py b/rag/observability.py new file mode 100644 index 0000000..b1a84e5 --- /dev/null +++ b/rag/observability.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import logging +import time +from collections import Counter +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Any + + +logger = logging.getLogger(__name__) +_request_id_ctx: ContextVar[str | None] = ContextVar("rag_request_id", default=None) +METRICS: Counter[str] = Counter() + + +def set_request_id(request_id: str | None) -> None: + _request_id_ctx.set(request_id) + + +def get_request_id() -> str | None: + return _request_id_ctx.get() + + +def record_metric(name: str, value: int = 1, **tags: Any) -> None: + suffix = ",".join(f"{key}={tags[key]}" for key in sorted(tags) if tags[key] is not None) + metric_key = f"{name}|{suffix}" if suffix else name + METRICS[metric_key] += value + + +@dataclass +class ClassifiedFailure: + error_code: str + failure_type: str + retriable: bool + + +def classify_exception(exc: Exception) -> ClassifiedFailure: + exc_name = exc.__class__.__name__.lower() + message = str(exc).lower() + if "timeout" in exc_name or "timeout" in message: + return ClassifiedFailure("timeout", "timeout", True) + if "json" in exc_name or "json" in message: + return ClassifiedFailure("parse_error", "parse_error", False) + if "validation" in exc_name or "invalid" in message: + return ClassifiedFailure("validation_failure", "validation_failure", False) + if "connection" in exc_name or "unavailable" in message: + return ClassifiedFailure("dependency_unavailable", "dependency_unavailable", True) + return ClassifiedFailure("provider_error", "provider_error", True) + + +def log_event( + *, + level: int, + message: str, + source: str, + provider: str | None, + operation: str, + result_status: str, + duration_ms: float | None = None, + error_code: str | None = None, + **extra: Any, +) -> None: + payload = { + "source": source, + "provider": provider, + "operation": operation, + "result_status": result_status, + "duration_ms": round(duration_ms, 2) if duration_ms is not None else None, + "error_code": error_code, + "request_id": get_request_id(), + } + payload.update({key: value for key, value in extra.items() if value is not None}) + logger.log(level, message, extra={"event": payload}) + + +class observe_operation: + def __init__(self, *, source: str, provider: str | None, operation: str): + self.source = source + self.provider = provider + self.operation = operation + self.started_at = 0.0 + + def __enter__(self): + self.started_at = time.monotonic() + log_event( + level=logging.INFO, + message="rag operation started", + source=self.source, + provider=self.provider, + operation=self.operation, + result_status="started", + ) + return self + + def __exit__(self, exc_type, exc, _tb): + duration_ms = (time.monotonic() - self.started_at) * 1000 + if exc is None: + log_event( + level=logging.INFO, + message="rag operation completed", + source=self.source, + provider=self.provider, + operation=self.operation, + result_status="success", + duration_ms=duration_ms, + ) + record_metric("rag.operation.success", source=self.source, provider=self.provider, operation=self.operation) + return False + + failure = classify_exception(exc) + log_event( + level=logging.ERROR, + message="rag operation failed", + source=self.source, + provider=self.provider, + operation=self.operation, + result_status="error", + duration_ms=duration_ms, + error_code=failure.error_code, + failure_type=failure.failure_type, + ) + record_metric( + "rag.operation.failure", + source=self.source, + provider=self.provider, + operation=self.operation, + error_code=failure.error_code, + ) + return False diff --git a/rag/retrieve.py b/rag/retrieve.py index 6372fbe..c72f919 100644 --- a/rag/retrieve.py +++ b/rag/retrieve.py @@ -3,6 +3,7 @@ """ from .config import load_rag_config, RAGConfig, get_service_config from .embedding import embed_single, embed_texts +from .observability import observe_operation, record_metric from .vector_store import QdrantVectorStore @@ -63,15 +64,19 @@ def search_with_query( use_user_embeddings=use_user_embeddings, ) - query_vector = embed_single(query, config=cfg) - store = QdrantVectorStore(config=cfg) - return store.search( - query_vector=query_vector, - limit=limit, - score_threshold=score_threshold, - sensor_uuids=sensor_filters, - kb_names=kb_filters, - ) + with observe_operation(source="rag.retrieve", provider=cfg.embedding.provider, operation="search_with_query"): + query_vector = embed_single(query, config=cfg) + store = QdrantVectorStore(config=cfg) + results = store.search( + query_vector=query_vector, + limit=limit, + score_threshold=score_threshold, + sensor_uuids=sensor_filters, + kb_names=kb_filters, + ) + if not results: + record_metric("rag.retrieve.empty_result", operation="search_with_query", service_id=service_id) + return results def search_with_texts( @@ -102,24 +107,28 @@ def search_with_texts( ) store = QdrantVectorStore(config=cfg) - vectors = embed_texts(normalized_texts, config=cfg) - merged_results: dict[str, dict] = {} + with observe_operation(source="rag.retrieve", provider=cfg.embedding.provider, operation="search_with_texts"): + vectors = embed_texts(normalized_texts, config=cfg) + merged_results: dict[str, dict] = {} - for vector in vectors: - results = store.search( - query_vector=vector, - limit=per_text_limit, - score_threshold=score_threshold, - sensor_uuids=sensor_filters, - kb_names=kb_filters, - ) - for item in results: - current = merged_results.get(item["id"]) - if current is None or item["score"] > current["score"]: - merged_results[item["id"]] = item + for vector in vectors: + results = store.search( + query_vector=vector, + limit=per_text_limit, + score_threshold=score_threshold, + sensor_uuids=sensor_filters, + kb_names=kb_filters, + ) + for item in results: + current = merged_results.get(item["id"]) + if current is None or item["score"] > current["score"]: + merged_results[item["id"]] = item - return sorted( - merged_results.values(), - key=lambda item: item["score"], - reverse=True, - )[:limit] + final_results = sorted( + merged_results.values(), + key=lambda item: item["score"], + reverse=True, + )[:limit] + if not final_results: + record_metric("rag.retrieve.empty_result", operation="search_with_texts", service_id=service_id) + return final_results diff --git a/rag/services/pest_disease.py b/rag/services/pest_disease.py index a5c5d1c..5b71c66 100644 --- a/rag/services/pest_disease.py +++ b/rag/services/pest_disease.py @@ -18,6 +18,7 @@ from rag.chat import ( build_rag_context, ) from rag.config import RAGConfig, get_service_config, load_rag_config +from rag.failure_contract import RAGServiceError from rag.user_data import build_plant_text logger = logging.getLogger(__name__) @@ -73,18 +74,47 @@ def _clean_json(raw: str) -> dict[str, Any]: cleaned = cleaned[4:] cleaned = cleaned.strip() if not cleaned: - return {} + raise RAGServiceError( + error_code="empty_response", + message="Pest disease LLM response was empty.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) try: - return json.loads(cleaned) - except (json.JSONDecodeError, ValueError): + parsed = json.loads(cleaned) + except (json.JSONDecodeError, ValueError) as exc: logger.warning("Invalid JSON returned by pest_disease LLM: %s", cleaned[:500]) - return {} + raise RAGServiceError( + error_code="invalid_json", + message="Pest disease LLM response was not valid JSON.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) from exc + if not isinstance(parsed, dict): + raise RAGServiceError( + error_code="invalid_schema", + message="Pest disease LLM response root must be a JSON object.", + source="llm", + details={"service_id": SERVICE_ID}, + http_status=502, + ) + return parsed def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: farm_details = get_farm_details(farm_uuid) if farm_details is None: - raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.") + raise RAGServiceError( + error_code="farm_not_found", + message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.", + source="farm_data", + details={"farm_uuid": farm_uuid}, + http_status=404, + ) return farm_details @@ -213,9 +243,12 @@ def _validate_detection_result(parsed: dict[str, Any]) -> dict[str, Any]: } missing = [key for key in required_keys if key not in parsed] if missing: - raise ValueError( - "Pest disease detection response is missing required fields: " - + ", ".join(missing) + raise RAGServiceError( + error_code="invalid_schema", + message="Pest disease detection response is missing required fields: " + ", ".join(missing), + source="llm", + details={"missing_fields": missing, "service_id": SERVICE_ID}, + http_status=502, ) return parsed @@ -232,9 +265,12 @@ def _validate_risk_result(parsed: dict[str, Any]) -> dict[str, Any]: } missing = [key for key in required_keys if key not in parsed] if missing: - raise ValueError( - "Pest disease risk response is missing required fields: " - + ", ".join(missing) + raise RAGServiceError( + error_code="invalid_schema", + message="Pest disease risk response is missing required fields: " + ", ".join(missing), + source="llm", + details={"missing_fields": missing, "service_id": SERVICE_ID}, + http_status=502, ) return parsed @@ -301,7 +337,12 @@ def get_pest_disease_detection( ) -> dict[str, Any]: normalized_images = _normalize_images(images) if not normalized_images: - raise ValueError("حداقل یک تصویر برای تشخیص لازم است.") + raise RAGServiceError( + error_code="missing_images", + message="حداقل یک تصویر برای تشخیص لازم است.", + source="request", + http_status=400, + ) cfg = load_rag_config() service, client, model = _build_service_client(cfg) @@ -338,12 +379,25 @@ def get_pest_disease_detection( raw = response.choices[0].message.content.strip() parsed = _clean_json(raw) _complete_audit_log(audit_log, raw) + except RAGServiceError as exc: + logger.error("Pest disease detection failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise except Exception as exc: logger.error("Pest disease detection failed for %s: %s", farm_uuid, exc) _fail_audit_log(audit_log, str(exc)) - raise RuntimeError(f"Pest disease detection failed for farm {farm_uuid}.") from exc + raise RAGServiceError( + error_code="upstream_failure", + message=f"Pest disease detection failed for farm {farm_uuid}.", + source="llm", + retriable=True, + details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID}, + http_status=503, + ) from exc parsed = _validate_detection_result(parsed) + parsed["status"] = "success" + parsed["source"] = "llm" parsed["farm_uuid"] = farm_uuid parsed["raw_response"] = raw return parsed @@ -392,12 +446,25 @@ def get_pest_disease_risk( raw = response.choices[0].message.content.strip() parsed = _clean_json(raw) _complete_audit_log(audit_log, raw) + except RAGServiceError as exc: + logger.error("Pest disease risk prediction failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise except Exception as exc: logger.error("Pest disease risk prediction failed for %s: %s", farm_uuid, exc) _fail_audit_log(audit_log, str(exc)) - raise RuntimeError(f"Pest disease risk prediction failed for farm {farm_uuid}.") from exc + raise RAGServiceError( + error_code="upstream_failure", + message=f"Pest disease risk prediction failed for farm {farm_uuid}.", + source="llm", + retriable=True, + details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID}, + http_status=503, + ) from exc parsed = _validate_risk_result(parsed) + parsed["status"] = "success" + parsed["source"] = "llm" parsed["farm_uuid"] = farm_uuid parsed["raw_response"] = raw return parsed diff --git a/rag/services/soil_anomaly.py b/rag/services/soil_anomaly.py index f902c83..11fb364 100644 --- a/rag/services/soil_anomaly.py +++ b/rag/services/soil_anomaly.py @@ -14,6 +14,7 @@ from rag.chat import ( build_rag_context, ) from rag.config import RAGConfig, get_service_config, load_rag_config +from rag.failure_contract import RAGServiceError logger = logging.getLogger(__name__) @@ -39,18 +40,48 @@ def _clean_json(raw: str) -> dict[str, Any]: cleaned = cleaned[4:] cleaned = cleaned.strip() if not cleaned: - return {} + raise RAGServiceError( + error_code="empty_response", + message="Soil anomaly LLM response was empty.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) try: - return json.loads(cleaned) - except (json.JSONDecodeError, ValueError): + parsed = json.loads(cleaned) + except (json.JSONDecodeError, ValueError) as exc: logger.warning("Invalid JSON returned by soil_anomaly LLM: %s", cleaned[:500]) - return {} + raise RAGServiceError( + error_code="invalid_json", + message="Soil anomaly LLM response was not valid JSON.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) from exc + if not isinstance(parsed, dict): + raise RAGServiceError( + error_code="invalid_schema", + message="Soil anomaly LLM response root must be a JSON object.", + source="llm", + retriable=False, + details={"service_id": SERVICE_ID}, + http_status=502, + ) + return parsed def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: farm_details = get_farm_details(farm_uuid) if farm_details is None: - raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.") + raise RAGServiceError( + error_code="farm_not_found", + message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.", + source="farm_data", + details={"farm_uuid": farm_uuid}, + http_status=404, + ) return farm_details @@ -80,9 +111,12 @@ def _validate_anomaly_insight(parsed: dict[str, Any]) -> dict[str, Any]: } missing = [key for key in required_keys if key not in parsed] if missing: - raise ValueError( - "Soil anomaly insight response is missing required fields: " - + ", ".join(missing) + raise RAGServiceError( + error_code="invalid_schema", + message="Soil anomaly insight response is missing required fields: " + ", ".join(missing), + source="llm", + details={"missing_fields": missing, "service_id": SERVICE_ID}, + http_status=502, ) return parsed @@ -156,12 +190,25 @@ def get_soil_anomaly_insight( raw = response.choices[0].message.content.strip() parsed = _clean_json(raw) _complete_audit_log(audit_log, raw) + except RAGServiceError as exc: + logger.error("Soil anomaly insight failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise except Exception as exc: logger.error("Soil anomaly insight failed for %s: %s", farm_uuid, exc) _fail_audit_log(audit_log, str(exc)) - raise RuntimeError(f"Soil anomaly insight failed for farm {farm_uuid}.") from exc + raise RAGServiceError( + error_code="upstream_failure", + message=f"Soil anomaly insight failed for farm {farm_uuid}.", + source="llm", + retriable=True, + details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID}, + http_status=503, + ) from exc parsed = _validate_anomaly_insight(parsed) + parsed["status"] = "success" + parsed["source"] = "llm" parsed["farm_uuid"] = farm_uuid parsed["raw_response"] = raw return parsed diff --git a/rag/services/water_need_prediction.py b/rag/services/water_need_prediction.py index 0ddbfa7..ce93745 100644 --- a/rag/services/water_need_prediction.py +++ b/rag/services/water_need_prediction.py @@ -14,6 +14,7 @@ from rag.chat import ( build_rag_context, ) from rag.config import RAGConfig, get_service_config, load_rag_config +from rag.failure_contract import RAGServiceError logger = logging.getLogger(__name__) @@ -38,18 +39,47 @@ def _clean_json(raw: str) -> dict[str, Any]: cleaned = cleaned[4:] cleaned = cleaned.strip() if not cleaned: - return {} + raise RAGServiceError( + error_code="empty_response", + message="Water need prediction LLM response was empty.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) try: - return json.loads(cleaned) - except (json.JSONDecodeError, ValueError): + parsed = json.loads(cleaned) + except (json.JSONDecodeError, ValueError) as exc: logger.warning("Invalid JSON returned by water_need_prediction LLM: %s", cleaned[:500]) - return {} + raise RAGServiceError( + error_code="invalid_json", + message="Water need prediction LLM response was not valid JSON.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) from exc + if not isinstance(parsed, dict): + raise RAGServiceError( + error_code="invalid_schema", + message="Water need prediction LLM response root must be a JSON object.", + source="llm", + details={"service_id": SERVICE_ID}, + http_status=502, + ) + return parsed def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: farm_details = get_farm_details(farm_uuid) if farm_details is None: - raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.") + raise RAGServiceError( + error_code="farm_not_found", + message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.", + source="farm_data", + details={"farm_uuid": farm_uuid}, + http_status=404, + ) return farm_details @@ -78,9 +108,12 @@ def _validate_prediction_insight(parsed: dict[str, Any]) -> dict[str, Any]: } missing = [key for key in required_keys if key not in parsed] if missing: - raise ValueError( - "Water need prediction insight response is missing required fields: " - + ", ".join(missing) + raise RAGServiceError( + error_code="invalid_schema", + message="Water need prediction insight response is missing required fields: " + ", ".join(missing), + source="llm", + details={"missing_fields": missing, "service_id": SERVICE_ID}, + http_status=502, ) return parsed @@ -154,12 +187,25 @@ def get_water_need_prediction_insight( raw = response.choices[0].message.content.strip() parsed = _clean_json(raw) _complete_audit_log(audit_log, raw) + except RAGServiceError as exc: + logger.error("Water need prediction insight failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise except Exception as exc: logger.error("Water need prediction insight failed for %s: %s", farm_uuid, exc) _fail_audit_log(audit_log, str(exc)) - raise RuntimeError(f"Water need prediction insight failed for farm {farm_uuid}.") from exc + raise RAGServiceError( + error_code="upstream_failure", + message=f"Water need prediction insight failed for farm {farm_uuid}.", + source="llm", + retriable=True, + details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID}, + http_status=503, + ) from exc parsed = _validate_prediction_insight(parsed) + parsed["status"] = "success" + parsed["source"] = "llm" parsed["farm_uuid"] = farm_uuid parsed["raw_response"] = raw return parsed diff --git a/rag/services/yield_harvest.py b/rag/services/yield_harvest.py index 2b3ef23..989d118 100644 --- a/rag/services/yield_harvest.py +++ b/rag/services/yield_harvest.py @@ -14,6 +14,7 @@ from rag.chat import ( _load_service_tone, ) from rag.config import RAGConfig, get_service_config, load_rag_config +from rag.failure_contract import RAGServiceError logger = logging.getLogger(__name__) @@ -90,6 +91,8 @@ class YieldHarvestRAGService: if audit_log is not None: _complete_audit_log(audit_log, raw) return { + "status": "success", + "source": "llm", "season_highlights_subtitle": validated.season_highlights_subtitle, "yield_prediction_explanation": validated.yield_prediction_explanation, "harvest_readiness_summary": validated.harvest_readiness_summary, @@ -99,12 +102,25 @@ class YieldHarvestRAGService: logger.warning("Yield harvest narrative parsing failed for farm_uuid=%s: %s", farm_uuid, exc) if audit_log is not None: _fail_audit_log(audit_log, str(exc)) - return {} + raise RAGServiceError( + error_code="invalid_payload", + message=f"Yield harvest narrative parsing failed for farm_uuid={farm_uuid or 'unknown'}.", + source="llm", + details={"farm_uuid": farm_uuid or "unknown", "service_id": SERVICE_ID}, + http_status=502, + ) from exc except Exception as exc: logger.error("Yield harvest narrative LLM call failed for farm_uuid=%s: %s", farm_uuid, exc) if audit_log is not None: _fail_audit_log(audit_log, str(exc)) - return {} + raise RAGServiceError( + error_code="upstream_failure", + message=f"Yield harvest narrative generation failed for farm_uuid={farm_uuid or 'unknown'}.", + source="llm", + retriable=True, + details={"farm_uuid": farm_uuid or "unknown", "service_id": SERVICE_ID}, + http_status=503, + ) from exc def _build_service_client(self, cfg: RAGConfig): service = get_service_config(SERVICE_ID, cfg) @@ -217,11 +233,31 @@ class YieldHarvestRAGService: cleaned = cleaned[4:] cleaned = cleaned.strip() if not cleaned: - raise ValueError("Yield harvest narrative response was empty.") + raise RAGServiceError( + error_code="empty_response", + message="Yield harvest narrative response was empty.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) try: parsed = json.loads(cleaned) except (json.JSONDecodeError, ValueError) as exc: - raise ValueError("Yield harvest narrative response was not valid JSON.") from exc + raise RAGServiceError( + error_code="invalid_json", + message="Yield harvest narrative response was not valid JSON.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) from exc if not isinstance(parsed, dict): - raise ValueError("Yield harvest narrative response root must be a JSON object.") + raise RAGServiceError( + error_code="invalid_schema", + message="Yield harvest narrative response root must be a JSON object.", + source="llm", + details={"service_id": SERVICE_ID}, + http_status=502, + ) return parsed diff --git a/rag/tests/test_failure_contracts.py b/rag/tests/test_failure_contracts.py new file mode 100644 index 0000000..135d557 --- /dev/null +++ b/rag/tests/test_failure_contracts.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from django.test import SimpleTestCase + +from rag.failure_contract import RAGServiceError +from rag.services.pest_disease import get_pest_disease_detection +from rag.services.soil_anomaly import get_soil_anomaly_insight +from rag.services.water_need_prediction import get_water_need_prediction_insight +from rag.services.yield_harvest import YieldHarvestRAGService + + +class RAGFailureContractTests(SimpleTestCase): + @patch("rag.services.soil_anomaly._create_audit_log", return_value=object()) + @patch("rag.services.soil_anomaly._fail_audit_log") + @patch("rag.services.soil_anomaly._build_service_client") + @patch("rag.services.soil_anomaly.build_rag_context", return_value="") + @patch("rag.services.soil_anomaly._load_farm_or_error", return_value={"farm_uuid": "farm-1"}) + def test_soil_anomaly_invalid_json_raises_structured_error( + self, + _mock_load_farm, + _mock_context, + mock_build_client, + _mock_fail, + _mock_audit, + ): + client = Mock() + client.chat.completions.create.return_value = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="not-json"))] + ) + mock_build_client.return_value = (SimpleNamespace(system_prompt=""), client, "gpt-test") + + with self.assertRaises(RAGServiceError) as exc_info: + get_soil_anomaly_insight(farm_uuid="farm-1", anomaly_payload={}) + + self.assertEqual(exc_info.exception.contract.error_code, "invalid_json") + + @patch("rag.services.water_need_prediction._create_audit_log", return_value=object()) + @patch("rag.services.water_need_prediction._fail_audit_log") + @patch("rag.services.water_need_prediction._build_service_client") + @patch("rag.services.water_need_prediction.build_rag_context", return_value="") + @patch("rag.services.water_need_prediction._load_farm_or_error", return_value={"farm_uuid": "farm-1"}) + def test_water_need_invalid_json_raises_structured_error( + self, + _mock_load_farm, + _mock_context, + mock_build_client, + _mock_fail, + _mock_audit, + ): + client = Mock() + client.chat.completions.create.return_value = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="not-json"))] + ) + mock_build_client.return_value = (SimpleNamespace(system_prompt=""), client, "gpt-test") + + with self.assertRaises(RAGServiceError) as exc_info: + get_water_need_prediction_insight(farm_uuid="farm-1", prediction_payload={}) + + self.assertEqual(exc_info.exception.contract.error_code, "invalid_json") + + def test_pest_detection_requires_image_with_structured_error(self): + with self.assertRaises(RAGServiceError) as exc_info: + get_pest_disease_detection(farm_uuid="farm-1", images=[]) + + self.assertEqual(exc_info.exception.contract.error_code, "missing_images") + + @patch("rag.services.yield_harvest._create_audit_log", return_value=object()) + @patch("rag.services.yield_harvest._fail_audit_log") + @patch("rag.services.yield_harvest.YieldHarvestRAGService._build_service_client") + def test_yield_harvest_invalid_json_raises_structured_error( + self, + mock_build_client, + _mock_fail, + _mock_audit, + ): + client = Mock() + client.chat.completions.create.return_value = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="not-json"))] + ) + mock_build_client.return_value = (SimpleNamespace(system_prompt=""), client, "gpt-test") + + with self.assertRaises(RAGServiceError) as exc_info: + YieldHarvestRAGService().generate_narrative({"farm_uuid": "farm-1"}) + + self.assertEqual(exc_info.exception.contract.error_code, "invalid_json") diff --git a/rag/tests/test_observability.py b/rag/tests/test_observability.py new file mode 100644 index 0000000..1a38d49 --- /dev/null +++ b/rag/tests/test_observability.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from django.test import SimpleTestCase + +from rag.embedding import embed_texts +from rag.ingest import ingest +from rag.observability import METRICS +from rag.retrieve import search_with_query + + +class RAGObservabilityTests(SimpleTestCase): + def tearDown(self): + METRICS.clear() + + def test_embed_texts_records_empty_input_metric(self): + result = embed_texts([]) + + self.assertEqual(result, []) + self.assertEqual(METRICS["rag.embedding.empty_input|operation=embed_texts"], 1) + + @patch("rag.retrieve.QdrantVectorStore") + @patch("rag.retrieve.embed_single", return_value=[0.1, 0.2]) + @patch("rag.retrieve.load_rag_config") + def test_search_with_query_records_empty_result_metric(self, mock_load_config, _mock_embed, mock_store_cls): + mock_load_config.return_value = SimpleNamespace( + embedding=SimpleNamespace(provider="gapgpt"), + ) + mock_store = Mock() + mock_store.search.return_value = [] + mock_store_cls.return_value = mock_store + + result = search_with_query("query") + + self.assertEqual(result, []) + self.assertEqual(METRICS["rag.retrieve.empty_result|operation=search_with_query,service_id=None"], 1) + + @patch("rag.ingest.load_sources", return_value=[]) + @patch("rag.ingest.QdrantVectorStore") + @patch("rag.ingest.load_rag_config") + def test_ingest_records_empty_sources_metric(self, mock_load_config, _mock_store_cls, _mock_sources): + mock_load_config.return_value = SimpleNamespace( + embedding=SimpleNamespace(provider="gapgpt"), + ) + + result = ingest() + + self.assertEqual(result["chunks_added"], 0) + self.assertEqual(METRICS["rag.ingest.empty_sources|kb_name=None"], 1) diff --git a/rag/tests/test_recommendation_services.py b/rag/tests/test_recommendation_services.py index 1231a44..f7184e4 100644 --- a/rag/tests/test_recommendation_services.py +++ b/rag/tests/test_recommendation_services.py @@ -4,10 +4,10 @@ from unittest.mock import Mock, patch from django.test import TestCase -from farm_data.models import SensorData +from farm_data.models import PlantCatalogSnapshot, SensorData +from farm_data.services import assign_farm_plants_from_backend_ids from irrigation.models import IrrigationMethod from location_data.models import SoilLocation -from plant.models import Plant from rag.services.fertilization import get_fertilization_recommendation from rag.services.irrigation import get_irrigation_recommendation from weather.models import WeatherForecast @@ -27,8 +27,8 @@ class RecommendationServiceDefaultsTests(TestCase): temperature_max=23.0, temperature_mean=18.0, ) - self.plant = Plant.objects.create(name="گوجه‌فرنگی") - self.onion = Plant.objects.create(name="پیاز") + self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=101, name="گوجه‌فرنگی") + self.onion = PlantCatalogSnapshot.objects.create(backend_plant_id=102, name="پیاز") self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای") self.farm_uuid = uuid.uuid4() self.farm = SensorData.objects.create( @@ -45,7 +45,7 @@ class RecommendationServiceDefaultsTests(TestCase): } }, ) - self.farm.plants.set([self.plant]) + assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id]) def build_irrigation_optimizer_result(self): return { @@ -162,6 +162,39 @@ class RecommendationServiceDefaultsTests(TestCase): self.assertEqual(result["sections"][1]["type"], "tip") self.assertEqual(result["water_balance"]["active_kc"], 0.9) + @patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[]) + @patch("rag.services.irrigation.resolve_kc", return_value=0.9) + @patch("rag.services.irrigation.resolve_crop_profile", return_value={}) + @patch("rag.services.irrigation.build_irrigation_method_text", return_value="method text") + @patch("rag.services.irrigation.build_plant_text", return_value="plant text") + @patch("rag.services.irrigation.build_rag_context", return_value="") + @patch("rag.services.irrigation._get_optimizer") + @patch("rag.services.irrigation.get_chat_client") + def test_irrigation_recommendation_reads_from_canonical_farm_data_assignments( + self, + mock_get_chat_client, + mock_get_optimizer, + _mock_build_rag_context, + mock_build_plant_text, + _mock_build_irrigation_method_text, + _mock_resolve_crop_profile, + _mock_resolve_kc, + _mock_calculate_forecast_water_needs, + ): + assign_farm_plants_from_backend_ids(self.farm, [self.onion.backend_plant_id, self.plant.backend_plant_id]) + mock_get_optimizer.return_value.optimize_irrigation.return_value = self.build_irrigation_optimizer_result() + mock_response = Mock() + mock_response.choices = [Mock(message=Mock(content=self.build_irrigation_llm_result()))] + mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response + + result = get_irrigation_recommendation( + farm_uuid=str(self.farm_uuid), + growth_stage="میوه‌دهی", + ) + + self.assertEqual(result["selected_plant"]["name"], "پیاز") + mock_build_plant_text.assert_called_once_with("پیاز", "میوه‌دهی") + @patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[]) @patch("rag.services.irrigation.resolve_kc", return_value=0.9) @patch("rag.services.irrigation.resolve_crop_profile", return_value={}) @@ -299,6 +332,34 @@ class RecommendationServiceDefaultsTests(TestCase): mock_build_plant_text.assert_called_once_with("پیاز", "flowering") self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20") + @patch("rag.services.fertilization.build_plant_text", return_value="plant text") + @patch("rag.services.fertilization.build_rag_context", return_value="") + @patch("rag.services.fertilization._get_optimizer") + @patch("rag.services.fertilization.get_chat_client") + def test_fertilization_recommendation_uses_canonical_assignment_lookup_for_requested_catalog_plant( + self, + mock_get_chat_client, + mock_get_optimizer, + _mock_build_rag_context, + mock_build_plant_text, + ): + assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id, self.onion.backend_plant_id]) + mock_get_optimizer.return_value.optimize_fertilization.return_value = self.build_fertilization_optimizer_result() + mock_response = Mock() + mock_response.choices = [Mock(message=Mock(content="not-json"))] + mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response + + result = get_fertilization_recommendation( + farm_uuid=str(self.farm_uuid), + plant_name="پیاز", + growth_stage="گلدهی", + ) + + optimizer_call = mock_get_optimizer.return_value.optimize_fertilization.call_args.kwargs + self.assertEqual(getattr(optimizer_call["plant"], "name", None), "پیاز") + mock_build_plant_text.assert_called_once_with("پیاز", "flowering") + self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20") + @patch("rag.services.fertilization.build_plant_text", return_value="plant text") @patch("rag.services.fertilization.build_rag_context", return_value="") @patch("rag.services.fertilization._get_optimizer") diff --git a/requirements.txt b/requirements.txt index 4740cb2..9643755 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,8 +9,11 @@ mysqlclient>=2.2,<2.3 # === Server === gunicorn>=22,<23 +whitenoise>=6.7,<6.8 # === API Docs === +drf-spectacular>=0.27,<0.28 +drf-spectacular-sidecar>=2024.5,<2025.0 # === Config === python-dotenv>=1.0,<1.1 diff --git a/soile/test_soil_moisture_heatmap_api.py b/soile/test_soil_moisture_heatmap_api.py index cfe3775..3a36d56 100644 --- a/soile/test_soil_moisture_heatmap_api.py +++ b/soile/test_soil_moisture_heatmap_api.py @@ -8,6 +8,7 @@ from django.test import TestCase, override_settings from django.utils import timezone from rest_framework.test import APIClient +from rag.failure_contract import RAGServiceError from soile.services import SoilMoistureHeatmapService @@ -128,6 +129,32 @@ class SoilAnomalyDetectionApiTests(TestCase): self.assertEqual(response.status_code, 404) self.assertEqual(response.json()["msg"], "Farm not found.") + @patch("soile.views.apps.get_app_config") + def test_anomaly_api_returns_structured_failure_for_invalid_llm_json(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_anomaly_detection=lambda **_kwargs: (_ for _ in ()).throw( + RAGServiceError( + error_code="invalid_json", + message="Soil anomaly LLM response was not valid JSON.", + source="llm", + retriable=True, + http_status=502, + ) + ) + ) + mock_get_app_config.return_value = SimpleNamespace( + get_soil_anomaly_service=lambda: mock_service + ) + + response = self.client.post( + "/anomaly-detection/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 502) + self.assertEqual(response.json()["data"]["error_code"], "invalid_json") + class SoilMoistureHeatmapServiceTests(TestCase): @patch("soile.services.SensorData.objects") diff --git a/soile/views.py b/soile/views.py index 6623090..7921801 100644 --- a/soile/views.py +++ b/soile/views.py @@ -6,6 +6,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from config.openapi import build_envelope_serializer, build_response +from rag.failure_contract import RAGServiceError from .serializers import ( SoilAnomalyDetectionRequestSerializer, @@ -179,6 +180,11 @@ class SoilAnomalyDetectionView(APIView): {"code": 404, "msg": str(exc), "data": None}, status=status.HTTP_404_NOT_FOUND, ) + except RAGServiceError as exc: + return Response( + {"code": exc.http_status, "msg": exc.contract.message, "data": exc.to_dict()}, + status=exc.http_status, + ) except Exception as exc: return Response( {"code": 500, "msg": f"خطا در تحلیل ناهنجاری خاک: {exc}", "data": None}, diff --git a/weather/adapters.py b/weather/adapters.py index a7cc7df..56ae4b0 100644 --- a/weather/adapters.py +++ b/weather/adapters.py @@ -265,7 +265,7 @@ class MockWeatherAdapter(BaseWeatherAdapter): def get_weather_adapter() -> BaseWeatherAdapter: from django.conf import settings - provider = getattr(settings, "WEATHER_DATA_PROVIDER", "mock") + provider = getattr(settings, "WEATHER_DATA_PROVIDER", "open-meteo") if provider == "open-meteo": return OpenMeteoWeatherAdapter( base_url=settings.WEATHER_API_BASE_URL, @@ -273,6 +273,8 @@ def get_weather_adapter() -> BaseWeatherAdapter: timeout=getattr(settings, "WEATHER_TIMEOUT_SECONDS", 60), ) if provider == "mock": + if not (getattr(settings, "DEBUG", False) or getattr(settings, "DEVELOP", False)): + raise RuntimeError("Mock weather provider is disabled outside dev/test environments.") return MockWeatherAdapter( delay_seconds=getattr(settings, "WEATHER_MOCK_DELAY_SECONDS", 0.8) ) diff --git a/weather/test_farm_weather_api.py b/weather/test_farm_weather_api.py index 0cb5461..bdcb262 100644 --- a/weather/test_farm_weather_api.py +++ b/weather/test_farm_weather_api.py @@ -6,6 +6,8 @@ from unittest.mock import patch from django.test import TestCase, override_settings from rest_framework.test import APIClient +from rag.failure_contract import RAGServiceError + @override_settings(ROOT_URLCONF="weather.urls") class FarmWeatherApiTests(TestCase): @@ -122,3 +124,29 @@ class WaterNeedPredictionApiTests(TestCase): self.assertEqual(response.status_code, 404) self.assertEqual(response.json()["msg"], "Farm not found.") + + @patch("weather.views.apps.get_app_config") + def test_water_need_api_returns_structured_failure_for_invalid_llm_json(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_water_need_prediction=lambda **_kwargs: (_ for _ in ()).throw( + RAGServiceError( + error_code="invalid_json", + message="Water need prediction LLM response was not valid JSON.", + source="llm", + retriable=True, + http_status=502, + ) + ) + ) + mock_get_app_config.return_value = SimpleNamespace( + get_water_need_service=lambda: mock_service + ) + + response = self.client.post( + "/water-need-prediction/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 502) + self.assertEqual(response.json()["data"]["error_code"], "invalid_json") diff --git a/weather/views.py b/weather/views.py index 579c6d4..c43a1ec 100644 --- a/weather/views.py +++ b/weather/views.py @@ -6,6 +6,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from config.openapi import build_envelope_serializer, build_response +from rag.failure_contract import RAGServiceError from .serializers import ( FarmWeatherRequestSerializer, @@ -133,6 +134,11 @@ class WaterNeedPredictionView(APIView): {"code": 404, "msg": str(exc), "data": None}, status=status.HTTP_404_NOT_FOUND, ) + except RAGServiceError as exc: + return Response( + {"code": exc.http_status, "msg": exc.contract.message, "data": exc.to_dict()}, + status=exc.http_status, + ) except Exception as exc: return Response( {"code": 500, "msg": f"خطا در تحلیل نیاز آبی مزرعه: {exc}", "data": None},