This commit is contained in:
2026-05-05 21:02:12 +03:30
parent 5301071df5
commit 1679825ae2
47 changed files with 1347 additions and 1403 deletions
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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.
-7
View File
@@ -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
+39
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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,
+4
View File
@@ -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")),
+3 -6
View File
@@ -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
+4 -4
View File
@@ -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
+9 -13
View File
@@ -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"))
+56
View File
@@ -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,
+82 -6
View File
@@ -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,
)
+4 -4
View File
@@ -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 -3
View File
@@ -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:
-19
View File
@@ -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",
]
-8
View File
@@ -1,8 +0,0 @@
class OpenApiTypes:
STR = str
INT = int
BOOL = bool
UUID = str
DATE = str
DATETIME = str
OBJECT = dict
-60
View File
@@ -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)
-19
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
+25 -1
View File
@@ -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
View File
@@ -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,
)
+3 -1
View File
@@ -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)
)
-67
View File
@@ -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
-179
View File
@@ -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
+11
View File
@@ -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
View File
@@ -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
+55
View File
@@ -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
View File
@@ -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],
+129
View File
@@ -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
View File
@@ -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
+81 -14
View File
@@ -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
+56 -9
View File
@@ -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
+55 -9
View File
@@ -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
+41 -5
View File
@@ -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
+88
View File
@@ -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")
+51
View File
@@ -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)
+66 -5
View File
@@ -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")
+3
View File
@@ -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
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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)
)
+28
View File
@@ -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
View File
@@ -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},