UPDATE
This commit is contained in:
+3
-3
@@ -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
|
||||
+69
-522
@@ -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 بودن را جداگانه مستند کنید.
|
||||
|
||||
+55
-347
@@ -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/<task_id>/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/<farm_uuid>/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/<pk>/` | جزئیات گیاه |
|
||||
| PUT | `/api/plants/<pk>/` | ویرایش کامل گیاه |
|
||||
| PATCH | `/api/plants/<pk>/` | ویرایش جزئی گیاه |
|
||||
| DELETE | `/api/plants/<pk>/` | حذف گیاه |
|
||||
| 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/<pk>/` | جزئیات روش آبیاری |
|
||||
| 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/<task_id>/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/<pk>/` به احتمال زیاد `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/<task_id>/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/<farm_uuid>/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/<pk>/` | `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/<pk>/` | `implemented` | Live route on AI service. |
|
||||
| `irrigation` | `PUT` | `/api/irrigation/<pk>/` | `implemented` | Live route on AI service. |
|
||||
| `irrigation` | `PATCH` | `/api/irrigation/<pk>/` | `implemented` | Live route on AI service. |
|
||||
| `irrigation` | `DELETE` | `/api/irrigation/<pk>/` | `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/<task_id>/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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
Submodule Schemas updated: 78acb5510d...536deea4b2
@@ -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
|
||||
@@ -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"
|
||||
|
||||
+34
-2
@@ -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,
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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]:
|
||||
@@ -110,6 +112,60 @@ class CropSimulationServiceTests(TestCase):
|
||||
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(
|
||||
self.service.manager,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -1,8 +0,0 @@
|
||||
class OpenApiTypes:
|
||||
STR = str
|
||||
INT = int
|
||||
BOOL = bool
|
||||
UUID = str
|
||||
DATE = str
|
||||
DATETIME = str
|
||||
OBJECT = dict
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+63
-21
@@ -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
|
||||
|
||||
|
||||
@@ -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/")
|
||||
|
||||
|
||||
+44
-1
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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},
|
||||
|
||||
+35
-2
@@ -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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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()
|
||||
+19
-1
@@ -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,11 +135,13 @@ def ingest(
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
store = QdrantVectorStore(config=cfg)
|
||||
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:
|
||||
record_metric("rag.ingest.empty_sources", kb_name=kb_name)
|
||||
return {"chunks_added": 0, "sources": [], "error": "هیچ منبعی یافت نشد"}
|
||||
|
||||
all_chunks: list[str] = []
|
||||
@@ -147,10 +162,12 @@ def ingest(
|
||||
})
|
||||
|
||||
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):
|
||||
record_metric("rag.ingest.embedding_mismatch", kb_name=kb_name)
|
||||
return {
|
||||
"chunks_added": 0,
|
||||
"sources": [s[0] for s in sources],
|
||||
@@ -163,6 +180,7 @@ def ingest(
|
||||
documents=all_chunks,
|
||||
metadatas=all_metas,
|
||||
)
|
||||
record_metric("rag.ingest.success", kb_name=kb_name, chunks=len(all_chunks))
|
||||
return {
|
||||
"chunks_added": len(all_chunks),
|
||||
"sources": [s[0] for s in sources],
|
||||
|
||||
@@ -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
|
||||
+11
-2
@@ -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,
|
||||
)
|
||||
|
||||
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)
|
||||
return store.search(
|
||||
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,6 +107,7 @@ def search_with_texts(
|
||||
)
|
||||
|
||||
store = QdrantVectorStore(config=cfg)
|
||||
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] = {}
|
||||
|
||||
@@ -118,8 +124,11 @@ def search_with_texts(
|
||||
if current is None or item["score"] > current["score"]:
|
||||
merged_results[item["id"]] = item
|
||||
|
||||
return sorted(
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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},
|
||||
|
||||
+3
-1
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user