This commit is contained in:
2026-05-11 03:27:21 +03:30
parent cf7cbb937c
commit d0e68a1a56
854 changed files with 102985 additions and 76 deletions
+527
View File
@@ -0,0 +1,527 @@
# توضیح خیلی ساده منطق RAG در پروژه
این فایل قرار است خیلی ساده بگوید RAG در این پروژه چطور کار می‌کند.
## اول: RAG یعنی چه؟
RAG یعنی:
1. سوال کاربر را می‌گیریم
2. متن‌های مرتبط را از حافظه دانشی پیدا می‌کنیم
3. آن متن‌ها را کنار سوال می‌گذاریم
4. بعد از مدل زبانی می‌خواهیم جواب بدهد
یعنی مدل فقط از حافظه خودش جواب نمی‌دهد؛
قبل از جواب دادن، اطلاعات مرتبط پروژه را هم می‌بیند.
---
## نقش فایل `rag/apps.py`
فایل `rag/apps.py` فقط اپ Django مربوط به RAG را ثبت می‌کند.
کار اصلی‌اش این است:
- اسم اپ را مشخص می‌کند: `rag`
- نام نمایشی اپ را مشخص می‌کند
پس:
- `rag/apps.py` منطق اصلی RAG را پیاده‌سازی نمی‌کند
- فقط می‌گوید این اپ در پروژه وجود دارد
منطق اصلی RAG بیشتر در این فایل‌هاست:
- `rag/views.py`
- `rag/chat.py`
- `rag/retrieve.py`
- `rag/ingest.py`
- `rag/embedding.py`
- `rag/vector_store.py`
- `rag/user_data.py`
- `rag/config.py`
---
## تصویر خیلی ساده از کل جریان
RAG در این پروژه دو بخش اصلی دارد:
### 1) آماده‌سازی دانش
در این بخش سیستم اطلاعات را جمع می‌کند و داخل دیتابیس برداری ذخیره می‌کند.
مراحل:
1. فایل‌های دانش را می‌خواند
2. متن‌ها را خرد می‌کند
3. هر تکه را تبدیل به embedding می‌کند
4. embeddingها را داخل Qdrant ذخیره می‌کند
این کار بیشتر در `rag/ingest.py` انجام می‌شود.
### 2) جواب دادن به سوال کاربر
در این بخش وقتی کاربر سوال می‌پرسد:
1. سوال embedding می‌شود
2. متن‌های نزدیک و مرتبط پیدا می‌شوند
3. داده‌های کاربر هم اضافه می‌شود
4. همه این‌ها به مدل زبانی داده می‌شود
5. مدل جواب را به صورت stream برمی‌گرداند
این کار بیشتر در `rag/chat.py` و `rag/retrieve.py` انجام می‌شود.
---
## بخش اول: سیستم چطور دانش را آماده می‌کند؟
### فایل اصلی: `rag/ingest.py`
این فایل کارش این است که اطلاعات را وارد سیستم RAG کند.
### از کجا اطلاعات می‌آید؟
سیستم این منابع را می‌خواند:
- فایل‌های پایگاه دانش
- فایل لحن یا tone
- داده‌های خاک هر کاربر
- داده‌های هواشناسی هر کاربر
### پایگاه دانش یعنی چه؟
پایگاه دانش یعنی متن‌هایی که پروژه از قبل دارد.
مثلا:
- اطلاعات عمومی چت
- اطلاعات آبیاری
- اطلاعات کودهی
در تنظیمات، برای هر بخش یک knowledge base تعریف شده است.
---
## مرحله 1: خواندن منابع
تابع `load_sources()` در `rag/ingest.py` منابع را جمع می‌کند.
خروجی این تابع تقریبا این شکلی است:
- شناسه منبع
- متن منبع
- شناسه سنسور یا کاربر
- نام پایگاه دانش
نکته مهم:
- داده‌های عمومی با `__global__` ذخیره می‌شوند
- داده‌های شخصی هر کاربر با `sensor_uuid` خودش ذخیره می‌شوند
- داده‌های کاربری معمولا با `__all__` در `kb_name` علامت می‌خورند
این کار باعث می‌شود بعدا سیستم بداند هر متن برای چه کسی یا چه بخشی بوده است.
---
## مرحله 2: خرد کردن متن
### فایل: `rag/chunker.py`
متن‌های طولانی مستقیم وارد جستجو نمی‌شوند.
اول آن‌ها را به تکه‌های کوچک‌تر تبدیل می‌کنیم.
چرا؟
چون:
- جستجو دقیق‌تر می‌شود
- embedding بهتر می‌شود
- مدل فقط بخش‌های لازم را می‌بیند
مثلا یک فایل بلند به چند chunk تبدیل می‌شود.
---
## مرحله 3: ساخت embedding
### فایل: `rag/embedding.py`
هر chunk متنی به یک لیست عددی تبدیل می‌شود.
به این لیست عددی می‌گوییم embedding.
خیلی ساده:
- متن شبیه به هم -> embedding شبیه به هم
- متن متفاوت -> embedding متفاوت
پس بعدا اگر کاربر سوالی شبیه یک متن بپرسد،
سیستم می‌تواند آن متن را پیدا کند.
---
## مرحله 4: ذخیره در Qdrant
### فایل: `rag/vector_store.py`
بعد از ساخت embedding، داده‌ها داخل Qdrant ذخیره می‌شوند.
Qdrant در این پروژه نقش حافظه برداری را دارد.
برای هر chunk این چیزها ذخیره می‌شود:
- خود متن
- embedding
- منبع متن
- شماره chunk
- `sensor_uuid`
- `kb_name`
این metadata خیلی مهم است؛
چون کمک می‌کند بعدا فقط داده‌های مرتبط برگردند.
---
## دستور ورود اطلاعات
### فایل: `rag/management/commands/rag_ingest.py`
این فایل یک command جنگو دارد که ingestion را اجرا می‌کند.
یعنی اگر این دستور اجرا شود:
```bash
python manage.py rag_ingest
```
سیستم:
- منابع را می‌خواند
- chunk می‌کند
- embedding می‌سازد
- داخل Qdrant ذخیره می‌کند
---
## بخش دوم: وقتی کاربر سوال می‌پرسد چه می‌شود؟
### ورودی API
### فایل: `rag/views.py`
در این فایل endpoint چت وجود دارد.
کارش این است که از کاربر این اطلاعات را بگیرد:
- `service_id`
- `query`
- `user_id` یا `sensor_uuid`
بعد چند بررسی انجام می‌شود:
- آیا سوال خالی نیست؟
- آیا `service_id` معتبر است؟
- اگر سرویس نیاز به داده کاربر دارد، آیا `user_id` داده شده؟
اگر همه چیز درست باشد،
در نهایت `chat_rag_stream()` صدا زده می‌شود.
---
## `service_id` چرا مهم است؟
چون سیستم چند نوع سرویس دارد.
مثلا:
- سرویس چت عمومی
- سرویس آبیاری
- سرویس کودهی
هر سرویس می‌تواند این‌ها را مشخص کند:
- از کدام knowledge base استفاده شود
- از چه مدل زبانی استفاده شود
- آیا داده‌های شخصی کاربر لازم است یا نه
- چه tone یا system prompt استفاده شود
این تنظیمات در `rag/config.py` و فایل `config/rag_config.yaml` مدیریت می‌شوند.
---
## ساخت context
### فایل اصلی: `rag/chat.py`
مهم‌ترین بخش پاسخ‌گویی همین‌جاست.
تابع مهم: `build_rag_context()`
این تابع یک context برای مدل می‌سازد.
این context از چند بخش ساخته می‌شود:
1. داده فعلی خاک کاربر
2. داده هواشناسی کاربر
3. متن‌های مرتبط پیدا شده از RAG
یعنی مدل فقط سوال را نمی‌بیند؛
بلکه این اطلاعات کمکی را هم می‌بیند.
---
## داده خاک و هواشناسی کاربر از کجا می‌آید؟
### فایل: `rag/user_data.py`
این فایل اطلاعات کاربر را از دیتابیس پروژه می‌سازد.
دو تابع مهم:
- `build_user_soil_text(sensor_uuid)`
- `build_user_weather_text(sensor_uuid)`
کار این توابع:
- داده‌های مدل‌های پروژه را می‌خوانند
- آن‌ها را به متن ساده تبدیل می‌کنند
چرا به متن؟
چون سیستم RAG در نهایت با متن کار می‌کند.
پس حتی داده‌های دیتابیس هم به متن تبدیل می‌شوند تا:
- embed شوند
- یا مستقیم داخل context قرار بگیرند
---
## پیدا کردن متن‌های مرتبط
### فایل: `rag/retrieve.py`
در اینجا تابع `search_with_query()` کار اصلی بازیابی را انجام می‌دهد.
مراحلش ساده است:
1. سوال کاربر embedding می‌شود
2. یک جستجوی شباهت در Qdrant انجام می‌شود
3. فقط متن‌های مجاز برگردانده می‌شوند
---
## چرا گفتیم "متن‌های مجاز"؟
چون این پروژه داده کاربر دارد و نباید اطلاعات یک کاربر به کاربر دیگر برسد.
برای همین موقع جستجو فیلتر گذاشته می‌شود.
فیلترها معمولا این‌ها هستند:
- `sensor_uuid`
- `kb_name`
یعنی سیستم فقط این‌ها را برمی‌گرداند:
- داده‌های عمومی (`__global__`)
- داده‌های همان کاربر
- داده‌های همان knowledge base
پس این بخش برای امنیت و جداسازی اطلاعات خیلی مهم است.
---
## جستجو در Qdrant چطور انجام می‌شود؟
### فایل: `rag/vector_store.py`
تابع `search()` در این فایل:
- query vector را می‌گیرد
- فیلترها را می‌سازد
- از Qdrant نتیجه می‌گیرد
بعد نتیجه‌ها را به شکل ساده برمی‌گرداند:
- `id`
- `score`
- `text`
- `metadata`
`score` یعنی میزان شباهت.
هرچه بیشتر باشد، یعنی متن به سوال نزدیک‌تر است.
---
## بعد از بازیابی چه می‌شود؟
### دوباره در `rag/chat.py`
بعد از این که متن‌های مرتبط پیدا شدند:
- متن‌های مرجع جمع می‌شوند
- داده کاربر هم کنار آن‌ها قرار می‌گیرد
- tone و system prompt هم اضافه می‌شود
در آخر یک پیام system ساخته می‌شود که به مدل می‌گوید:
- از داده‌های خاک استفاده کن
- از متن‌های مرجع استفاده کن
- با زبان کاربر جواب بده
---
## تولید جواب نهایی
### تابع: `chat_rag_stream()`
این تابع:
1. تنظیمات سرویس را می‌خواند
2. context را می‌سازد
3. پیام system و user را آماده می‌کند
4. به مدل زبانی درخواست می‌فرستد
5. جواب را به صورت stream برمی‌گرداند
پس جواب نهایی فقط از خود مدل نیست؛
بلکه از ترکیب این‌ها ساخته می‌شود:
- سوال کاربر
- داده‌های فعلی کاربر
- متن‌های مرجع RAG
- لحن و دستور سیستم
---
## tone چیست؟
tone یعنی لحن پاسخ.
مثلا سیستم می‌تواند مشخص کند:
- رسمی جواب بده
- ساده جواب بده
- تخصصی جواب بده
فایل‌های tone از روی knowledge base یا service خوانده می‌شوند.
پس tone روی سبک جواب اثر دارد،
نه روی اصل جستجو.
---
## نقش `rag/config.py`
این فایل تنظیمات را بارگذاری می‌کند.
مثلا:
- مدل embedding چیست
- Qdrant کجاست
- اندازه vector چقدر است
- chunking چگونه باشد
- سرویس‌ها چه هستند
- هر سرویس از کدام knowledge base استفاده کند
یعنی این فایل مغز تنظیمات سیستم است.
---
## خلاصه خیلی ساده کل مسیر
اگر بخواهیم خیلی خلاصه بگوییم:
### مرحله آماده‌سازی
1. فایل‌ها و داده‌های کاربر خوانده می‌شوند
2. متن‌ها chunk می‌شوند
3. embedding ساخته می‌شود
4. داخل Qdrant ذخیره می‌شوند
### مرحله پاسخ‌گویی
1. کاربر سوال می‌پرسد
2. سوال embedding می‌شود
3. متن‌های مشابه پیدا می‌شوند
4. داده خاک و هواشناسی کاربر هم اضافه می‌شود
5. همه این‌ها به LLM داده می‌شود
6. LLM جواب نهایی را می‌سازد
---
## فرق این پروژه با یک چت ساده
اگر چت ساده بود:
- مدل فقط با دانسته‌های خودش جواب می‌داد
ولی اینجا:
- مدل به داده‌های واقعی پروژه دسترسی دارد
- داده‌های همان کاربر را می‌بیند
- از متن‌های مرجع واقعی استفاده می‌کند
پس جواب‌ها:
- دقیق‌تر می‌شوند
- شخصی‌تر می‌شوند
- به داده‌های واقعی نزدیک‌تر می‌شوند
---
## فایل‌ها را خیلی ساده به خاطر بسپار
- `rag/apps.py` -> فقط ثبت اپ
- `rag/views.py` -> گرفتن درخواست کاربر
- `rag/chat.py` -> ساخت context و گرفتن جواب از مدل
- `rag/retrieve.py` -> جستجوی متن مرتبط
- `rag/ingest.py` -> وارد کردن دانش به سیستم
- `rag/embedding.py` -> تبدیل متن به embedding
- `rag/vector_store.py` -> ذخیره و جستجو در Qdrant
- `rag/user_data.py` -> ساخت متن از داده‌های کاربر
- `rag/config.py` -> تنظیمات کل RAG
---
## یک مثال خیلی ساده
فرض کن کاربر بپرسد:
`آیا خاک من برای آبیاری مناسب است؟`
سیستم این کارها را می‌کند:
1. سوال را می‌گیرد
2. می‌فهمد باید از سرویس یا دانش آبیاری استفاده کند
3. داده خاک همان کاربر را از دیتابیس می‌گیرد
4. داده هواشناسی را هم می‌گیرد
5. متن‌های مرتبط آبیاری را از Qdrant پیدا می‌کند
6. همه را به مدل می‌دهد
7. مدل جواب می‌دهد
پس جواب نهایی فقط یک حدس عمومی نیست؛
بلکه بر اساس:
- اطلاعات خاک
- اطلاعات هوا
- متن‌های مرجع آبیاری
ساخته می‌شود.
---
## نتیجه نهایی
منطق RAG این پروژه به زبان خیلی ساده این است:
- اول دانش را آماده می‌کند
- بعد موقع سوال، دانش مرتبط را پیدا می‌کند
- داده‌های واقعی کاربر را هم اضافه می‌کند
- و در آخر از مدل می‌خواهد با این اطلاعات جواب بدهد
و یادت باشد:
- `rag/apps.py` فقط فایل ثبت اپ است
- منطق واقعی RAG در فایل‌های `chat`, `retrieve`, `ingest`, `vector_store`, `user_data` و `views` قرار دارد
+393
View File
@@ -0,0 +1,393 @@
# مستند سیستم RAG — پایگاه دانش CropLogic
## فهرست
1. [معرفی کلی](#معرفی-کلی)
2. [معماری و ساختار](#معماری-و-ساختار)
3. [منابع داده](#منابع-داده)
4. [پایپ‌لاین Embedding](#پایپلاین-embedding)
5. [نحوه اجرا](#نحوه-اجرا)
6. [فلوی پیام کاربر](#فلوی-پیام-کاربر)
7. [API Endpoint](#api-endpoint)
8. [تنظیمات](#تنظیمات)
9. [ایزوله‌سازی کاربران](#ایزولهسازی-کاربران)
10. [سرویس‌های توصیه](#سرویسهای-توصیه)
---
## معرفی کلی
سیستم RAG در CropLogic یک چت هوشمند کشاورزی است که:
- **دانش پایه کشاورزی** را embed و ذخیره می‌کند
- **داده‌های خاک و هواشناسی هر کاربر** را از DB می‌خواند و embed می‌کند
- وقتی کاربر سوال می‌پرسد، **اطلاعات مرتبط** را بازیابی و به **LLM** ارسال می‌کند
**Vector Store:** Qdrant
**API Provider:** GapGPT (با fallback به Avalai) — Adapter Pattern
### پایگاه‌های دانش مجزا
سیستم از **سه پایگاه دانش** مجزا استفاده می‌کند:
| KB | توضیح | فایل Tone |
|----|-------|-----------|
| `chat` | چت عمومی و پاسخ به سوالات متنوع | `config/tones/chat_tone.txt` |
| `irrigation` | توصیه‌های آبیاری (فرمت JSON) | `config/tones/irrigation_tone.txt` |
| `fertilization` | توصیه‌های کودهی (فرمت JSON) | `config/tones/fertilization_tone.txt` |
تشخیص هوشمند KB از روی کلمات کلیدی سوال (آبیاری، آب، کود، NPK).
---
## معماری و ساختار
```
rag/
├── config.py # بارگذاری تنظیمات از rag_config.yaml
├── api_provider.py # Adapter Pattern برای GapGPT/Avalai
├── client.py # ساخت کلاینت Qdrant
├── chunker.py # تکه‌تکه کردن متن
├── embedding.py # تعبیه‌سازی متن
├── vector_store.py # ذخیره و جستجو در Qdrant (با فیلتر kb_name)
├── user_data.py # خواندن داده‌های خاک/سنسور/هواشناسی از DB
├── ingest.py # پایپ‌لاین: خواندن → چانک → embed → ذخیره
├── retrieve.py # بازیابی: embed کوئری → جستجو
├── chat.py # ساخت context و چت استریمی با LLM
├── views.py # API endpoint
├── urls.py # مسیریابی
├── tasks.py # تسک Celery
├── services/ # سرویس‌های توصیه (بدون API)
│ ├── irrigation.py # توصیه آبیاری
│ └── fertilization.py # توصیه کودهی
└── management/commands/
└── rag_ingest.py
```
فایل‌های تنظیمات:
```
config/
├── rag_config.yaml
├── tones/
│ ├── chat_tone.txt
│ ├── irrigation_tone.txt
│ └── fertilization_tone.txt
└── knowledge_base/
├── chat/
├── irrigation/
└── fertilization/
```
---
## منابع داده
سیستم از **چهار منبع** داده تغذیه می‌شود:
### 1. لحن‌های مجزا — `config/tones/`
هر KB یک فایل لحن مخصوص دارد که سبک خروجی LLM را تعریف می‌کند.
ذخیره با: `sensor_uuid = __global__`, `kb_name = chat|irrigation|fertilization`
### 2. پایگاه‌های دانش — `config/knowledge_base/`
- `chat/`: دانش عمومی کشاورزی
- `irrigation/`: دانش تخصصی آبیاری (ET0، بارش، رطوبت)
- `fertilization/`: دانش تخصصی کودهی (NPK، pH، نوع خاک)
ذخیره با: `sensor_uuid = __global__`, `kb_name = chat|irrigation|fertilization`
### 3. داده‌های خاک کاربر — از DB
برای هر سنسور:
- `SensorData`: رطوبت، دما، pH، EC، NPK
- `SoilLocation`: مختصات جغرافیایی
- `SoilDepthData`: داده‌های خاک در سه عمق
تابع `build_user_soil_text()` این داده‌ها را به متن فارسی تبدیل می‌کند.
ذخیره با: `sensor_uuid = {uuid واقعی}`, `kb_name = __all__`
### 4. داده‌های هواشناسی کاربر — از DB
- `WeatherForecast`: پیش‌بینی ۷ روز آینده (دما، بارش، رطوبت، باد، ET0)
تابع `build_user_weather_text()` این داده‌ها را به متن فارسی تبدیل می‌کند.
ذخیره با: `sensor_uuid = {uuid واقعی}`, `kb_name = __all__`
---
## پایپلاین Embedding
```
منابع → load_sources() → chunk_text() → embed_texts() → Qdrant
```
1. **بارگذاری منابع** (`ingest.py:load_sources`):
- لحن‌ها از `config/tones/`
- KB‌ها از `config/knowledge_base/`
- داده‌های کاربران از DB (`user_data.py`)
2. **چانک کردن** (`chunker.py`):
- حداکثر ۵۰۰ توکن هر چانک
- ۵۰ توکن همپوشانی
3. **Embedding** (`embedding.py`):
- استفاده از `api_provider.get_embedding_client()`
- مدل: `text-embedding-3-small`
- بچ‌سایز: ۳۲
4. **ذخیره در Qdrant** (`vector_store.py`):
- هر point: `{id, vector[1536], payload{text, source, sensor_uuid, kb_name, chunk_index}}`
---
## نحوه اجرا
### دستی
```bash
python manage.py rag_ingest --recreate
```
### دوره‌ای (Celery Beat)
تسک `rag_ingest_task` هر ۶ ساعت اجرا می‌شود و داده‌های جدید را embed می‌کند.
---
## فلوی پیام کاربر
```
POST /api/rag/chat/ {message, sensor_uuid}
1. تشخیص KB از روی کلمات کلیدی (_detect_kb_intent)
2. بارگذاری داده‌های فعلی کاربر از DB:
- build_user_soil_text(sensor_uuid)
- build_user_weather_text(sensor_uuid)
3. Embed کردن سوال (embed_single)
4. جستجو در Qdrant با فیلتر:
- sensor_uuid = {uuid کاربر} OR __global__
- kb_name = {detected_kb} OR __all__
5. ساخت context:
[داده‌های فعلی خاک] + [پیش‌بینی هواشناسی] + [متن‌های مرجع از RAG]
6. ارسال به LLM (GapGPT):
system_prompt = tone + دستورالعمل + context
7. StreamingHttpResponse → کاربر
```
---
## API Endpoint
### POST `/api/rag/chat/`
**Request:**
```json
{
"message": "وضعیت خاک من چطوره؟",
"sensor_uuid": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Response:** Stream متنی (text/plain)
---
## تنظیمات
### `config/rag_config.yaml`
```yaml
embedding:
provider: "gapgpt" # gapgpt یا avalai
model: "text-embedding-3-small"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
qdrant:
host: "localhost"
port: 6333
collection_name: "croplogic_kb"
vector_size: 1536
chunking:
max_chunk_tokens: 500
overlap_tokens: 50
llm:
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
knowledge_bases:
chat:
path: "config/knowledge_base/chat"
tone_file: "config/tones/chat_tone.txt"
irrigation:
path: "config/knowledge_base/irrigation"
tone_file: "config/tones/irrigation_tone.txt"
fertilization:
path: "config/knowledge_base/fertilization"
tone_file: "config/tones/fertilization_tone.txt"
```
### متغیرهای محیطی
| متغیر | توضیح |
|-------|-------|
| `GAPGPT_API_KEY` | کلید API برای GapGPT |
| `AVALAI_API_KEY` | کلید API برای Avalai (fallback) |
| `QDRANT_HOST` | آدرس Qdrant |
| `QDRANT_PORT` | پورت Qdrant |
---
## ایزوله‌سازی کاربران
- هر چانک یک فیلد `sensor_uuid` در metadata دارد
- داده‌های عمومی: `sensor_uuid = __global__`
- داده‌های کاربر: `sensor_uuid = {uuid واقعی}`
- هنگام جستجو، فیلتر `should` اعمال می‌شود:
- `sensor_uuid = {uuid کاربر}` OR `__global__`
- `kb_name = {detected_kb}` OR `__all__`
- نتیجه: هر کاربر فقط داده‌های خودش + دانش عمومی را می‌بیند
---
## سرویس‌های توصیه
سرویس‌های آبیاری و کودهی **بدون API** هستند و از RAG استفاده می‌کنند.
### توصیه آبیاری
```python
from rag.services import get_irrigation_recommendation
result = get_irrigation_recommendation(
sensor_uuid="550e8400-...",
query="توصیه آبیاری برای مزرعه من چیست؟" # اختیاری
)
```
**خروجی:**
```python
{
"irrigation_needed": True,
"amount_mm": 25.0,
"reason": "رطوبت خاک پایین و بارش پیش‌بینی نشده",
"next_check_date": "2026-03-20",
"raw_response": "..."
}
```
### توصیه کودهی
```python
from rag.services import get_fertilization_recommendation
result = get_fertilization_recommendation(
sensor_uuid="550e8400-...",
query="توصیه کودهی برای مزرعه من چیست؟" # اختیاری
)
```
**خروجی:**
```python
{
"fertilizer_needed": True,
"fertilizer_type": "NPK 20-10-10",
"amount_kg_per_hectare": 150.0,
"reason": "سطح ازت پایین",
"npk_status": {
"nitrogen": "low",
"phosphorus": "normal",
"potassium": "normal"
},
"raw_response": "..."
}
```
---
## نمودار معماری
```
┌─────────────────────────────────────────────────────────┐
│ منابع داده │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌───────────────────┐ │
│ │ tones/ │ │ knowledge_ │ │ Django DB │ │
│ │ 3 files │ │ base/ │ │ SensorData │ │
│ │ │ │ chat/irrig/ │ │ SoilLocation │ │
│ │ │ │ fertiliz/ │ │ SoilDepthData │ │
│ │ │ │ │ │ WeatherForecast │ │
│ └────┬─────┘ └──────┬───────┘ └────────┬──────────┘ │
│ │ │ │ │
│ └───────────┬────┘ │ │
│ __global__ sensor_uuid │
│ kb_name=chat/ kb_name=__all__ │
│ irrigation/ │
│ fertilization │
└───────────────┬────────────────────────┬────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ ingest pipeline │
│ │
│ load_sources() → chunk_text() → embed_texts() │
│ (با Adapter Pattern: GapGPT/Avalai) │
│ │
│ کامند: python manage.py rag_ingest --recreate │
│ تسک: rag_ingest_task.delay(recreate=True) │
└────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Qdrant │
│ collection: croplogic_kb │
│ │
│ هر point = {id, vector[1536], payload{text, │
│ source, sensor_uuid, kb_name, │
│ chunk_index}} │
└────────────────────────┬────────────────────────────────┘
(هنگام سوال کاربر)
┌─────────────────────────────────────────────────────────┐
│ فلوی پاسخ به کاربر │
│ │
│ 1. POST /api/rag/chat/ {message, sensor_uuid} │
│ 2. تشخیص KB از کلمات کلیدی (_detect_kb_intent) │
│ 3. build_user_soil_text() + build_user_weather_text() │
│ 4. embed_single(message) → query vector │
│ 5. Qdrant search با فیلتر sensor_uuid + kb_name │
│ 6. system_prompt = tone + دستورالعمل + context │
│ 7. GapGPT LLM (gpt-4o) → streaming response │
│ 8. StreamingHttpResponse → کاربر │
└─────────────────────────────────────────────────────────┘
```
---
**تغییرات اخیر:**
- ✅ Adapter Pattern برای سوئیچ بین GapGPT و Avalai
- ✅ سه پایگاه دانش مجزا (chat/irrigation/fertilization)
- ✅ داده‌های هواشناسی embed می‌شوند
- ✅ فیلتر `kb_name` در جستجوی Qdrant
- ✅ سرویس‌های توصیه آبیاری و کودهی (بدون API)
+5
View File
@@ -0,0 +1,5 @@
"""
ماژول RAG — برای جلوگیری از AppRegistryNotReady این فایل import سنگین انجام نمی‌دهد.
"""
__all__: list[str] = []
+92
View File
@@ -0,0 +1,92 @@
"""
Adapter Pattern برای API providers — سوئیچ بین GapGPT، Avalai و ArvanCloud AI.
"""
import logging
import os
try:
from openai import OpenAI
except ImportError: # pragma: no cover - optional for stripped test envs
class OpenAI: # type: ignore[override]
def __init__(self, *args, **kwargs):
raise ImportError("openai package is required for RAG clients.")
from .config import RAGConfig, load_rag_config
logger = logging.getLogger(__name__)
def _mask_secret(value: str | None) -> str:
if not value:
return "<missing>"
if len(value) <= 8:
return "****"
return f"{value[:4]}...{value[-4:]}"
def _get_env_or_value(env_var: str | None, direct_value: str | None) -> str | None:
if env_var:
return os.environ.get(env_var) or direct_value
return direct_value
def get_embedding_client(config: RAGConfig | None = None) -> OpenAI:
"""
ساخت کلاینت OpenAI برای Embedding بر اساس provider فعال.
provider از config.embedding.provider خوانده می‌شود.
"""
cfg = config or load_rag_config()
emb = cfg.embedding
provider = emb.provider or "gapgpt"
logger.info("embedding provider=%s", provider)
if provider == "avalai":
env_var = emb.avalai_api_key_env or emb.api_key_env or "AVALAI_API_KEY"
api_key = _get_env_or_value(env_var, emb.avalai_api_key or emb.api_key)
base_url = emb.avalai_base_url or emb.base_url or "https://api.avalai.ir/v1"
elif provider == "arvancloud":
env_var = emb.arvancloud_api_key_env or "ARVANCLOUD_EMBEDDING_API_KEY"
api_key = _get_env_or_value(env_var, emb.arvancloud_api_key)
base_url = (
emb.arvancloud_base_url
or "https://arvancloudai.ir/gateway/models/Bge-m3/rBA2PgcTC2sfhXwamupI4NvQ8crddUGTYXOsuKVye91PoNuGhbRgpHHNY8sMHBVQWWerZSAi4a0AijUL6YBqY9EW-Y1LhW_0ec6Mxr85GQy41lXiV6M8Od4mvLIeDF-wLRUHIervod0O5ZqGj2MOX8z1zdUpXkCrIS2uDjHlfHBZofledZjsOVDmFZU7IYfvkA__ljQqNeKXSFgpwUR7SmsbRUXGTDB2moLdeRq9zBpQIw/v1"
)
else:
env_var = emb.api_key_env or "GAPGPT_API_KEY"
api_key = _get_env_or_value(env_var, emb.api_key)
base_url = emb.base_url or "https://api.gapgpt.app/v1"
logger.info(
"embedding base_url=%s api_key=%s",
base_url,
_mask_secret(api_key),
)
return OpenAI(api_key=api_key, base_url=base_url)
def get_chat_client(config: RAGConfig | None = None) -> OpenAI:
"""
ساخت کلاینت OpenAI برای Chat/LLM بر اساس provider فعال.
provider از config.llm.provider خوانده می‌شود.
"""
cfg = config or load_rag_config()
llm = cfg.llm
provider = llm.provider or cfg.embedding.provider
logger.info("chat provider=%s", provider)
if provider == "avalai":
env_var = llm.avalai_api_key_env or llm.api_key_env or "AVALAI_API_KEY"
api_key = os.environ.get(env_var)
base_url = llm.avalai_base_url or llm.base_url or "https://api.avalai.ir/v1"
else:
env_var = llm.api_key_env or "GAPGPT_API_KEY"
api_key = os.environ.get(env_var)
base_url = llm.base_url or "https://api.gapgpt.app/v1"
logger.info(
"chat base_url=%s api_key=%s",
base_url,
_mask_secret(api_key),
)
return OpenAI(api_key=api_key, base_url=base_url)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class RagConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "rag"
verbose_name = "RAG - پایگاه دانش"
+434
View File
@@ -0,0 +1,434 @@
"""
چت RAG برای API چت عمومی — با ارسال کامل داده مزرعه و retrieval تکمیلی از KB.
"""
import base64
import json
import logging
import mimetypes
from pathlib import Path
from typing import Any
from .api_provider import get_chat_client
from .chunker import chunk_text
from .config import RAGConfig, ServiceConfig, get_service_config, load_rag_config
from .retrieve import search_with_texts
logger = logging.getLogger(__name__)
def _coerce_text_content(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, list):
parts: list[str] = []
for item in value:
if isinstance(item, dict) and item.get("type") == "text":
text_value = item.get("text")
if isinstance(text_value, str) and text_value.strip():
parts.append(text_value.strip())
elif isinstance(item, str) and item.strip():
parts.append(item.strip())
return "\n".join(parts)
return str(value)
def _normalize_image_inputs(images: list[Any] | None) -> list[dict[str, str]]:
normalized: list[dict[str, str]] = []
for item in images or []:
if isinstance(item, str):
value = item.strip()
if value:
normalized.append({"url": value})
continue
if not isinstance(item, dict):
continue
url = item.get("url") or item.get("image_url") or item.get("data_url")
if not isinstance(url, str) or not url.strip():
continue
entry = {"url": url.strip()}
detail = item.get("detail")
if isinstance(detail, str) and detail.strip():
entry["detail"] = detail.strip()
normalized.append(entry)
return normalized
def _build_content_parts(text: str, images: list[dict[str, str]] | None = None) -> str | list[dict[str, Any]]:
normalized_text = (text or "").strip()
normalized_images = _normalize_image_inputs(images)
if not normalized_images:
return normalized_text
parts: list[dict[str, Any]] = []
if normalized_text:
parts.append({"type": "text", "text": normalized_text})
for image in normalized_images:
image_payload: dict[str, Any] = {"url": image["url"]}
if image.get("detail"):
image_payload["detail"] = image["detail"]
parts.append({"type": "image_url", "image_url": image_payload})
return parts
def _normalize_history_messages(history: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
for item in history or []:
if not isinstance(item, dict):
continue
role = str(item.get("role") or "").strip().lower()
if role not in {"user", "assistant"}:
continue
text = _coerce_text_content(
item.get("content", item.get("message", item.get("text")))
).strip()
images = _normalize_image_inputs(item.get("images") or item.get("image_urls"))
if not text and not images:
continue
content = _build_content_parts(text, images if role == "user" else None)
normalized.append({"role": role, "content": content})
return normalized
def encode_uploaded_image(uploaded_file: Any) -> dict[str, str]:
content_type = getattr(uploaded_file, "content_type", None) or mimetypes.guess_type(
getattr(uploaded_file, "name", "")
)[0] or "application/octet-stream"
raw = uploaded_file.read()
if not isinstance(raw, (bytes, bytearray)):
raise ValueError("Uploaded image payload is invalid.")
encoded = base64.b64encode(raw).decode("ascii")
return {
"url": f"data:{content_type};base64,{encoded}",
"detail": "auto",
}
def _load_tone(config: RAGConfig | None) -> str:
"""بارگذاری فایل لحن پیش‌فرض (chat KB)."""
cfg = config or load_rag_config()
base = Path(__file__).resolve().parent.parent
chat_kb = cfg.knowledge_bases.get("chat")
if chat_kb:
tone_path = base / chat_kb.tone_file
if tone_path.exists():
return tone_path.read_text(encoding="utf-8").strip()
logger.warning("Default tone file not found: %s", tone_path)
return ""
def _load_service_tone(service: ServiceConfig, config: RAGConfig | None = None) -> str:
cfg = config or load_rag_config()
if service.tone_file:
base = Path(__file__).resolve().parent.parent
tone_path = base / service.tone_file
if tone_path.exists():
return tone_path.read_text(encoding="utf-8").strip()
logger.warning("Service tone file not found: %s", tone_path)
return _load_tone(cfg)
def _format_farm_context(farm_uuid: str) -> str:
from farm_data.services import get_farm_details
farm_details = get_farm_details(farm_uuid)
if not farm_details:
raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.")
serialized = json.dumps(
farm_details,
ensure_ascii=False,
indent=2,
default=str,
)
return "[اطلاعات کامل مزرعه]\n" + serialized
def _format_farm_context_from_details(farm_details: dict) -> str:
serialized = json.dumps(
farm_details,
ensure_ascii=False,
indent=2,
default=str,
)
return "[اطلاعات کامل مزرعه]\n" + serialized
def _load_farm_details_context(
sensor_uuid: str | None,
farm_details: dict | None = None,
) -> str:
if not sensor_uuid:
return ""
if farm_details is not None:
return _format_farm_context_from_details(farm_details)
return _format_farm_context(sensor_uuid)
def _build_system_prompt(
service: ServiceConfig,
query: str,
farm_context: str,
config: RAGConfig | None = None,
) -> str:
tone = _load_service_tone(service, config)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(
"با استفاده از اطلاعات کامل مزرعه و اطلاعات بازیابی‌شده از پایگاه دانش که در ادامه آمده "
"به سوال کاربر پاسخ بده. "
"اگر داده‌ای در اطلاعات مزرعه وجود دارد، همان را مبنای پاسخ قرار بده و چیزی حدس نزن. "
"نتایج بازیابی‌شده از پایگاه دانش را برای تکمیل یا توضیح پاسخ استفاده کن. "
"اگر داده کافی نبود، این کمبود را شفاف بگو. "
"پاسخ را به زبان کاربر بنویس."
)
system_parts.append(farm_context)
system_parts.append(f"[سوال کاربر]\n{query}")
return "\n\n".join(part for part in system_parts if part)
def _create_audit_log(
farm_uuid: str,
service_id: str,
model: str,
query: str,
system_prompt: str,
messages: list[dict],
) -> "ChatAuditLog":
from .models import ChatAuditLog
log = ChatAuditLog.objects.create(
farm_uuid=farm_uuid,
service_id=service_id,
model=model,
user_query=query,
system_prompt=system_prompt,
messages=messages,
status=ChatAuditLog.STATUS_STARTED,
)
logger.info(
"Created chat audit log id=%s service_id=%s farm_uuid=%s model=%s",
log.id,
service_id,
farm_uuid,
model,
)
return log
def _complete_audit_log(audit_log: "ChatAuditLog", response_text: str) -> None:
from .models import ChatAuditLog
audit_log.response_text = response_text
audit_log.status = ChatAuditLog.STATUS_COMPLETED
audit_log.save(update_fields=["response_text", "status", "updated_at"])
def _fail_audit_log(
audit_log: "ChatAuditLog",
error_message: str,
response_text: str = "",
) -> None:
from .models import ChatAuditLog
audit_log.response_text = response_text
audit_log.error_message = error_message
audit_log.status = ChatAuditLog.STATUS_FAILED
audit_log.save(
update_fields=["response_text", "error_message", "status", "updated_at"]
)
def build_rag_context(
query: str,
sensor_uuid: str | None = None,
config: RAGConfig | None = None,
limit: int = 8,
kb_name: str | None = None,
service_id: str | None = None,
farm_details: dict | None = None,
) -> str:
"""
ساخت context مشترک برای همه سرویس‌های RAG.
شامل:
- اطلاعات کامل مزرعه از farm_data/services.py
- جستجوی KB بر اساس پیام کاربر
- جستجوی KB بر اساس chunk های کامل داده مزرعه
"""
logger.info(
"Building RAG context sensor_uuid=%s kb_name=%s limit=%s query_len=%s",
sensor_uuid,
kb_name,
limit,
len(query or ""),
)
parts: list[str] = []
cfg = config or load_rag_config()
service = get_service_config(service_id, cfg) if service_id else None
include_user_embeddings = service.use_user_embeddings if service else True
resolved_kb_name = kb_name or (service.knowledge_base if service else None)
farm_context = _load_farm_details_context(
sensor_uuid=sensor_uuid,
farm_details=farm_details,
)
if farm_context:
parts.append(farm_context)
search_texts = [query]
if farm_context:
search_texts.extend(chunk_text(farm_context, config=cfg))
results = search_with_texts(
search_texts,
sensor_uuid=sensor_uuid,
limit=limit,
per_text_limit=3,
config=cfg,
kb_name=resolved_kb_name,
service_id=service_id,
use_user_embeddings=include_user_embeddings,
)
if results:
rag_texts = [r.get("text", "").strip() for r in results if r.get("text")]
if rag_texts:
parts.append("[متن‌های مرجع]\n" + "\n\n---\n\n".join(rag_texts))
return "\n\n---\n\n".join(parts) if parts else ""
def chat_rag_stream(
query: str,
farm_uuid: str,
config: RAGConfig | None = None,
system_override: str | None = None,
farm_details: dict | None = None,
history: list[dict[str, Any]] | None = None,
images: list[dict[str, str]] | None = None,
):
"""
چت استریمی با سرویس ثابت `chat` و context مستقیم مزرعه.
Args:
query: پیام کاربر
farm_uuid: شناسه مزرعه
config: تنظیمات RAG
system_override: جایگزین system prompt (اختیاری)
history: لیست پیام های قبلی کاربر/هوش مصنوعی
images: تصاویر مربوط به پیام فعلی کاربر
Yields:
chunk های استریم پاسخ مدل
"""
cfg = config or load_rag_config()
service_id = "chat"
service = get_service_config(service_id, cfg)
service_llm_config = service.llm
service_cfg = RAGConfig(
embedding=cfg.embedding,
qdrant=cfg.qdrant,
chunking=cfg.chunking,
llm=service_llm_config,
knowledge_bases=cfg.knowledge_bases,
services=cfg.services,
chromadb=cfg.chromadb,
)
client = get_chat_client(service_cfg)
model = service_llm_config.model
logger.info(
"chat_rag_stream started service_id=%s farm_uuid=%s query_len=%s",
service_id,
farm_uuid,
len(query or ""),
)
context = build_rag_context(
query=query,
sensor_uuid=farm_uuid,
config=cfg,
service_id=service_id,
farm_details=farm_details,
)
logger.info(
"Loaded augmented context for farm_uuid=%s context_len=%s",
farm_uuid,
len(context),
)
if system_override is not None:
system_prompt = system_override
else:
system_prompt = _build_system_prompt(
service,
query,
context,
config=cfg,
)
messages = [{"role": "system", "content": system_prompt}]
messages.extend(_normalize_history_messages(history))
messages.append({"role": "user", "content": _build_content_parts(query, images)})
logger.info(
"Final prompt prepared service_id=%s farm_uuid=%s model=%s messages_count=%s",
service_id,
farm_uuid,
model,
len(messages),
)
logger.info("Final system prompt for farm_uuid=%s:\n%s", farm_uuid, system_prompt)
audit_log = _create_audit_log(
farm_uuid=farm_uuid,
service_id=service_id,
model=model,
query=query,
system_prompt=system_prompt,
messages=messages,
)
response_chunks: list[str] = []
try:
stream = client.chat.completions.create(
model=model,
messages=messages,
stream=True,
)
logger.info(
"Started streaming response id=%s service_id=%s farm_uuid=%s",
audit_log.id,
service_id,
farm_uuid,
)
for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
content = delta.content if delta else ""
if content:
response_chunks.append(content)
yield content
full_response = "".join(response_chunks)
_complete_audit_log(audit_log, full_response)
logger.info(
"Completed chat response id=%s farm_uuid=%s response_len=%s response=\n%s",
audit_log.id,
farm_uuid,
len(full_response),
full_response,
)
except Exception as exc:
partial_response = "".join(response_chunks)
_fail_audit_log(audit_log, str(exc), partial_response)
logger.exception(
"Chat request failed id=%s service_id=%s farm_uuid=%s partial_response_len=%s",
audit_log.id,
service_id,
farm_uuid,
len(partial_response),
)
raise
+65
View File
@@ -0,0 +1,65 @@
"""
تکه‌تکه کردن متن (Chunking) برای RAG
"""
from .config import load_rag_config, RAGConfig
# تقریب: هر توکن حدود ۳–۴ نویسه برای فارسی/انگلیسی
CHARS_PER_TOKEN = 3.5
def chunk_text(
text: str,
config: RAGConfig | None = None,
max_chunk_tokens: int | None = None,
overlap_tokens: int | None = None,
) -> list[str]:
"""
تکه‌تکه کردن متن بر اساس توکن (تقریبی با نویسه).
Args:
text: متن ورودی
config: تنظیمات RAG
max_chunk_tokens: حداکثر توکن هر چانک (override)
overlap_tokens: تعداد توکن همپوشانی بین چانک‌ها (override)
Returns:
لیست چانک‌ها
"""
cfg = config or load_rag_config()
max_tok = max_chunk_tokens if max_chunk_tokens is not None else cfg.chunking.max_chunk_tokens
overlap = overlap_tokens if overlap_tokens is not None else cfg.chunking.overlap_tokens
max_chars = int(max_tok * CHARS_PER_TOKEN)
overlap_chars = int(overlap * CHARS_PER_TOKEN)
step = max_chars - overlap_chars
if step <= 0:
step = max_chars
text = text.strip()
if not text:
return []
chunks: list[str] = []
start = 0
while start < len(text):
end = start + max_chars
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
start += step
return chunks
def chunk_texts(
texts: list[str],
config: RAGConfig | None = None,
**kwargs,
) -> list[str]:
"""چند متن را تکه‌تکه می‌کند و همه چانک‌ها را برمی‌گرداند."""
all_chunks: list[str] = []
for t in texts:
all_chunks.extend(chunk_text(t, config=config, **kwargs))
return all_chunks
+19
View File
@@ -0,0 +1,19 @@
"""
کلاینت Qdrant — اتصال به دیتابیس وکتور
"""
from qdrant_client import QdrantClient
from qdrant_client.http import models as qmodels
from .config import QdrantConfig, load_rag_config
def get_qdrant_client(config: QdrantConfig | None = None) -> QdrantClient:
"""
ایجاد کلاینت Qdrant.
اگر config داده نشود، از rag_config.yaml بارگذاری می‌شود.
"""
if config is None:
rag = load_rag_config()
config = rag.qdrant
return QdrantClient(host=config.host, port=config.port)
+194
View File
@@ -0,0 +1,194 @@
"""
بارگذاری تنظیمات RAG از rag_config.yaml — با پشتیبانی از چند provider،
چند پایگاه دانش و چند سرویس.
"""
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
@dataclass
class EmbeddingConfig:
provider: str
model: str
batch_size: int = 32
api_key: str | None = None
api_key_env: str | None = None
base_url: str | None = None
avalai_api_key: str | None = None
avalai_base_url: str | None = None
avalai_api_key_env: str | None = None
arvancloud_api_key: str | None = None
arvancloud_base_url: str | None = None
arvancloud_api_key_env: str | None = None
@dataclass
class QdrantConfig:
host: str = "localhost"
port: int = 6333
collection_name: str = "croplogic_kb"
vector_size: int = 384
@dataclass
class ChunkingConfig:
max_chunk_tokens: int = 500
overlap_tokens: int = 50
@dataclass
class LLMConfig:
provider: str = "gapgpt"
model: str = "gpt-4o"
base_url: str | None = None
api_key_env: str | None = None
avalai_base_url: str | None = None
avalai_api_key_env: str | None = None
@dataclass
class KnowledgeBaseConfig:
path: str
tone_file: str
description: str = ""
@dataclass
class ServiceConfig:
service_id: str
knowledge_base: str
llm: LLMConfig = field(default_factory=LLMConfig)
tone_file: str | None = None
system_prompt: str | None = None
use_user_embeddings: bool = True
description: str = ""
@dataclass
class RAGConfig:
embedding: EmbeddingConfig
qdrant: QdrantConfig
chunking: ChunkingConfig
llm: LLMConfig = field(default_factory=LLMConfig)
knowledge_bases: dict[str, KnowledgeBaseConfig] = field(default_factory=dict)
services: dict[str, ServiceConfig] = field(default_factory=dict)
chromadb: dict[str, Any] = field(default_factory=dict)
def _build_llm_config(data: dict[str, Any] | None, default: LLMConfig | None = None) -> LLMConfig:
llm_data = data or {}
fallback = default or LLMConfig()
return LLMConfig(
provider=llm_data.get("provider", fallback.provider),
model=llm_data.get("model", fallback.model),
base_url=llm_data.get("base_url", fallback.base_url),
api_key_env=llm_data.get("api_key_env", fallback.api_key_env),
avalai_base_url=llm_data.get("avalai_base_url", fallback.avalai_base_url),
avalai_api_key_env=llm_data.get("avalai_api_key_env", fallback.avalai_api_key_env),
)
def get_service_config(service_id: str, config: RAGConfig | None = None) -> ServiceConfig:
cfg = config or load_rag_config()
service = cfg.services.get(service_id)
if service is None:
raise KeyError(f"Unknown service_id: {service_id}")
return service
def load_rag_config(config_path: str | Path | None = None) -> RAGConfig:
"""
بارگذاری تنظیمات از YAML و env.
QDRANT_HOST و QDRANT_PORT از متغیرهای محیطی override می‌شوند.
"""
if config_path is None:
base = Path(__file__).resolve().parent.parent
config_path = base / "config" / "rag_config.yaml"
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(f"RAG config not found: {path}")
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
emb = data.get("embedding", {})
embedding = EmbeddingConfig(
provider=emb.get("provider", "sentence_transformers"),
model=emb.get("model", "text-embedding-3-small"),
batch_size=emb.get("batch_size", 32),
api_key=emb.get("api_key"),
api_key_env=emb.get("api_key_env"),
base_url=emb.get("base_url"),
avalai_api_key=emb.get("avalai_api_key"),
avalai_base_url=emb.get("avalai_base_url"),
avalai_api_key_env=emb.get("avalai_api_key_env"),
arvancloud_api_key=emb.get("arvancloud_api_key"),
arvancloud_base_url=emb.get("arvancloud_base_url"),
arvancloud_api_key_env=emb.get("arvancloud_api_key_env"),
)
qd = data.get("qdrant", {})
qdrant = QdrantConfig(
host=os.environ.get("QDRANT_HOST", qd.get("host", "localhost")),
port=int(os.environ.get("QDRANT_PORT", qd.get("port", 6333))),
collection_name=qd.get("collection_name", "croplogic_kb"),
vector_size=qd.get("vector_size", 1536),
)
ch = data.get("chunking", {})
chunking = ChunkingConfig(
max_chunk_tokens=ch.get("max_chunk_tokens", 500),
overlap_tokens=ch.get("overlap_tokens", 50),
)
llm = _build_llm_config(data.get("llm", {}))
kb_data = data.get("knowledge_bases", {})
knowledge_bases: dict[str, KnowledgeBaseConfig] = {}
for kb_name, kb_conf in kb_data.items():
knowledge_bases[kb_name] = KnowledgeBaseConfig(
path=kb_conf.get("path", f"config/knowledge_base/{kb_name}"),
tone_file=kb_conf.get("tone_file", f"config/tones/{kb_name}_tone.txt"),
description=kb_conf.get("description", ""),
)
services_data = data.get("services", {})
services: dict[str, ServiceConfig] = {}
for service_id, service_conf in services_data.items():
kb_name = service_conf.get("knowledge_base", service_id)
kb_conf = knowledge_bases.get(kb_name)
services[service_id] = ServiceConfig(
service_id=service_id,
knowledge_base=kb_name,
llm=_build_llm_config(service_conf.get("llm"), default=llm),
tone_file=service_conf.get("tone_file") or (kb_conf.tone_file if kb_conf else None),
system_prompt=service_conf.get("system_prompt"),
use_user_embeddings=service_conf.get("use_user_embeddings", True),
description=service_conf.get("description", ""),
)
if not services:
for kb_name, kb_conf in knowledge_bases.items():
services[kb_name] = ServiceConfig(
service_id=kb_name,
knowledge_base=kb_name,
llm=llm,
tone_file=kb_conf.tone_file,
use_user_embeddings=True,
description=kb_conf.description,
)
return RAGConfig(
embedding=embedding,
qdrant=qdrant,
chunking=chunking,
llm=llm,
knowledge_bases=knowledge_bases,
services=services,
chromadb=data.get("chromadb", {}),
)
+91
View File
@@ -0,0 +1,91 @@
"""
سرویس تعبیه‌سازی متن — از Adapter Pattern برای سوئیچ بین providers استفاده می‌کند
"""
import logging
import time
from .api_provider import get_embedding_client
from .config import RAGConfig, load_rag_config
from .observability import classify_exception, log_event, observe_operation, record_metric
logger = logging.getLogger(__name__)
def embed_texts(
texts: list[str],
config: RAGConfig | None = None,
model: str | None = None,
dimensions: int | None = None,
) -> list[list[float]]:
"""
تعبیه‌سازی لیست متن‌ها با Avalai.
Args:
texts: لیست رشته‌های ورودی
config: تنظیمات RAG (پیش‌فرض: load_rag_config)
model: نام مدل (override از config)
dimensions: تعداد ابعاد (فقط برای مدل‌های پشتیبانی‌کننده)
Returns:
لیست وکتورها
"""
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
provider = cfg.embedding.provider or "unknown"
batch_size = cfg.embedding.batch_size
all_embeddings: list[list[float]] = []
extra = {}
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
def embed_single(text: str, config: RAGConfig | None = None, **kwargs) -> list[float]:
"""تعبیه‌سازی یک متن. خروجی مستقیماً یک وکتور است."""
vecs = embed_texts([text], config=config, **kwargs)
return vecs[0] if vecs else []
+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()
+187
View File
@@ -0,0 +1,187 @@
"""
پایپ‌لاین ورودی RAG: خواندن، چانک، embed و ذخیره در vector store — با پشتیبانی از چند پایگاه دانش
منابع:
۱. لحن هر پایگاه دانش (tone) — sensor_uuid=__global__, kb_name=chat|irrigation|fertilization
۲. پایگاه‌های دانش سه‌گانه — sensor_uuid=__global__, kb_name=chat|irrigation|fertilization
۳. دیتای خاک + هواشناسی هر کاربر از DB — sensor_uuid=uuid, kb_name=__all__
"""
import uuid
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
TEXT_EXTENSIONS = {".txt", ".md", ".rst", ".json"}
SENSOR_UUID_GLOBAL = "__global__"
KB_NAME_ALL = "__all__"
def _resolve_path(base: Path, p: str) -> Path:
"""تبدیل مسیر نسبی به مطلق نسبت به base پروژه."""
path = Path(p)
if not path.is_absolute():
path = base / path
return path
def _load_file(path: Path) -> str | None:
"""خواندن یک فایل متنی."""
if not path.exists() or not path.is_file():
return None
try:
return path.read_text(encoding="utf-8").strip()
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
def _load_files_from_dir(dir_path: Path, prefix: str = "kb") -> list[tuple[str, str]]:
"""
خواندن همه فایل‌های متنی از یک دایرکتوری.
Returns: [(source_id, content), ...]
"""
if not dir_path.exists() or not dir_path.is_dir():
return []
out: list[tuple[str, str]] = []
for f in sorted(dir_path.rglob("*")):
if f.is_file() and f.suffix.lower() in TEXT_EXTENSIONS:
rel = f.relative_to(dir_path)
source_id = f"{prefix}:{rel}"
content = _load_file(f)
if content:
out.append((source_id, content))
return out
def load_sources(
config: RAGConfig | None = None,
kb_name: str | None = None,
) -> list[tuple[str, str, str, str]]:
"""
بارگذاری منابع: لحن‌ها، پایگاه‌های دانش سه‌گانه، دیتای کاربران.
اگر kb_name مشخص شود، فقط آن پایگاه دانش لود می‌شود.
Returns:
[(source_id, content, sensor_uuid, kb_name), ...]
"""
cfg = config or load_rag_config()
base = Path(__file__).resolve().parent.parent
sources: list[tuple[str, str, str, str]] = []
kbs_to_load = cfg.knowledge_bases.items()
if kb_name:
kbs_to_load = [(k, v) for k, v in kbs_to_load if k == kb_name]
for kbn, kb_cfg in kbs_to_load:
tone_path = _resolve_path(base, kb_cfg.tone_file)
content = _load_file(tone_path)
if content:
sources.append((f"tone:{kbn}", content, SENSOR_UUID_GLOBAL, kbn))
kb_path = _resolve_path(base, kb_cfg.path)
for sid, c in _load_files_from_dir(kb_path, prefix=f"kb:{kbn}"):
sources.append((sid, c, SENSOR_UUID_GLOBAL, kbn))
if kb_path.is_file():
content = _load_file(kb_path)
if content:
sources.append((f"kb:{kbn}:{kb_path.name}", content, SENSOR_UUID_GLOBAL, kbn))
for sid, content in load_user_sources():
if sid.startswith("user:"):
sensor_uuid = sid.replace("user:", "")
elif sid.startswith("weather:"):
sensor_uuid = sid.replace("weather:", "")
else:
sensor_uuid = sid
sources.append((sid, content, sensor_uuid, KB_NAME_ALL))
return sources
def ingest(
recreate: bool = False,
config: RAGConfig | None = None,
kb_name: str | None = None,
) -> dict:
"""
ورودی کامل: منابع را می‌خواند، چانک، embed و به vector store می‌فرستد.
kb_name اختیاری: اگر مشخص شود فقط آن پایگاه دانش ingest می‌شود.
Args:
recreate: اگر True باشد، collection را از نو می‌سازد
config: تنظیمات RAG
kb_name: نام پایگاه دانش (chat/irrigation/fertilization) — اختیاری
Returns:
آمار ورودی (تعداد چانک، منبع‌ها، خطاها)
"""
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] = []
all_metas: list[dict] = []
all_ids: list[str] = []
for source_id, content, sensor_uuid, src_kb in sources:
chunks = chunk_text(content, config=cfg)
for i, ch in enumerate(chunks):
uid = str(uuid.uuid4())
all_ids.append(uid)
all_chunks.append(ch)
all_metas.append({
"source": source_id,
"chunk_index": i,
"sensor_uuid": sensor_uuid,
"kb_name": src_kb,
})
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],
"error": f"تعداد embed با چانک‌ها مطابقت ندارد: {len(embeddings)} vs {len(all_chunks)}",
}
store.add_documents(
ids=all_ids,
embeddings=embeddings,
documents=all_chunks,
metadatas=all_metas,
)
record_metric("rag.ingest.success", kb_name=kb_name, chunks=len(all_chunks))
return {
"chunks_added": len(all_chunks),
"sources": [s[0] for s in sources],
}
@@ -0,0 +1,30 @@
"""
ورودی RAG: لحن، پایگاه دانش و اطلاعات کاربر را embed و به Qdrant می‌فرستد.
اجرا: python manage.py rag_ingest [--recreate]
"""
from django.core.management.base import BaseCommand
from rag.ingest import ingest
class Command(BaseCommand):
help = "Embed لحن، پایگاه دانش و اطلاعات کاربر و ذخیره در Qdrant"
def add_arguments(self, parser):
parser.add_argument(
"--recreate",
action="store_true",
help="collection را از نو بساز (حذف و ایجاد مجدد)",
)
def handle(self, *args, **options):
recreate = options.get("recreate", False)
result = ingest(recreate=recreate)
if "error" in result:
self.stderr.write(self.style.ERROR(result["error"]))
return
self.stdout.write(
self.style.SUCCESS(
f"{result['chunks_added']} چانک از منابع {result['sources']} ذخیره شد."
)
)
+33
View File
@@ -0,0 +1,33 @@
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="ChatAuditLog",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("farm_uuid", models.UUIDField(blank=True, help_text="شناسه مزرعه مرتبط با درخواست چت", null=True)),
("service_id", models.CharField(default="chat", help_text="شناسه سرویس RAG استفاده شده برای این درخواست", max_length=64)),
("model", models.CharField(blank=True, help_text="مدل LLM استفاده شده برای پاسخ", max_length=128)),
("user_query", models.TextField(help_text="متن پرسش کاربر")),
("system_prompt", models.TextField(blank=True, help_text="system prompt نهایی ارسال شده به مدل")),
("messages", models.JSONField(blank=True, default=list, help_text="لیست کامل پیام‌های ارسال شده به مدل")),
("response_text", models.TextField(blank=True, help_text="متن کامل پاسخ دریافتی از مدل")),
("error_message", models.TextField(blank=True, help_text="خطای رخ داده هنگام فراخوانی مدل یا استریم")),
("status", models.CharField(choices=[("started", "شروع شده"), ("completed", "تکمیل شده"), ("failed", "ناموفق")], default="started", max_length=16)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"db_table": "rag_chatauditlog",
"ordering": ["-created_at"],
"verbose_name": "لاگ چت RAG",
"verbose_name_plural": "لاگ\u200cهای چت RAG",
},
),
]
+1
View File
@@ -0,0 +1 @@
+62
View File
@@ -0,0 +1,62 @@
from django.db import models
class ChatAuditLog(models.Model):
STATUS_STARTED = "started"
STATUS_COMPLETED = "completed"
STATUS_FAILED = "failed"
STATUS_CHOICES = [
(STATUS_STARTED, "شروع شده"),
(STATUS_COMPLETED, "تکمیل شده"),
(STATUS_FAILED, "ناموفق"),
]
farm_uuid = models.UUIDField(
null=True,
blank=True,
help_text="شناسه مزرعه مرتبط با درخواست چت",
)
service_id = models.CharField(
max_length=64,
default="chat",
help_text="شناسه سرویس RAG استفاده شده برای این درخواست",
)
model = models.CharField(
max_length=128,
blank=True,
help_text="مدل LLM استفاده شده برای پاسخ",
)
user_query = models.TextField(help_text="متن پرسش کاربر")
system_prompt = models.TextField(
blank=True,
help_text="system prompt نهایی ارسال شده به مدل",
)
messages = models.JSONField(
default=list,
blank=True,
help_text="لیست کامل پیام‌های ارسال شده به مدل",
)
response_text = models.TextField(
blank=True,
help_text="متن کامل پاسخ دریافتی از مدل",
)
error_message = models.TextField(
blank=True,
help_text="خطای رخ داده هنگام فراخوانی مدل یا استریم",
)
status = models.CharField(
max_length=16,
choices=STATUS_CHOICES,
default=STATUS_STARTED,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "rag_chatauditlog"
ordering = ["-created_at"]
verbose_name = "لاگ چت RAG"
verbose_name_plural = "لاگ‌های چت RAG"
def __str__(self):
return f"{self.service_id} - {self.farm_uuid or 'no-farm'} - {self.status}"
+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
+134
View File
@@ -0,0 +1,134 @@
"""
بازیابی RAG: embed کوئری و جستجو در vector store
"""
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
def _resolve_search_options(
sensor_uuid: str | None = None,
config: RAGConfig | None = None,
kb_name: str | None = None,
service_id: str | None = None,
use_user_embeddings: bool | None = None,
) -> tuple[RAGConfig, list[str], list[str]]:
cfg = config or load_rag_config()
service = get_service_config(service_id, cfg) if service_id else None
resolved_kb_name = kb_name or (service.knowledge_base if service else None)
include_user_embeddings = (
use_user_embeddings
if use_user_embeddings is not None
else (service.use_user_embeddings if service else True)
)
sensor_filters = ["__global__"]
if include_user_embeddings and sensor_uuid:
sensor_filters.insert(0, sensor_uuid)
kb_filters = [resolved_kb_name] if resolved_kb_name else []
if include_user_embeddings:
kb_filters.append("__all__")
return cfg, sensor_filters, kb_filters
def search_with_query(
query: str,
sensor_uuid: str | None = None,
limit: int = 5,
score_threshold: float | None = None,
config: RAGConfig | None = None,
kb_name: str | None = None,
service_id: str | None = None,
use_user_embeddings: bool | None = None,
) -> list[dict]:
"""
کوئری را embed می‌کند و در vector store جستجو می‌کند.
فقط chunks مربوط به sensor_uuid یا __global__ برمی‌گردد (ایزوله‌سازی کاربر).
kb_name: اختیاری — فیلتر بر اساس پایگاه دانش.
Args:
sensor_uuid: شناسه سنسور کاربر — اجباری برای امنیت
kb_name: نام پایگاه دانش (chat/irrigation/fertilization)
Returns:
لیست نتایج با id, score, text, metadata
"""
cfg, sensor_filters, kb_filters = _resolve_search_options(
sensor_uuid=sensor_uuid,
config=config,
kb_name=kb_name,
service_id=service_id,
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)
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(
texts: list[str],
sensor_uuid: str | None = None,
limit: int = 8,
per_text_limit: int = 3,
score_threshold: float | None = None,
config: RAGConfig | None = None,
kb_name: str | None = None,
service_id: str | None = None,
use_user_embeddings: bool | None = None,
) -> list[dict]:
"""
چند متن را embed می‌کند و نتیجه جستجوها را به صورت dedupe شده برمی‌گرداند.
برای حالتی مناسب است که هم پیام کاربر و هم داده‌های مزرعه را علیه KB جستجو کنیم.
"""
normalized_texts = [text.strip() for text in texts if text and text.strip()]
if not normalized_texts:
return []
cfg, sensor_filters, kb_filters = _resolve_search_options(
sensor_uuid=sensor_uuid,
config=config,
kb_name=kb_name,
service_id=service_id,
use_user_embeddings=use_user_embeddings,
)
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] = {}
for vector in vectors:
results = store.search(
query_vector=vector,
limit=per_text_limit,
score_threshold=score_threshold,
sensor_uuids=sensor_filters,
kb_names=kb_filters,
)
for item in results:
current = merged_results.get(item["id"])
if current is None or item["score"] > current["score"]:
merged_results[item["id"]] = item
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
+24
View File
@@ -0,0 +1,24 @@
"""
سرویس‌های RAG — آبیاری و کودهی
بدون API — قابل استفاده از سایر سرویس‌ها
"""
from .irrigation import get_irrigation_recommendation
from .irrigation_plan_parser import IrrigationPlanParserService
from .fertilization import get_fertilization_recommendation
from .fertilization_plan_parser import FertilizationPlanParserService
from .pest_disease import get_pest_disease_detection, get_pest_disease_risk
from .soil_anomaly import get_soil_anomaly_insight
from .water_need_prediction import get_water_need_prediction_insight
from .yield_harvest import YieldHarvestRAGService
__all__ = [
"get_irrigation_recommendation",
"IrrigationPlanParserService",
"get_fertilization_recommendation",
"FertilizationPlanParserService",
"get_pest_disease_detection",
"get_pest_disease_risk",
"get_soil_anomaly_insight",
"get_water_need_prediction_insight",
"YieldHarvestRAGService",
]
+738
View File
@@ -0,0 +1,738 @@
"""
سرویس توصیه کودهی — بدون API، قابل فراخوانی از سایر سرویس‌ها.
از RAG با پایگاه دانش fertilization و خروجی optimizer برای ساخت پاسخ ساختاریافته استفاده می‌کند.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from django.apps import apps
from farm_data.models import SensorData
from farm_data.services import clone_snapshot_as_runtime_plant, get_farm_plant_snapshot_by_name
from rag.api_provider import get_chat_client
from rag.chat import (
_complete_audit_log,
_create_audit_log,
_fail_audit_log,
_load_service_tone,
build_rag_context,
)
from rag.config import RAGConfig, get_service_config, load_rag_config
from rag.user_data import build_plant_text
logger = logging.getLogger(__name__)
KB_NAME = "fertilization"
SERVICE_ID = "fertilization"
HECTARE_TO_SQUARE_METER = 10000.0
DEFAULT_FERTILIZATION_PROMPT = (
"از RAG و خروجی بهینه ساز شبیه سازی برای ساخت پاسخ ساختاریافته کودهی استفاده کن. "
"اگر بلوک [خروجی بهینه ساز شبیه سازی] وجود داشت، همان مرجع قطعی اعداد، فرمول، روش مصرف و زمان بندی است. "
"پاسخ فقط JSON معتبر بر اساس قرارداد status/data برگردان."
)
DEFAULT_MACRO_DESCRIPTIONS = {
"n": "نیتروژن برای حفظ رشد رویشی، رنگ سبز برگ و بازسازی سریع بوته مهم است.",
"p": "فسفر به توسعه ریشه، انتقال انرژی و پشتیبانی از گلدهی و استقرار کمک می کند.",
"k": "پتاسیم به تنظیم آب، کیفیت محصول و مقاومت گیاه در برابر تنش محیطی کمک می کند.",
}
DEFAULT_MICRO_NAMES = {
"fe": "آهن",
"zn": "روی",
"mn": "منگنز",
"b": "بر",
"cu": "مس",
"mg": "منیزیم",
"ca": "کلسیم",
"mo": "مولیبدن",
}
DEFAULT_MICRO_DESCRIPTIONS = {
"fe": "آهن در ساخت کلروفیل و کاهش زردی بین رگبرگی نقش دارد.",
"zn": "روی در رشد متعادل، تشکیل هورمون ها و فعالیت آنزیمی موثر است.",
"mn": "منگنز در فتوسنتز و فعالیت آنزیم های متابولیکی نقش پشتیبان دارد.",
"b": "بر در گرده افشانی، تشکیل گل و انتقال قندها اهمیت دارد.",
"cu": "مس به فعالیت آنزیمی و استحکام نسبی بافت های گیاه کمک می کند.",
"mg": "منیزیم بخش مرکزی کلروفیل است و در فتوسنتز اهمیت دارد.",
"ca": "کلسیم در استحکام دیواره سلولی و کیفیت رشد بافت های جوان موثر است.",
"mo": "مولیبدن در متابولیسم نیتروژن و کارایی جذب آن نقش دارد.",
}
DEFAULT_STAGE_LABELS = {
"initial": "استقرار",
"vegetative": "رشد رویشی",
"flowering": "گلدهی",
"fruiting": "میوه دهی",
}
def _get_optimizer():
return apps.get_app_config("crop_simulation").get_recommendation_optimizer()
def _safe_float(value: Any, default: float | None = None) -> float | None:
try:
if value is None or value == "":
return default
return float(value)
except (TypeError, ValueError):
return default
def _stage_key(growth_stage: str | None) -> str:
text = (growth_stage or "").strip().lower()
if any(token in text for token in ("flower", "گل", "anthesis")):
return "flowering"
if any(token in text for token in ("fruit", "میوه", "برداشت", "ripen", "harvest")):
return "fruiting"
if any(token in text for token in ("initial", "seed", "جوانه", "نشا", "استقرار")):
return "initial"
return "vegetative"
def _clean_json_response(raw: str) -> dict[str, Any]:
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`").removeprefix("json").strip()
try:
parsed = json.loads(cleaned)
return parsed if isinstance(parsed, dict) else {}
except (json.JSONDecodeError, ValueError):
return {}
def _normalize_label(value: float) -> str:
if float(value).is_integer():
return str(int(value))
return f"{value:.2f}".rstrip("0").rstrip(".")
def _parse_npk_ratio(formula: str | None) -> dict[str, float | str]:
if not formula:
return {"n": 0.0, "p": 0.0, "k": 0.0, "label": "0-0-0"}
parts = re.findall(r"\d+(?:\.\d+)?", formula)
if len(parts) < 3:
return {"n": 0.0, "p": 0.0, "k": 0.0, "label": formula}
n, p, k = (_safe_float(part, 0.0) or 0.0 for part in parts[:3])
return {
"n": round(n, 3),
"p": round(p, 3),
"k": round(k, 3),
"label": f"{_normalize_label(n)}-{_normalize_label(p)}-{_normalize_label(k)}",
}
def _method_id(label: str) -> str:
text = (label or "").strip()
if "محلول" in text and ("آبیاری" in text or "کودآبیاری" in text):
return "foliar_fertigation"
if "محلول" in text:
return "foliar_spray"
if "آبیاری" in text or "کودآبیاری" in text:
return "fertigation"
if "سرک" in text or "خاک" in text or "نواری" in text:
return "soil_application"
return "custom_application"
def _slug_value(value: str) -> str:
token = re.sub(r"[^a-zA-Z0-9]+", "-", (value or "").strip().lower()).strip("-")
return token or "fertilizer"
def _fertilizer_display_name(formula: str | None) -> str:
ratio = _parse_npk_ratio(formula)
label = ratio["label"] if ratio["label"] else (formula or "کود پیشنهادی")
if label and label != "0-0-0":
return f"کود کامل {label}"
return formula or "کود پیشنهادی"
def _fertilizer_type_label(formula: str | None) -> str:
ratio = _parse_npk_ratio(formula)
if ratio["label"] and ratio["label"] != "0-0-0":
return "NPK"
return formula or "Fertilizer"
def _first_text(*values: Any) -> str:
for value in values:
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _default_application_steps(application_method: str) -> list[dict[str, Any]]:
if "محلول" in application_method:
return [
{
"step_number": 1,
"title": "آماده سازی",
"description": "دوز توصیه شده را در مقدار کمی آب تمیز حل کنید تا محلول یکنواخت به دست آید.",
},
{
"step_number": 2,
"title": "اختلاط",
"description": "محلول را به مخزن اصلی اضافه کنید و همزمان هم بزنید تا ته نشینی رخ ندهد.",
},
{
"step_number": 3,
"title": "مصرف",
"description": "در ساعات خنک روز به صورت یکنواخت محلول پاشی کنید و پس از اجرا بوته را پایش کنید.",
},
]
return [
{
"step_number": 1,
"title": "آماده سازی",
"description": "مقدار توصیه شده را بر اساس مساحت مزرعه اندازه گیری و پیش از اجرا یکنواخت تقسیم کنید.",
},
{
"step_number": 2,
"title": "تزریق یا پخش",
"description": "کود را از طریق کودآبیاری یا مصرف خاکی سبک مطابق روش پیشنهادی وارد مزرعه کنید.",
},
{
"step_number": 3,
"title": "پایش",
"description": "پس از اجرا رطوبت خاک، وضعیت برگ و پاسخ بوته را تا نوبت بعدی بررسی کنید.",
},
]
def _warning_from_weather(forecasts: list[Any], application_method: str) -> str:
if not forecasts:
return "هنگام مصرف از دستکش و ماسک استفاده کنید و قبل از اختلاط آزمون سازگاری در مقیاس کوچک انجام دهید."
rainy = next(
(
item
for item in forecasts
if (_safe_float(getattr(item, "precipitation", None), 0.0) or 0.0) >= 3.0
),
None,
)
hot = next(
(
item
for item in forecasts
if (_safe_float(getattr(item, "temperature_max", None), 0.0) or 0.0) >= 32.0
),
None,
)
if rainy is not None and "محلول" in application_method:
return (
f"به دلیل احتمال بارش موثر در {rainy.forecast_date} محلول پاشی را به پنجره خشک منتقل کنید و "
"در زمان اجرا از ماسک و دستکش استفاده شود."
)
if hot is not None:
return (
"به دلیل گرمای پیش رو، مصرف را فقط در صبح زود یا نزدیک غروب انجام دهید و از اختلاط غلیظ خودداری کنید."
)
return "هنگام مصرف از دستکش و ماسک استفاده کنید و پیش از اختلاط با سایر نهاده ها آزمون سازگاری انجام دهید."
def _fallback_optimizer_result(growth_stage: str | None) -> dict[str, Any]:
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
stage_key = _stage_key(growth_stage)
target = defaults["stage_targets"].get(stage_key, defaults["stage_targets"]["vegetative"])
base_amount = round(max(40.0, (target["n"] * 1.25)), 2)
return {
"engine": "defaults",
"recommended_strategy": {
"code": stage_key,
"label": DEFAULT_STAGE_LABELS.get(stage_key, stage_key),
"score": 0.0,
"expected_yield_index": 0.0,
"fertilizer_type": target["formula"],
"amount_kg_per_ha": base_amount,
"application_method": target["application_method"],
"timing": target["timing"],
"validity_period": f"معتبر برای {defaults['validity_days']} روز آینده",
"reasoning": [
"پیشنهاد از تنظیمات پایه مرحله رشد ساخته شد زیرا خروجی کامل optimizer در دسترس نبود.",
f"فرمول هدف مرحله {DEFAULT_STAGE_LABELS.get(stage_key, stage_key)} برابر با {target['formula']} در نظر گرفته شد.",
],
},
"alternatives": [],
"context_text": "fallback fertilization context",
}
def _build_legacy_sections(
structured_data: dict[str, Any],
recommended_strategy: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
primary = structured_data.get("primary_recommendation", {})
guide = structured_data.get("application_guide", {})
recommended_strategy = recommended_strategy or {}
return [
{
"type": "recommendation",
"title": primary.get("display_title") or "برنامه کودهی",
"icon": "leaf",
"content": primary.get("summary", ""),
"fertilizerType": primary.get("npk_ratio", {}).get("label") or primary.get("fertilizer_type", ""),
"amount": primary.get("dosage", {}).get("label", ""),
"applicationMethod": primary.get("application_method", {}).get("label", ""),
"timing": recommended_strategy.get("timing", ""),
"validityPeriod": recommended_strategy.get("validity_period", ""),
"expandableExplanation": primary.get("reasoning", ""),
},
{
"type": "list",
"title": "مراحل مصرف",
"icon": "list",
"items": [step.get("title", "") for step in guide.get("steps", []) if step.get("title")],
},
{
"type": "warning",
"title": "هشدار کودهی",
"icon": "alert-triangle",
"content": guide.get("safety_warning", ""),
},
]
def _coerce_steps(value: Any, application_method: str) -> list[dict[str, Any]]:
if not isinstance(value, list):
return _default_application_steps(application_method)
steps = []
for index, item in enumerate(value, start=1):
if isinstance(item, dict):
title = _first_text(item.get("title"), f"مرحله {index}")
description = _first_text(item.get("description"), item.get("content"))
if not description:
continue
steps.append(
{
"step_number": int(item.get("step_number") or index),
"title": title,
"description": description,
}
)
elif isinstance(item, str) and item.strip():
steps.append(
{
"step_number": index,
"title": f"مرحله {index}",
"description": item.strip(),
}
)
return steps or _default_application_steps(application_method)
def _normalize_micro_items(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
items = []
for item in value:
if not isinstance(item, dict):
continue
key = _first_text(item.get("key")).lower()
if not key:
continue
nutrient_value = _safe_float(item.get("value"))
if nutrient_value is None:
continue
items.append(
{
"key": key,
"name": _first_text(item.get("name"), DEFAULT_MICRO_NAMES.get(key, key.upper())),
"value": round(nutrient_value, 3),
"unit": "percent",
"description": _first_text(item.get("description"), DEFAULT_MICRO_DESCRIPTIONS.get(key, "")),
}
)
return items
def _build_nutrient_analysis(llm_analysis: dict[str, Any] | None, npk_ratio: dict[str, Any]) -> dict[str, Any]:
llm_analysis = llm_analysis if isinstance(llm_analysis, dict) else {}
macro_by_key: dict[str, dict[str, Any]] = {}
for item in llm_analysis.get("macro", []):
if not isinstance(item, dict):
continue
key = _first_text(item.get("key")).lower()
if key:
macro_by_key[key] = item
macro = []
for key, name in (("n", "نیتروژن (N)"), ("p", "فسفر (P)"), ("k", "پتاسیم (K)")):
source = macro_by_key.get(key, {})
macro.append(
{
"key": key,
"name": name,
"value": round(_safe_float(npk_ratio.get(key), 0.0) or 0.0, 3),
"unit": "percent",
"description": _first_text(source.get("description"), DEFAULT_MACRO_DESCRIPTIONS[key]),
}
)
return {"macro": macro, "micro": _normalize_micro_items(llm_analysis.get("micro"))}
def _build_application_guide(
llm_guide: dict[str, Any] | None,
*,
application_method: str,
warning_text: str,
) -> dict[str, Any]:
llm_guide = llm_guide if isinstance(llm_guide, dict) else {}
return {
"safety_warning": _first_text(llm_guide.get("safety_warning"), warning_text),
"steps": _coerce_steps(llm_guide.get("steps"), application_method),
}
def _build_alternative_recommendations(
llm_alternatives: Any,
optimizer_alternatives: list[dict[str, Any]],
recommended_strategy: dict[str, Any],
) -> list[dict[str, Any]]:
llm_items = llm_alternatives if isinstance(llm_alternatives, list) else []
alternatives = []
for index, optimizer_item in enumerate(optimizer_alternatives[:3]):
llm_item = llm_items[index] if index < len(llm_items) and isinstance(llm_items[index], dict) else {}
formula = _first_text(
llm_item.get("fertilizer_code"),
optimizer_item.get("fertilizer_type"),
recommended_strategy.get("fertilizer_type"),
)
display_name = _first_text(llm_item.get("fertilizer_name"), _fertilizer_display_name(formula), optimizer_item.get("label"))
description = _first_text(
llm_item.get("description"),
*(optimizer_item.get("reasoning") or []),
f"این گزینه با امتیاز {optimizer_item.get('score', 0)} برای شرایط مشابه قابل استفاده است.",
)
alternatives.append(
{
"fertilizer_code": _slug_value(formula or optimizer_item.get("code", f"alt-{index + 1}")),
"fertilizer_name": display_name,
"fertilizer_type": _first_text(llm_item.get("fertilizer_type"), _fertilizer_type_label(formula)),
"usage_method": _first_text(
llm_item.get("usage_method"),
optimizer_item.get("application_method"),
recommended_strategy.get("application_method"),
),
"description": description,
}
)
for llm_item in llm_items[len(alternatives):3]:
if not isinstance(llm_item, dict):
continue
fertilizer_name = _first_text(llm_item.get("fertilizer_name"))
fertilizer_code = _first_text(llm_item.get("fertilizer_code"), fertilizer_name)
if not fertilizer_name or not fertilizer_code:
continue
alternatives.append(
{
"fertilizer_code": _slug_value(fertilizer_code),
"fertilizer_name": fertilizer_name,
"fertilizer_type": _first_text(llm_item.get("fertilizer_type"), "Fertilizer"),
"usage_method": _first_text(llm_item.get("usage_method"), recommended_strategy.get("application_method", "")),
"description": _first_text(llm_item.get("description"), "گزینه جایگزین در صورت محدودیت تامین یا تغییر شرایط مزرعه."),
}
)
return alternatives
def _normalize_llm_payload(parsed_result: dict[str, Any]) -> dict[str, Any]:
if not isinstance(parsed_result, dict):
return {"status": "success", "data": {}}
if isinstance(parsed_result.get("data"), dict):
status = parsed_result.get("status") or "success"
return {"status": status, "data": parsed_result["data"]}
if any(key in parsed_result for key in ("primary_recommendation", "nutrient_analysis", "application_guide")):
status = parsed_result.get("status") or "success"
return {"status": status, "data": parsed_result}
sections = parsed_result.get("sections")
if isinstance(sections, list):
recommendation = next((item for item in sections if isinstance(item, dict) and item.get("type") == "recommendation"), {})
list_section = next((item for item in sections if isinstance(item, dict) and item.get("type") == "list"), {})
warning = next((item for item in sections if isinstance(item, dict) and item.get("type") == "warning"), {})
return {
"status": "success",
"data": {
"primary_recommendation": {
"display_title": _first_text(recommendation.get("title"), recommendation.get("fertilizerType")),
"reasoning": _first_text(recommendation.get("expandableExplanation"), recommendation.get("content")),
"summary": _first_text(recommendation.get("content"), recommendation.get("title")),
},
"application_guide": {
"safety_warning": _first_text(warning.get("content")),
"steps": list_section.get("items", []),
},
"alternative_recommendations": [],
},
}
return {"status": "success", "data": {}}
def _build_final_response(
*,
llm_payload: dict[str, Any],
optimized_result: dict[str, Any] | None,
plant_name: str | None,
crop_id: str | None,
growth_stage: str | None,
forecasts: list[Any],
) -> dict[str, Any]:
normalized_llm = _normalize_llm_payload(llm_payload)
advisory = normalized_llm.get("data", {}) if isinstance(normalized_llm.get("data"), dict) else {}
optimizer_payload = optimized_result or _fallback_optimizer_result(growth_stage)
recommended = optimizer_payload.get("recommended_strategy", {})
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
stage_key = _stage_key(growth_stage)
stage_target = defaults["stage_targets"].get(stage_key, defaults["stage_targets"]["vegetative"])
formula = _first_text(recommended.get("fertilizer_type"), stage_target.get("formula"))
npk_ratio = _parse_npk_ratio(formula)
application_method_label = _first_text(recommended.get("application_method"), stage_target.get("application_method"))
amount_kg_per_ha = round(_safe_float(recommended.get("amount_kg_per_ha"), 0.0) or 0.0, 3)
amount_per_square_meter = round(amount_kg_per_ha / HECTARE_TO_SQUARE_METER, 6)
interval_days = int(
stage_target.get(
"application_interval_days",
defaults.get("default_application_interval_days", 14),
)
)
primary_advisory = advisory.get("primary_recommendation") if isinstance(advisory.get("primary_recommendation"), dict) else {}
reasoning = _first_text(primary_advisory.get("reasoning"), " ".join(recommended.get("reasoning", [])))
if not reasoning:
reasoning = "این توصیه با اتکا به مرحله رشد، وضعیت خاک و خروجی بهینه ساز شبیه سازی تنظیم شده است."
summary = _first_text(primary_advisory.get("summary"))
if not summary:
summary = f"{_fertilizer_display_name(formula)} برای مرحله {DEFAULT_STAGE_LABELS.get(stage_key, stage_key)} مناسب ارزیابی شده است."
warning_text = _warning_from_weather(forecasts, application_method_label)
nutrient_analysis = _build_nutrient_analysis(advisory.get("nutrient_analysis"), npk_ratio)
application_guide = _build_application_guide(
advisory.get("application_guide"),
application_method=application_method_label,
warning_text=warning_text,
)
alternatives = _build_alternative_recommendations(
advisory.get("alternative_recommendations"),
optimizer_payload.get("alternatives", []),
recommended,
)
structured_data = {
"primary_recommendation": {
"fertilizer_code": _slug_value(formula),
"fertilizer_name": _first_text(primary_advisory.get("fertilizer_name"), _fertilizer_display_name(formula)),
"display_title": _first_text(primary_advisory.get("display_title"), _fertilizer_display_name(formula)),
"fertilizer_type": _first_text(primary_advisory.get("fertilizer_type"), _fertilizer_type_label(formula)),
"npk_ratio": npk_ratio,
"application_method": {
"id": _method_id(application_method_label),
"label": application_method_label,
},
"application_interval": {
"value": interval_days,
"unit": "day",
"label": f"هر {interval_days} روز",
},
"dosage": {
"base_amount_per_hectare": amount_kg_per_ha,
"base_amount_per_square_meter": amount_per_square_meter,
"unit": "kg",
"label": f"{_normalize_label(amount_kg_per_ha)} کیلوگرم در هکتار",
"calculation_basis": optimizer_payload.get("engine", "product"),
},
"reasoning": reasoning,
"summary": summary,
},
"nutrient_analysis": nutrient_analysis,
"application_guide": application_guide,
"alternative_recommendations": alternatives,
}
structured_data["sections"] = _build_legacy_sections(structured_data, recommended)
return {"status": normalized_llm.get("status") or "success", "data": structured_data}
def _validate_fertilization_response(parsed_result: dict[str, Any]) -> dict[str, Any]:
if not isinstance(parsed_result, dict):
raise ValueError("Fertilization recommendation response is not a JSON object.")
data = parsed_result.get("data")
if not isinstance(data, dict):
raise ValueError("Fertilization recommendation response is missing data.")
if not isinstance(data.get("primary_recommendation"), dict):
raise ValueError("Fertilization recommendation response is missing primary_recommendation.")
return parsed_result
def get_fertilization_recommendation(
farm_uuid: str | None = None,
plant_name: str | None = None,
growth_stage: str | None = None,
crop_id: str | None = None,
query: str | None = None,
config: RAGConfig | None = None,
limit: int = 8,
sensor_uuid: str | None = None,
) -> dict[str, Any]:
"""
توصیه کودهی برای یک مزرعه.
از RAG با پایگاه دانش fertilization استفاده می کند و خروجی نهایی را با optimizer ترکیب می کند.
"""
cfg = config or load_rag_config()
service = get_service_config(SERVICE_ID, cfg)
service_cfg = RAGConfig(
embedding=cfg.embedding,
qdrant=cfg.qdrant,
chunking=cfg.chunking,
llm=service.llm,
knowledge_bases=cfg.knowledge_bases,
services=cfg.services,
chromadb=cfg.chromadb,
)
client = get_chat_client(service_cfg)
model = service.llm.model
resolved_farm_uuid = str(farm_uuid or sensor_uuid or "").strip()
if not resolved_farm_uuid:
raise ValueError("farm_uuid is required.")
user_query = query or "توصیه کودهی بهینه برای مزرعه من چیست؟"
sensor = (
SensorData.objects.select_related("center_location")
.prefetch_related("plant_assignments__plant")
.filter(farm_uuid=resolved_farm_uuid)
.first()
)
plant_config = apps.get_app_config("plant")
resolved_plant_name = plant_config.resolve_plant_name(plant_name)
if not resolved_plant_name and crop_id:
resolved_plant_name = plant_config.resolve_plant_name(crop_id)
resolved_growth_stage = plant_config.resolve_growth_stage(growth_stage)
plant = None
if sensor is not None:
selected_snapshot = get_farm_plant_snapshot_by_name(sensor, resolved_plant_name)
plant = clone_snapshot_as_runtime_plant(
selected_snapshot,
growth_stage=resolved_growth_stage,
)
if selected_snapshot is not None:
resolved_plant_name = selected_snapshot.name
forecasts = []
optimized_result = None
if sensor is not None and getattr(sensor, "center_location", None) is not None:
from weather.models import WeatherForecast
forecasts = list(
WeatherForecast.objects.filter(
location=sensor.center_location,
forecast_date__isnull=False,
).order_by("forecast_date")[:7]
)
if sensor is not None and plant is not None:
optimized_result = _get_optimizer().optimize_fertilization(
sensor=sensor,
plant=plant,
forecasts=forecasts,
growth_stage=resolved_growth_stage,
)
context = build_rag_context(
user_query,
resolved_farm_uuid,
config=cfg,
limit=limit,
kb_name=KB_NAME,
service_id=SERVICE_ID,
)
extra_parts: list[str] = []
if resolved_plant_name and resolved_growth_stage:
plant_text = build_plant_text(resolved_plant_name, resolved_growth_stage)
if plant_text:
extra_parts.append("[اطلاعات گیاه]\n" + plant_text)
if optimized_result is not None:
extra_parts.append("[خروجی بهینه ساز شبیه سازی]\n" + optimized_result["context_text"])
if extra_parts:
context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "")
tone = _load_service_tone(service, cfg)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(DEFAULT_FERTILIZATION_PROMPT)
if context:
system_parts.append("\n\n" + context)
system_content = "\n".join(system_parts)
messages = [
{"role": "system", "content": system_content},
{"role": "user", "content": user_query},
]
audit_log = _create_audit_log(
farm_uuid=resolved_farm_uuid,
service_id=SERVICE_ID,
model=model,
query=user_query,
system_prompt=system_content,
messages=messages,
)
try:
response = client.chat.completions.create(
model=model,
messages=messages,
)
raw = response.choices[0].message.content.strip()
except Exception as exc:
logger.error("Fertilization recommendation error for %s: %s", resolved_farm_uuid, exc)
_fail_audit_log(audit_log, str(exc))
raise RuntimeError(
f"Fertilization recommendation failed for farm {resolved_farm_uuid}."
) from exc
llm_payload = _clean_json_response(raw)
result = _build_final_response(
llm_payload=llm_payload,
optimized_result=optimized_result,
plant_name=resolved_plant_name,
crop_id=crop_id,
growth_stage=resolved_growth_stage,
forecasts=forecasts,
)
result = _validate_fertilization_response(result)
result["raw_response"] = raw
result["simulation_optimizer"] = optimized_result
result["sections"] = result["data"].get("sections", [])
_complete_audit_log(
audit_log,
json.dumps(result, ensure_ascii=False, default=str),
)
return result
@@ -0,0 +1,398 @@
from __future__ import annotations
import json
import logging
from typing import Any, Literal
from pydantic import BaseModel, Field, ValidationError
from rag.api_provider import get_chat_client
from rag.chat import (
_complete_audit_log,
_create_audit_log,
_fail_audit_log,
_load_service_tone,
build_rag_context,
)
from rag.config import RAGConfig, get_service_config, load_rag_config
logger = logging.getLogger(__name__)
SERVICE_ID = "fertilization_plan_parser"
KB_NAME = "fertilization_plan_parser"
CORE_FIELDS = [
"crop_name",
"growth_stage",
"fertilizer_name",
"formula",
"amount",
"application_method",
"timing",
"interval_days",
]
FERTILIZATION_PLAN_PROMPT = (
"شما یک تحلیل گر برنامه کودهی هستی. "
"کاربر ممکن است برنامه کودهی را کامل یا ناقص توضیح دهد. "
"فقط JSON معتبر برگردان و هرگز متن خارج از JSON، markdown یا کلید اضافه تولید نکن. "
"اگر اطلاعات کافی بود status را completed بگذار و final_plan را تکمیل کن. "
"اگر اطلاعات ناقص بود status را needs_clarification بگذار، missing_fields را پر کن و در questions سوال های کوتاه و دقیق برگردان. "
"اگر چند کود در متن بود، همه را در applications لیست کن. "
"اگر هرکدام از فیلدهای اصلی خالی، null یا نامشخص بود، حق نداری status را completed بگذاری. "
"در حالت completed هیچ فیلد null در collected_data و final_plan نباید وجود داشته باشد. "
"از حدس زدن مقدار، زمان یا روش مصرف خودداری کن. "
"Schema: "
"{"
'"status": "completed" | "needs_clarification", '
'"summary": string, '
'"missing_fields": [string], '
'"questions": [{"id": string, "field": string, "question": string, "rationale": string}], '
'"collected_data": {'
'"crop_name": string|null, '
'"growth_stage": string|null, '
'"objective": string|null, '
'"applications": ['
"{"
'"fertilizer_name": string|null, '
'"formula": string|null, '
'"amount": string|null, '
'"application_method": string|null, '
'"timing": string|null, '
'"interval_days": integer|null, '
'"purpose": string|null'
"}"
"], "
'"notes": [string]'
"}, "
'"final_plan": {same shape as collected_data} | null'
"}."
)
class ClarificationQuestionSchema(BaseModel):
id: str
field: str
question: str
rationale: str = ""
class FertilizerApplicationSchema(BaseModel):
fertilizer_name: str | None = None
formula: str | None = None
amount: str | None = None
application_method: str | None = None
timing: str | None = None
interval_days: int | None = None
purpose: str | None = None
class FertilizationPlanSchema(BaseModel):
crop_name: str | None = None
growth_stage: str | None = None
objective: str | None = None
applications: list[FertilizerApplicationSchema] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
class FertilizationPlanParseResultSchema(BaseModel):
status: Literal["completed", "needs_clarification"]
summary: str
missing_fields: list[str] = Field(default_factory=list)
questions: list[ClarificationQuestionSchema] = Field(default_factory=list)
collected_data: FertilizationPlanSchema = Field(default_factory=FertilizationPlanSchema)
final_plan: FertilizationPlanSchema | None = None
class FertilizationPlanParserService:
def parse_plan(
self,
*,
message: str = "",
answers: dict[str, Any] | None = None,
partial_plan: dict[str, Any] | None = None,
farm_uuid: str | None = None,
) -> dict[str, Any]:
cfg = load_rag_config()
service, client, model = self._build_service_client(cfg)
normalized_message = (message or "").strip()
normalized_answers = answers if isinstance(answers, dict) else {}
normalized_partial = partial_plan if isinstance(partial_plan, dict) else {}
structured_context = {
"message": normalized_message,
"answers": normalized_answers,
"partial_plan": normalized_partial,
"required_core_fields": CORE_FIELDS,
"service": "fertilization_plan_parser",
}
rag_query = self._build_retrieval_query(
message=normalized_message,
answers=normalized_answers,
)
rag_context = build_rag_context(
query=rag_query,
sensor_uuid=farm_uuid,
config=cfg,
kb_name=KB_NAME,
service_id=SERVICE_ID,
)
system_prompt, messages = self._build_messages(
service=service,
cfg=cfg,
structured_context=structured_context,
rag_context=rag_context,
)
audit_log = None
if farm_uuid:
try:
audit_log = _create_audit_log(
farm_uuid=farm_uuid,
service_id=SERVICE_ID,
model=model,
query=rag_query,
system_prompt=system_prompt,
messages=messages,
)
except Exception as exc:
logger.warning("Fertilization plan parser audit log creation failed for %s: %s", farm_uuid, exc)
try:
response = client.chat.completions.create(
model=model,
messages=messages,
response_format={"type": "json_object"},
)
raw = (response.choices[0].message.content or "").strip()
parsed = self._clean_json(raw)
validated = FertilizationPlanParseResultSchema.model_validate(parsed)
normalized = self._normalize_result(validated)
if audit_log is not None:
_complete_audit_log(audit_log, raw)
return normalized
except (ValidationError, ValueError, KeyError, IndexError) as exc:
logger.warning("Fertilization plan parser parsing failed: %s", exc)
if audit_log is not None:
_fail_audit_log(audit_log, str(exc))
return self._fallback_result(
message=normalized_message,
answers=normalized_answers,
partial_plan=normalized_partial,
)
except Exception as exc:
logger.error("Fertilization plan parser failed: %s", exc)
if audit_log is not None:
_fail_audit_log(audit_log, str(exc))
return self._fallback_result(
message=normalized_message,
answers=normalized_answers,
partial_plan=normalized_partial,
)
def _build_service_client(self, cfg: RAGConfig):
service = get_service_config(SERVICE_ID, cfg)
service_cfg = RAGConfig(
embedding=cfg.embedding,
qdrant=cfg.qdrant,
chunking=cfg.chunking,
llm=service.llm,
knowledge_bases=cfg.knowledge_bases,
services=cfg.services,
chromadb=cfg.chromadb,
)
client = get_chat_client(service_cfg)
return service, client, service.llm.model
def _build_messages(
self,
*,
service: Any,
cfg: RAGConfig,
structured_context: dict[str, Any],
rag_context: str,
) -> tuple[str, list[dict[str, str]]]:
tone = _load_service_tone(service, cfg)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(FERTILIZATION_PLAN_PROMPT)
system_parts.append(
"[structured_context]\n"
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
)
if rag_context:
system_parts.append(rag_context)
system_prompt = "\n\n".join(part for part in system_parts if part)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": "برنامه کودهی را استخراج یا برای تکمیل آن سوال بپرس."},
]
return system_prompt, messages
def _build_retrieval_query(
self,
*,
message: str,
answers: dict[str, Any],
) -> str:
answer_lines = [f"{key}: {value}" for key, value in answers.items()]
parts = [part for part in [message, "\n".join(answer_lines)] if part]
return "\n".join(parts) or "استخراج برنامه کودهی از متن کاربر"
def _normalize_result(self, validated: FertilizationPlanParseResultSchema) -> dict[str, Any]:
collected = validated.collected_data.model_dump()
final_plan = validated.final_plan.model_dump() if validated.final_plan is not None else None
missing_fields = list(dict.fromkeys(validated.missing_fields))
computed_missing = self._find_missing_fields(final_plan or collected)
for field in computed_missing:
if field not in missing_fields:
missing_fields.append(field)
can_complete = validated.status == "completed" and not missing_fields
if can_complete:
final_plan = final_plan or collected
questions: list[dict[str, Any]] = []
status_fa = "تکمیل شد"
else:
questions = [item.model_dump() for item in validated.questions]
if not questions and missing_fields:
questions = self._build_generic_questions(missing_fields)
final_plan = None
validated.status = "needs_clarification"
status_fa = "نیازمند پرسش تکمیلی"
return {
"status": "completed" if can_complete else "needs_clarification",
"status_fa": status_fa,
"summary": validated.summary,
"missing_fields": missing_fields,
"questions": questions,
"collected_data": collected,
"final_plan": final_plan,
}
def _fallback_result(
self,
*,
message: str,
answers: dict[str, Any],
partial_plan: dict[str, Any],
) -> dict[str, Any]:
applications = partial_plan.get("applications")
if not isinstance(applications, list):
applications = []
notes = list(partial_plan.get("notes") or [])
if message:
notes.append(f"متن اولیه کاربر: {message}")
if answers:
notes.append("پاسخ های تکمیلی کاربر دریافت شده است.")
return {
"status": "needs_clarification",
"status_fa": "نیازمند پرسش تکمیلی",
"summary": "اطلاعات برنامه کودهی برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.",
"missing_fields": CORE_FIELDS,
"questions": self._build_generic_questions(CORE_FIELDS),
"collected_data": {
"crop_name": partial_plan.get("crop_name"),
"growth_stage": partial_plan.get("growth_stage"),
"objective": partial_plan.get("objective"),
"applications": applications,
"notes": notes,
},
"final_plan": None,
}
def _build_generic_questions(self, missing_fields: list[str]) -> list[dict[str, str]]:
catalog = {
"crop_name": {
"id": "crop_name",
"field": "crop_name",
"question": "این برنامه کودهی برای کدام محصول است؟",
"rationale": "نام محصول برای ثبت برنامه لازم است.",
},
"growth_stage": {
"id": "growth_stage",
"field": "growth_stage",
"question": "محصول الان در چه مرحله رشدی قرار دارد؟",
"rationale": "مرحله رشد برای تکمیل برنامه لازم است.",
},
"fertilizer_name": {
"id": "fertilizer_name",
"field": "fertilizer_name",
"question": "نام کود یا ترکیب کودی چیست؟",
"rationale": "بدون نام کود نمی توان برنامه را نهایی کرد.",
},
"formula": {
"id": "formula",
"field": "formula",
"question": "فرمول یا آنالیز کود چیست؟ مثلا 20-20-20.",
"rationale": "ترکیب دقیق کود هنوز مشخص نشده است.",
},
"amount": {
"id": "amount",
"field": "amount",
"question": "مقدار مصرف هر نوبت کود چقدر است؟",
"rationale": "دوز مصرف در متن مشخص نشده است.",
},
"application_method": {
"id": "application_method",
"field": "application_method",
"question": "روش مصرف کود چیست؟ مثلا کودآبیاری، سرک یا محلول پاشی.",
"rationale": "روش اجرا هنوز معلوم نیست.",
},
"timing": {
"id": "timing",
"field": "timing",
"question": "زمان مصرف کود چه موقع است؟ مثلا هر 10 روز یا بعد از آبیاری.",
"rationale": "زمان بندی برنامه نیاز به شفاف سازی دارد.",
},
"interval_days": {
"id": "interval_days",
"field": "interval_days",
"question": "فاصله بین نوبت های مصرف کود چند روز است؟",
"rationale": "عدد فاصله بین نوبت ها برای JSON نهایی لازم است.",
},
}
return [catalog[field] for field in missing_fields if field in catalog][:5]
def _find_missing_fields(self, plan: dict[str, Any]) -> list[str]:
missing: list[str] = []
if not isinstance(plan, dict):
return CORE_FIELDS[:]
if plan.get("crop_name") in (None, ""):
missing.append("crop_name")
if plan.get("growth_stage") in (None, ""):
missing.append("growth_stage")
applications = plan.get("applications")
if not isinstance(applications, list) or not applications:
return missing + [
field
for field in ["fertilizer_name", "formula", "amount", "application_method", "timing", "interval_days"]
if field not in missing
]
first_application = applications[0] if isinstance(applications[0], dict) else {}
for field in ["fertilizer_name", "formula", "amount", "application_method", "timing", "interval_days"]:
value = first_application.get(field)
if value is None or (isinstance(value, str) and not value.strip()):
missing.append(field)
return missing
def _clean_json(self, raw: str) -> dict[str, Any]:
cleaned = (raw or "").strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
if not cleaned:
raise ValueError("Fertilization plan parser response was empty.")
parsed = json.loads(cleaned)
if not isinstance(parsed, dict):
raise ValueError("Fertilization plan parser response root must be an object.")
return parsed
+539
View File
@@ -0,0 +1,539 @@
"""
سرویس توصیه آبیاری — بدون API، قابل فراخوانی از سایر سرویس‌ها
از RAG با پایگاه دانش irrigation و لحن مخصوص آبیاری استفاده می‌کند.
"""
import json
import logging
from typing import Any
from django.apps import apps
from django.db import transaction
from farm_data.models import SensorData
from farm_data.services import (
clone_snapshot_as_runtime_plant,
get_farm_plant_snapshot_by_name,
)
from irrigation.evapotranspiration import (
calculate_forecast_water_needs,
resolve_crop_profile,
resolve_kc,
)
from irrigation.models import IrrigationMethod
from rag.api_provider import get_chat_client
from rag.chat import (
_complete_audit_log,
_create_audit_log,
_fail_audit_log,
_load_service_tone,
build_rag_context,
)
from rag.config import RAGConfig, get_service_config, load_rag_config
from rag.user_data import build_irrigation_method_text, build_plant_text
from weather.models import WeatherForecast
logger = logging.getLogger(__name__)
KB_NAME = "irrigation"
SERVICE_ID = "irrigation"
DEFAULT_IRRIGATION_PROMPT = (
"از محاسبات FAO-56 و خروجی بهینه ساز شبیه سازی برای ساخت توصیه آبیاری استفاده کن. "
"اگر بلوک [خروجی بهینه ساز شبیه سازی] وجود داشت، همان را مرجع اصلی اعداد قرار بده. "
"پاسخ را در قالب JSON معتبر با کلیدهای plan، timeline و sections برگردان و عدد جدید متناقض نساز."
)
def _get_optimizer():
return apps.get_app_config("crop_simulation").get_recommendation_optimizer()
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _sensor_metric(sensor: SensorData | None, metric: str) -> float | None:
if sensor is None or not isinstance(sensor.sensor_payload, dict):
return None
for payload in sensor.sensor_payload.values():
if isinstance(payload, dict) and payload.get(metric) is not None:
return _safe_float(payload.get(metric), default=0.0)
return None
def _coerce_list(value: Any) -> list[Any]:
return value if isinstance(value, list) else []
def _coerce_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _estimate_duration_minutes(amount_per_event_mm: float, efficiency_percent: float | None) -> int:
normalized_efficiency = max(_safe_float(efficiency_percent, 75.0), 30.0)
estimated_minutes = round(max(amount_per_event_mm, 1.0) * (2400 / normalized_efficiency))
return max(10, min(estimated_minutes, 240))
def _default_warning(
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
soil_moisture: float | None,
) -> str:
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
reasoning = _coerce_list(strategy.get("reasoning"))
if reasoning:
return str(reasoning[0])
if soil_moisture is not None and soil_moisture < 25:
return "رطوبت خاک پایین است و نباید آبیاری به تعویق بیفتد."
if soil_moisture is not None and soil_moisture > 80:
return "رطوبت خاک بالاست و باید از آبیاری اضافی خودداری شود."
if any(_safe_float(item.get("effective_rainfall_mm")) > 0 for item in daily_water_needs):
return "با توجه به بارش موثر پیش بینی شده، برنامه آبیاری را قبل از اجرا دوباره بررسی کنید."
return "در ساعات گرم روز آبیاری انجام نشود."
def _normalize_plan(
llm_result: dict[str, Any],
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
irrigation_method: IrrigationMethod | None,
soil_moisture: float | None,
) -> dict[str, Any]:
llm_plan = _coerce_dict(llm_result.get("plan"))
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
frequency = llm_plan.get("frequencyPerWeek")
if frequency is None:
frequency = strategy.get("frequency_per_week") or strategy.get("events") or len(daily_water_needs) or 1
duration = llm_plan.get("durationMinutes")
if duration is None:
duration = _estimate_duration_minutes(
_safe_float(strategy.get("amount_per_event_mm"), 6.0),
getattr(irrigation_method, "water_efficiency_percent", None),
)
best_time = llm_plan.get("bestTimeOfDay")
if not best_time:
best_time = strategy.get("timing") or (
daily_water_needs[0].get("irrigation_timing") if daily_water_needs else "05:30 تا 08:00 صبح"
)
moisture_level = llm_plan.get("moistureLevel")
if moisture_level is None:
moisture_level = round(
soil_moisture
if soil_moisture is not None
else _safe_float(strategy.get("moisture_target_percent"), 70.0)
)
warning = llm_plan.get("warning")
if not warning:
warning = _default_warning(optimizer_result, daily_water_needs, soil_moisture)
return {
"frequencyPerWeek": int(max(_safe_float(frequency, 1), 1)),
"durationMinutes": int(max(_safe_float(duration, 10), 10)),
"bestTimeOfDay": str(best_time),
"moistureLevel": int(max(min(_safe_float(moisture_level, 70), 100), 0)),
"warning": str(warning),
}
def _normalize_timeline(
llm_result: dict[str, Any],
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
) -> list[dict[str, Any]]:
raw_timeline = _coerce_list(llm_result.get("timeline"))
timeline: list[dict[str, Any]] = []
for index, item in enumerate(raw_timeline, start=1):
item_dict = _coerce_dict(item)
title = item_dict.get("title")
description = item_dict.get("description")
if title and description:
timeline.append(
{
"step_number": int(item_dict.get("step_number") or index),
"title": str(title),
"description": str(description),
}
)
if timeline:
return timeline
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
event_dates = _coerce_list(strategy.get("event_dates"))
best_timing = strategy.get("timing") or (
daily_water_needs[0].get("irrigation_timing") if daily_water_needs else "صبح زود"
)
generated = [
{
"step_number": 1,
"title": "بررسی فشار",
"description": "فشار ابتدا و انتهای لاین کنترل شود.",
},
{
"step_number": 2,
"title": "اجرای آبیاری",
"description": f"آبیاری در بازه {best_timing} انجام شود.",
},
]
if event_dates:
generated.append(
{
"step_number": 3,
"title": "پیگیری برنامه",
"description": f"نوبت های پیشنهادی برای تاریخ های {', '.join(map(str, event_dates))} بررسی شوند.",
}
)
else:
generated.append(
{
"step_number": 3,
"title": "بازبینی رطوبت",
"description": "بعد از هر نوبت، رطوبت خاک و یکنواختی توزیع آب کنترل شود.",
}
)
return generated
def _normalize_sections(
llm_result: dict[str, Any],
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
plan_warning: str,
) -> list[dict[str, Any]]:
raw_sections = _coerce_list(llm_result.get("sections"))
sections: list[dict[str, Any]] = []
for section in raw_sections:
item = _coerce_dict(section)
section_type = str(item.get("type") or "").strip().lower()
if section_type not in {"warning", "tip"}:
continue
content = item.get("content")
title = item.get("title")
if not content or not title:
continue
icon = item.get("icon") or (
"tabler-alert-triangle" if section_type == "warning" else "tabler-bulb"
)
sections.append(
{
"title": str(title),
"icon": str(icon),
"type": section_type,
"content": str(content),
}
)
if not any(item["type"] == "warning" for item in sections):
sections.insert(
0,
{
"title": "هشدار آبیاری",
"icon": "tabler-alert-triangle",
"type": "warning",
"content": plan_warning,
},
)
if not any(item["type"] == "tip" for item in sections):
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
reasoning = _coerce_list(strategy.get("reasoning"))
tip_content = (
str(reasoning[-1])
if reasoning
else "شست وشوی فیلترها و بازبینی یکنواختی پخش آب به پایداری برنامه آبیاری کمک می کند."
)
if any(_safe_float(item.get("effective_rainfall_mm")) > 0 for item in daily_water_needs):
tip_content = "قبل از نوبت بعدی، مقدار بارش موثر و رطوبت خاک را دوباره با برنامه تطبیق دهید."
sections.append(
{
"title": "نکته بهره وری",
"icon": "tabler-bulb",
"type": "tip",
"content": tip_content,
}
)
return sections[:4]
def _build_irrigation_ui_payload(
llm_result: dict[str, Any],
optimizer_result: dict[str, Any] | None,
daily_water_needs: list[dict[str, Any]],
crop_profile: dict[str, Any],
active_kc: float,
irrigation_method: IrrigationMethod | None,
sensor: SensorData | None,
) -> dict[str, Any]:
soil_moisture = _sensor_metric(sensor, "soil_moisture")
plan = _normalize_plan(
llm_result,
optimizer_result,
daily_water_needs,
irrigation_method,
soil_moisture,
)
payload = {
"plan": plan,
"water_balance": {
"daily": daily_water_needs,
"crop_profile": crop_profile,
"active_kc": active_kc,
},
"timeline": _normalize_timeline(llm_result, optimizer_result, daily_water_needs),
"sections": _normalize_sections(
llm_result,
optimizer_result,
daily_water_needs,
plan["warning"],
),
}
return payload
def _resolve_irrigation_method(
sensor: SensorData | None,
irrigation_method_name: str | None,
) -> IrrigationMethod | None:
if irrigation_method_name:
return IrrigationMethod.objects.filter(name=irrigation_method_name).first()
if sensor is not None:
return sensor.irrigation_method
return None
def _persist_irrigation_method_on_farm(
sensor: SensorData | None,
irrigation_method: IrrigationMethod | None,
) -> None:
if sensor is None or irrigation_method is None:
return
if sensor.irrigation_method_id == irrigation_method.id:
return
with transaction.atomic():
sensor.irrigation_method = irrigation_method
sensor.save(update_fields=["irrigation_method", "updated_at"])
def get_irrigation_recommendation(
farm_uuid: str | None = None,
plant_name: str | None = None,
growth_stage: str | None = None,
irrigation_method_name: str | None = None,
query: str | None = None,
config: RAGConfig | None = None,
limit: int = 8,
sensor_uuid: str | None = None,
) -> dict:
"""
توصیه آبیاری برای یک مزرعه.
از RAG با پایگاه دانش irrigation استفاده می‌کند.
Args:
farm_uuid: شناسه مزرعه
plant_name: نام گیاه (برای بارگذاری مشخصات از جدول Plant)
growth_stage: مرحله رشد گیاه
irrigation_method_name: نام روش آبیاری (برای بارگذاری از جدول IrrigationMethod)
query: سوال اختیاری
config: تنظیمات RAG
limit: تعداد چانک‌های بازیابی‌شده
Returns:
dict ساختاریافته برای توصیه آبیاری
"""
cfg = config or load_rag_config()
service = get_service_config(SERVICE_ID, cfg)
service_cfg = RAGConfig(
embedding=cfg.embedding,
qdrant=cfg.qdrant,
chunking=cfg.chunking,
llm=service.llm,
knowledge_bases=cfg.knowledge_bases,
services=cfg.services,
chromadb=cfg.chromadb,
)
client = get_chat_client(service_cfg)
model = service.llm.model
resolved_farm_uuid = str(farm_uuid or sensor_uuid or "").strip()
if not resolved_farm_uuid:
raise ValueError("farm_uuid is required.")
user_query = query or "توصیه آبیاری برای مزرعه من چیست؟"
sensor = (
SensorData.objects.select_related("center_location", "irrigation_method")
.prefetch_related("plant_assignments__plant")
.filter(farm_uuid=resolved_farm_uuid)
.first()
)
irrigation_method = _resolve_irrigation_method(sensor, irrigation_method_name)
_persist_irrigation_method_on_farm(sensor, irrigation_method)
plant = None
resolved_plant_name = plant_name
if sensor is not None:
selected_snapshot = get_farm_plant_snapshot_by_name(sensor, plant_name)
plant = clone_snapshot_as_runtime_plant(
selected_snapshot,
growth_stage=growth_stage,
)
if selected_snapshot is not None:
resolved_plant_name = selected_snapshot.name
elif plant_name:
resolved_plant_name = plant_name
crop_profile = resolve_crop_profile(plant, growth_stage=growth_stage)
active_kc = resolve_kc(crop_profile, growth_stage=growth_stage)
forecasts = []
daily_water_needs = []
optimized_result = None
if sensor is not None:
forecasts = list(
WeatherForecast.objects.filter(
location=sensor.center_location,
forecast_date__isnull=False,
).order_by("forecast_date")[:7]
)
efficiency_percent = (
getattr(irrigation_method, "water_efficiency_percent", None)
if irrigation_method
else None
)
daily_water_needs = calculate_forecast_water_needs(
forecasts=forecasts,
latitude_deg=float(sensor.center_location.latitude),
crop_profile=crop_profile,
growth_stage=growth_stage,
irrigation_efficiency_percent=efficiency_percent,
)
if plant is not None and forecasts:
optimized_result = _get_optimizer().optimize_irrigation(
sensor=sensor,
plant=plant,
forecasts=forecasts,
daily_water_needs=daily_water_needs,
growth_stage=growth_stage,
irrigation_method=irrigation_method,
)
context = build_rag_context(
user_query,
resolved_farm_uuid,
config=cfg,
limit=limit,
kb_name=KB_NAME,
service_id=SERVICE_ID,
)
extra_parts: list[str] = []
resolved_irrigation_method_name = irrigation_method.name if irrigation_method is not None else None
if resolved_plant_name and growth_stage:
plant_text = build_plant_text(resolved_plant_name, growth_stage)
if plant_text:
extra_parts.append("[اطلاعات گیاه]\n" + plant_text)
if resolved_irrigation_method_name:
method_text = build_irrigation_method_text(resolved_irrigation_method_name)
if method_text:
extra_parts.append("[روش آبیاری انتخابی]\n" + method_text)
if daily_water_needs:
total_mm = round(sum(item["gross_irrigation_mm"] for item in daily_water_needs), 2)
schedule_lines = [
f"- {item['forecast_date']}: ET0={item['et0_mm']} mm, ETc={item['etc_mm']} mm, "
f"بارش مؤثر={item['effective_rainfall_mm']} mm, نیاز آبی={item['gross_irrigation_mm']} mm, "
f"زمان پیشنهادی={item['irrigation_timing']}"
for item in daily_water_needs
]
extra_parts.append(
"[خروجی قطعی محاسبات FAO-56]\n"
f"کل نیاز آبی ۷ روز آینده: {total_mm} mm\n"
f"Kc مورد استفاده: {active_kc}\n"
+ "\n".join(schedule_lines)
)
if optimized_result is not None:
extra_parts.append("[خروجی بهینه ساز شبیه سازی]\n" + optimized_result["context_text"])
if extra_parts:
context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "")
tone = _load_service_tone(service, cfg)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(DEFAULT_IRRIGATION_PROMPT)
if context:
system_parts.append("\n\n" + context)
system_content = "\n".join(system_parts)
messages = [
{"role": "system", "content": system_content},
{"role": "user", "content": user_query},
]
audit_log = _create_audit_log(
farm_uuid=resolved_farm_uuid,
service_id=SERVICE_ID,
model=model,
query=user_query,
system_prompt=system_content,
messages=messages,
)
try:
response = client.chat.completions.create(
model=model,
messages=messages,
)
raw = response.choices[0].message.content.strip()
except Exception as exc:
logger.error("Irrigation recommendation error for %s: %s", resolved_farm_uuid, exc)
_fail_audit_log(audit_log, str(exc))
raise RuntimeError(
f"Irrigation recommendation failed for farm {resolved_farm_uuid}."
) from exc
try:
cleaned = raw
if cleaned.startswith("```"):
cleaned = cleaned.strip("`").removeprefix("json").strip()
llm_result = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
llm_result = {}
result = _build_irrigation_ui_payload(
_coerce_dict(llm_result),
optimized_result,
daily_water_needs,
crop_profile,
active_kc,
irrigation_method,
sensor,
)
result["raw_response"] = raw
result["simulation_optimizer"] = optimized_result
result["selected_irrigation_method"] = (
{
"id": irrigation_method.id,
"name": irrigation_method.name,
"category": irrigation_method.category,
"water_efficiency_percent": irrigation_method.water_efficiency_percent,
}
if irrigation_method is not None
else None
)
_complete_audit_log(
audit_log,
json.dumps(result, ensure_ascii=False, default=str),
)
return result
@@ -0,0 +1,397 @@
from __future__ import annotations
import json
import logging
from typing import Any, Literal
from pydantic import BaseModel, Field, ValidationError
from rag.api_provider import get_chat_client
from rag.chat import (
_complete_audit_log,
_create_audit_log,
_fail_audit_log,
_load_service_tone,
build_rag_context,
)
from rag.config import RAGConfig, get_service_config, load_rag_config
logger = logging.getLogger(__name__)
SERVICE_ID = "irrigation_plan_parser"
KB_NAME = "irrigation_plan_parser"
CORE_FIELDS = [
"crop_name",
"growth_stage",
"irrigation_method",
"water_amount_per_event",
"duration_minutes",
"frequency_text",
"interval_days",
"preferred_time_of_day",
"start_date",
"target_area",
]
IRRIGATION_PLAN_PROMPT = (
"شما یک تحلیل گر برنامه آبیاری هستی. "
"کاربر ممکن است برنامه آبیاری را کامل یا ناقص توضیح دهد. "
"وظیفه شما این است که فقط JSON معتبر برگردانی و متن اضافه، markdown، توضیح بیرون از JSON یا کلید اضافه تولید نکنی. "
"اگر اطلاعات کافی بود status را completed بگذار و final_plan را کامل کن. "
"اگر اطلاعات کافی نبود status را needs_clarification بگذار، missing_fields را پر کن و 1 تا 5 سوال کوتاه و دقیق در questions برگردان. "
"اگر هرکدام از فیلدهای اصلی خالی، null یا نامشخص بود، حق نداری status را completed بگذاری. "
"در حالت completed هیچ فیلد null در collected_data و final_plan نباید وجود داشته باشد. "
"از حدس زدن جزئیات برنامه خودداری کن. "
"اگر کاربر فقط بخشی از سوالات قبلی را جواب داد، داده های جدید را با partial_plan ادغام کن و فقط سوالات باقی مانده را بپرس. "
"Schema: "
"{"
'"status": "completed" | "needs_clarification", '
'"summary": string, '
'"missing_fields": [string], '
'"questions": [{"id": string, "field": string, "question": string, "rationale": string}], '
'"collected_data": {'
'"crop_name": string|null, '
'"growth_stage": string|null, '
'"irrigation_method": string|null, '
'"water_amount_per_event": string|null, '
'"duration_minutes": integer|null, '
'"frequency_text": string|null, '
'"interval_days": integer|null, '
'"preferred_time_of_day": string|null, '
'"start_date": string|null, '
'"target_area": string|null, '
'"trigger_conditions": [string], '
'"notes": [string]'
"}, "
'"final_plan": {same shape as collected_data} | null'
"}."
)
class ClarificationQuestionSchema(BaseModel):
id: str
field: str
question: str
rationale: str = ""
class IrrigationPlanSchema(BaseModel):
crop_name: str | None = None
growth_stage: str | None = None
irrigation_method: str | None = None
water_amount_per_event: str | None = None
duration_minutes: int | None = None
frequency_text: str | None = None
interval_days: int | None = None
preferred_time_of_day: str | None = None
start_date: str | None = None
target_area: str | None = None
trigger_conditions: list[str] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
class IrrigationPlanParseResultSchema(BaseModel):
status: Literal["completed", "needs_clarification"]
summary: str
missing_fields: list[str] = Field(default_factory=list)
questions: list[ClarificationQuestionSchema] = Field(default_factory=list)
collected_data: IrrigationPlanSchema = Field(default_factory=IrrigationPlanSchema)
final_plan: IrrigationPlanSchema | None = None
class IrrigationPlanParserService:
def parse_plan(
self,
*,
message: str = "",
answers: dict[str, Any] | None = None,
partial_plan: dict[str, Any] | None = None,
farm_uuid: str | None = None,
) -> dict[str, Any]:
cfg = load_rag_config()
service, client, model = self._build_service_client(cfg)
normalized_message = (message or "").strip()
normalized_answers = answers if isinstance(answers, dict) else {}
normalized_partial = partial_plan if isinstance(partial_plan, dict) else {}
structured_context = {
"message": normalized_message,
"answers": normalized_answers,
"partial_plan": normalized_partial,
"required_core_fields": CORE_FIELDS,
"service": "irrigation_plan_parser",
}
rag_query = self._build_retrieval_query(
message=normalized_message,
answers=normalized_answers,
)
rag_context = build_rag_context(
query=rag_query,
sensor_uuid=farm_uuid,
config=cfg,
kb_name=KB_NAME,
service_id=SERVICE_ID,
)
system_prompt, messages = self._build_messages(
service=service,
cfg=cfg,
structured_context=structured_context,
rag_context=rag_context,
)
audit_log = None
if farm_uuid:
try:
audit_log = _create_audit_log(
farm_uuid=farm_uuid,
service_id=SERVICE_ID,
model=model,
query=rag_query,
system_prompt=system_prompt,
messages=messages,
)
except Exception as exc:
logger.warning("Irrigation plan parser audit log creation failed for %s: %s", farm_uuid, exc)
try:
response = client.chat.completions.create(
model=model,
messages=messages,
response_format={"type": "json_object"},
)
raw = (response.choices[0].message.content or "").strip()
parsed = self._clean_json(raw)
validated = IrrigationPlanParseResultSchema.model_validate(parsed)
normalized = self._normalize_result(validated)
if audit_log is not None:
_complete_audit_log(audit_log, raw)
return normalized
except (ValidationError, ValueError, KeyError, IndexError) as exc:
logger.warning("Irrigation plan parser parsing failed: %s", exc)
if audit_log is not None:
_fail_audit_log(audit_log, str(exc))
return self._fallback_result(
message=normalized_message,
answers=normalized_answers,
partial_plan=normalized_partial,
)
except Exception as exc:
logger.error("Irrigation plan parser failed: %s", exc)
if audit_log is not None:
_fail_audit_log(audit_log, str(exc))
return self._fallback_result(
message=normalized_message,
answers=normalized_answers,
partial_plan=normalized_partial,
)
def _build_service_client(self, cfg: RAGConfig):
service = get_service_config(SERVICE_ID, cfg)
service_cfg = RAGConfig(
embedding=cfg.embedding,
qdrant=cfg.qdrant,
chunking=cfg.chunking,
llm=service.llm,
knowledge_bases=cfg.knowledge_bases,
services=cfg.services,
chromadb=cfg.chromadb,
)
client = get_chat_client(service_cfg)
return service, client, service.llm.model
def _build_messages(
self,
*,
service: Any,
cfg: RAGConfig,
structured_context: dict[str, Any],
rag_context: str,
) -> tuple[str, list[dict[str, str]]]:
tone = _load_service_tone(service, cfg)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(IRRIGATION_PLAN_PROMPT)
system_parts.append(
"[structured_context]\n"
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
)
if rag_context:
system_parts.append(rag_context)
system_prompt = "\n\n".join(part for part in system_parts if part)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": "برنامه آبیاری را استخراج یا برای تکمیل آن سوال بپرس."},
]
return system_prompt, messages
def _build_retrieval_query(
self,
*,
message: str,
answers: dict[str, Any],
) -> str:
answer_lines = [f"{key}: {value}" for key, value in answers.items()]
parts = [part for part in [message, "\n".join(answer_lines)] if part]
return "\n".join(parts) or "استخراج برنامه آبیاری از متن کاربر"
def _normalize_result(self, validated: IrrigationPlanParseResultSchema) -> dict[str, Any]:
collected = validated.collected_data.model_dump()
final_plan = validated.final_plan.model_dump() if validated.final_plan is not None else None
missing_fields = list(dict.fromkeys(validated.missing_fields))
computed_missing = self._find_missing_fields(final_plan or collected)
for field in computed_missing:
if field not in missing_fields:
missing_fields.append(field)
can_complete = validated.status == "completed" and not missing_fields
if can_complete:
final_plan = final_plan or collected
questions: list[dict[str, Any]] = []
status_fa = "تکمیل شد"
else:
questions = [item.model_dump() for item in validated.questions]
if not questions and missing_fields:
questions = self._build_generic_questions(missing_fields)
final_plan = None
validated.status = "needs_clarification"
status_fa = "نیازمند پرسش تکمیلی"
return {
"status": "completed" if can_complete else "needs_clarification",
"status_fa": status_fa,
"summary": validated.summary,
"missing_fields": missing_fields,
"questions": questions,
"collected_data": collected,
"final_plan": final_plan,
}
def _fallback_result(
self,
*,
message: str,
answers: dict[str, Any],
partial_plan: dict[str, Any],
) -> dict[str, Any]:
merged = dict(partial_plan)
notes = list(merged.get("notes") or [])
if message:
notes.append(f"متن اولیه کاربر: {message}")
for key, value in answers.items():
merged.setdefault(key, value)
return {
"status": "needs_clarification",
"status_fa": "نیازمند پرسش تکمیلی",
"summary": "اطلاعات برنامه آبیاری برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.",
"missing_fields": CORE_FIELDS,
"questions": self._build_generic_questions(CORE_FIELDS),
"collected_data": {
"crop_name": merged.get("crop_name"),
"growth_stage": merged.get("growth_stage"),
"irrigation_method": merged.get("irrigation_method"),
"water_amount_per_event": merged.get("water_amount_per_event"),
"duration_minutes": merged.get("duration_minutes"),
"frequency_text": merged.get("frequency_text"),
"interval_days": merged.get("interval_days"),
"preferred_time_of_day": merged.get("preferred_time_of_day"),
"start_date": merged.get("start_date"),
"target_area": merged.get("target_area"),
"trigger_conditions": merged.get("trigger_conditions") or [],
"notes": notes,
},
"final_plan": None,
}
def _build_generic_questions(self, missing_fields: list[str]) -> list[dict[str, str]]:
catalog = {
"crop_name": {
"id": "crop_name",
"field": "crop_name",
"question": "این برنامه آبیاری برای کدام محصول است؟",
"rationale": "نام محصول برای ثبت برنامه لازم است.",
},
"growth_stage": {
"id": "growth_stage",
"field": "growth_stage",
"question": "محصول الان در چه مرحله رشدی قرار دارد؟",
"rationale": "مرحله رشد برای کامل شدن برنامه لازم است.",
},
"irrigation_method": {
"id": "irrigation_method",
"field": "irrigation_method",
"question": "روش آبیاری چیست؟ مثلا قطره ای، بارانی یا غرقابی.",
"rationale": "روش اجرا روی شکل برنامه تاثیر دارد.",
},
"water_amount_per_event": {
"id": "water_amount_per_event",
"field": "water_amount_per_event",
"question": "در هر نوبت آبیاری چه مقدار آب داده می شود؟",
"rationale": "حجم یا عمق آب هر نوبت مشخص نشده است.",
},
"duration_minutes": {
"id": "duration_minutes",
"field": "duration_minutes",
"question": "مدت زمان هر نوبت آبیاری چند دقیقه است؟",
"rationale": "مدت اجرای هر نوبت هنوز مشخص نیست.",
},
"frequency_text": {
"id": "frequency_text",
"field": "frequency_text",
"question": "فاصله یا تعداد نوبت های آبیاری چگونه است؟ مثلا هر 3 روز یک بار.",
"rationale": "الگوی تکرار آبیاری باید مشخص باشد.",
},
"interval_days": {
"id": "interval_days",
"field": "interval_days",
"question": "فاصله بین دو آبیاری چند روز است؟",
"rationale": "عدد فاصله آبیاری برای JSON نهایی لازم است.",
},
"preferred_time_of_day": {
"id": "preferred_time_of_day",
"field": "preferred_time_of_day",
"question": "بهترین زمان اجرای آبیاری چه موقع از روز است؟",
"rationale": "زمان اجرای برنامه هنوز معلوم نیست.",
},
"start_date": {
"id": "start_date",
"field": "start_date",
"question": "این برنامه از چه تاریخی یا از چه زمانی باید شروع شود؟",
"rationale": "زمان شروع برنامه هنوز مشخص نشده است.",
},
"target_area": {
"id": "target_area",
"field": "target_area",
"question": "این برنامه برای کل مزرعه است یا بخش/ناحیه خاصی از مزرعه؟",
"rationale": "محدوده اجرای برنامه باید مشخص باشد.",
},
}
return [catalog[field] for field in missing_fields if field in catalog][:5]
def _find_missing_fields(self, plan: dict[str, Any]) -> list[str]:
missing: list[str] = []
for field in CORE_FIELDS:
value = plan.get(field)
if value is None:
missing.append(field)
continue
if isinstance(value, str) and not value.strip():
missing.append(field)
return missing
def _clean_json(self, raw: str) -> dict[str, Any]:
cleaned = (raw or "").strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
if not cleaned:
raise ValueError("Irrigation plan parser response was empty.")
parsed = json.loads(cleaned)
if not isinstance(parsed, dict):
raise ValueError("Irrigation plan parser response root must be an object.")
return parsed
+470
View File
@@ -0,0 +1,470 @@
"""
سرویس RAG برای تشخیص تصویری و پیش بینی ریسک آفات و بیماری گیاه.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from farm_data.services import get_farm_details
from rag.api_provider import get_chat_client
from rag.chat import (
_build_content_parts,
_complete_audit_log,
_create_audit_log,
_fail_audit_log,
_load_service_tone,
build_rag_context,
)
from rag.config import RAGConfig, get_service_config, load_rag_config
from rag.failure_contract import RAGServiceError
from rag.user_data import build_plant_text
logger = logging.getLogger(__name__)
KB_NAME = "pest_disease"
SERVICE_ID = "pest_disease"
DETECTION_PROMPT = (
"شما یک دستیار تخصصی تشخیص آفات و بیماری گیاهی هستی. "
"با استفاده از تصویر، اطلاعات مزرعه، و متن های بازیابی شده از پایگاه دانش تحلیل کن. "
"پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: "
"has_issue, category, confidence, severity, summary, detected_signs, possible_causes, immediate_actions, reasoning. "
"category فقط یکی از no_issue, pest, disease, nutrient_stress, abiotic_stress, unknown باشد. "
"severity فقط یکی از low, medium, high باشد."
)
RISK_PROMPT = (
"شما یک دستیار تخصصی پیش بینی ریسک آفات و بیماری گیاهی هستی. "
"با استفاده از داده های مزرعه، آب و هوا، مرحله رشد، و متن های بازیابی شده از پایگاه دانش تحلیل کن. "
"پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: "
"summary, forecast_window, overall_risk, disease_risk, pest_risk, key_drivers, recommended_actions. "
"overall_risk فقط یکی از low, medium, high باشد. "
"disease_risk و pest_risk باید آبجکت هایی با کلیدهای score, level, likely_conditions, reasoning باشند و level فقط یکی از low, medium, high باشد."
)
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
if value in (None, ""):
return default
return float(value)
except (TypeError, ValueError):
return default
def _normalize_images(images: list[dict[str, str]] | None) -> list[dict[str, str]]:
output: list[dict[str, str]] = []
for item in images or []:
if not isinstance(item, dict):
continue
url = item.get("url")
if not isinstance(url, str) or not url.strip():
continue
output.append({"url": url.strip(), "detail": item.get("detail", "auto")})
return output
def _clean_json(raw: str) -> dict[str, Any]:
cleaned = (raw or "").strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
if not cleaned:
raise 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:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, ValueError) as exc:
logger.warning("Invalid JSON returned by pest_disease LLM: %s", cleaned[:500])
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 RAGServiceError(
error_code="farm_not_found",
message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.",
source="farm_data",
details={"farm_uuid": farm_uuid},
http_status=404,
)
return farm_details
def _build_service_client(cfg: RAGConfig):
service = get_service_config(SERVICE_ID, cfg)
service_cfg = RAGConfig(
embedding=cfg.embedding,
qdrant=cfg.qdrant,
chunking=cfg.chunking,
llm=service.llm,
knowledge_bases=cfg.knowledge_bases,
services=cfg.services,
chromadb=cfg.chromadb,
)
client = get_chat_client(service_cfg)
return service, client, service.llm.model
def _weather_risk_summary(farm_details: dict[str, Any]) -> dict[str, Any]:
weather = farm_details.get("weather") or {}
soil = (farm_details.get("soil") or {}).get("resolved_metrics") or {}
humidity = _safe_float(weather.get("humidity_mean"), 55.0)
temp = _safe_float(weather.get("temperature_mean"), 24.0)
rain = _safe_float(weather.get("precipitation"), 0.0)
moisture = _safe_float(soil.get("soil_moisture"), _safe_float(soil.get("wv0033"), 35.0))
ec = _safe_float(soil.get("electrical_conductivity"), 0.0)
ph = _safe_float(soil.get("soil_ph") or soil.get("phh2o"), 7.0)
fungal_score = min(max(round((humidity * 0.45) + (moisture * 0.35) + (rain * 2.5) - 25, 2), 0.0), 100.0)
pest_score = min(max(round((temp * 2.2) + max(0.0, 45.0 - moisture) + (ec * 3.0) - 20, 2), 0.0), 100.0)
abiotic_stress = min(max(round((abs(ph - 6.8) * 18.0) + (ec * 8.0), 2), 0.0), 100.0)
return {
"humidity_mean": humidity,
"temperature_mean": temp,
"precipitation": rain,
"soil_moisture": moisture,
"ec": ec,
"ph": ph,
"fungal_score": fungal_score,
"pest_score": pest_score,
"abiotic_stress_score": abiotic_stress,
}
def _risk_level(score: float) -> str:
if score >= 70:
return "high"
if score >= 40:
return "medium"
return "low"
def _build_risk_context(farm_details: dict[str, Any], plant_name: str | None, growth_stage: str | None) -> dict[str, Any]:
risk = _weather_risk_summary(farm_details)
disease_level = _risk_level(risk["fungal_score"])
pest_level = _risk_level(risk["pest_score"])
overall_score = max(risk["fungal_score"], risk["pest_score"], risk["abiotic_stress_score"])
overall_level = _risk_level(overall_score)
drivers = []
if risk["humidity_mean"] >= 70:
drivers.append("رطوبت بالا")
if risk["soil_moisture"] >= 60:
drivers.append("رطوبت خاک بالا")
if risk["temperature_mean"] >= 30:
drivers.append("دمای بالا")
if risk["precipitation"] > 2:
drivers.append("بارش موثر")
if risk["ec"] > 2.5:
drivers.append("EC بالا")
if abs(risk["ph"] - 6.8) > 0.8:
drivers.append("خروج pH از محدوده مطلوب")
if not drivers:
drivers.append("شرایط فعلی مزرعه نسبتا پایدار است")
return {
"summary": "برآورد ریسک آفات و بیماری بر اساس داده های فعلی مزرعه ساخته شد.",
"forecast_window": "24 تا 72 ساعت آینده",
"overall_risk": overall_level,
"disease_risk": {
"score": risk["fungal_score"],
"level": disease_level,
"likely_conditions": [
"فشار قارچی و بیماری برگی" if disease_level != "low" else "ریسک بیماری فعلا پایین است",
],
"reasoning": [
f"رطوبت میانگین حدود {risk['humidity_mean']} درصد است.",
f"رطوبت خاک حدود {risk['soil_moisture']} درصد برآورد شده است.",
],
},
"pest_risk": {
"score": risk["pest_score"],
"level": pest_level,
"likely_conditions": [
"فشار آفات مکنده یا تنش زا" if pest_level != "low" else "ریسک آفت فعلا پایین است",
],
"reasoning": [
f"دمای میانگین حدود {risk['temperature_mean']} درجه است.",
f"EC فعلی حدود {risk['ec']} و pH حدود {risk['ph']} است.",
],
},
"key_drivers": drivers,
"recommended_actions": [
"بازدید مزرعه و بررسی برگ ها و پشت برگ انجام شود.",
"در صورت مشاهده علائم مشکوک، نمونه برداری تصویری نزدیک تر انجام شود.",
"رطوبت ماندگار و یکنواختی آبیاری پایش شود.",
],
"farm_context": {
"plant_name": plant_name,
"growth_stage": growth_stage,
"risk_summary": risk,
},
}
def _validate_detection_result(parsed: dict[str, Any]) -> dict[str, Any]:
required_keys = {
"has_issue",
"category",
"confidence",
"severity",
"summary",
"detected_signs",
"possible_causes",
"immediate_actions",
"reasoning",
}
missing = [key for key in required_keys if key not in parsed]
if 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
def _validate_risk_result(parsed: dict[str, Any]) -> dict[str, Any]:
required_keys = {
"summary",
"forecast_window",
"overall_risk",
"disease_risk",
"pest_risk",
"key_drivers",
"recommended_actions",
}
missing = [key for key in required_keys if key not in parsed]
if 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
def _build_detection_messages(
*,
service: Any,
cfg: RAGConfig,
query: str,
rag_context: str,
plant_text: str,
images: list[dict[str, str]],
) -> tuple[str, list[dict[str, Any]]]:
tone = _load_service_tone(service, cfg)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(DETECTION_PROMPT)
if plant_text:
system_parts.append("[اطلاعات گیاه]\n" + plant_text)
if rag_context:
system_parts.append(rag_context)
system_prompt = "\n\n".join(part for part in system_parts if part)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": _build_content_parts(query, images)},
]
return system_prompt, messages
def _build_risk_messages(
*,
service: Any,
cfg: RAGConfig,
query: str,
rag_context: str,
structured_context: dict[str, Any],
plant_text: str,
) -> tuple[str, list[dict[str, str]]]:
tone = _load_service_tone(service, cfg)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(RISK_PROMPT)
if plant_text:
system_parts.append("[اطلاعات گیاه]\n" + plant_text)
system_parts.append("[کانتکست ساختاریافته ریسک]\n" + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str))
if rag_context:
system_parts.append(rag_context)
system_prompt = "\n\n".join(part for part in system_parts if part)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": query},
]
return system_prompt, messages
def get_pest_disease_detection(
*,
farm_uuid: str,
plant_name: str | None = None,
query: str | None = None,
images: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
normalized_images = _normalize_images(images)
if not normalized_images:
raise RAGServiceError(
error_code="missing_images",
message="حداقل یک تصویر برای تشخیص لازم است.",
source="request",
http_status=400,
)
cfg = load_rag_config()
service, client, model = _build_service_client(cfg)
farm_details = _load_farm_or_error(farm_uuid)
resolved_plant_name = plant_name or (farm_details.get("plants") or [{}])[0].get("name")
user_query = query or "این تصویر را بررسی کن و بگو آیا گیاه دچار آفت یا بیماری شده است یا نه."
plant_text = build_plant_text(resolved_plant_name, "") if resolved_plant_name else ""
rag_context = build_rag_context(
query=user_query,
sensor_uuid=farm_uuid,
config=cfg,
kb_name=KB_NAME,
service_id=SERVICE_ID,
farm_details=farm_details,
)
system_prompt, messages = _build_detection_messages(
service=service,
cfg=cfg,
query=user_query,
rag_context=rag_context,
plant_text=plant_text or "",
images=normalized_images,
)
audit_log = _create_audit_log(
farm_uuid=farm_uuid,
service_id=SERVICE_ID,
model=model,
query=user_query,
system_prompt=system_prompt,
messages=messages,
)
try:
response = client.chat.completions.create(model=model, messages=messages)
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 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
def get_pest_disease_risk(
*,
farm_uuid: str,
plant_name: str | None = None,
growth_stage: str | None = None,
query: str | None = None,
) -> dict[str, Any]:
cfg = load_rag_config()
service, client, model = _build_service_client(cfg)
farm_details = _load_farm_or_error(farm_uuid)
resolved_plant_name = plant_name or (farm_details.get("plants") or [{}])[0].get("name")
risk_context = _build_risk_context(farm_details, resolved_plant_name, growth_stage)
user_query = query or "ریسک آفات و بیماری این مزرعه را برای چند روز آینده پیش بینی کن."
plant_text = build_plant_text(resolved_plant_name, growth_stage or "") if resolved_plant_name else ""
rag_context = build_rag_context(
query=user_query,
sensor_uuid=farm_uuid,
config=cfg,
kb_name=KB_NAME,
service_id=SERVICE_ID,
farm_details=farm_details,
)
system_prompt, messages = _build_risk_messages(
service=service,
cfg=cfg,
query=user_query,
rag_context=rag_context,
structured_context=risk_context,
plant_text=plant_text or "",
)
audit_log = _create_audit_log(
farm_uuid=farm_uuid,
service_id=SERVICE_ID,
model=model,
query=user_query,
system_prompt=system_prompt,
messages=messages,
)
try:
response = client.chat.completions.create(model=model, messages=messages)
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 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
+214
View File
@@ -0,0 +1,214 @@
from __future__ import annotations
import json
import logging
from typing import Any
from farm_data.services import get_farm_details
from rag.api_provider import get_chat_client
from rag.chat import (
_complete_audit_log,
_create_audit_log,
_fail_audit_log,
_load_service_tone,
build_rag_context,
)
from rag.config import RAGConfig, get_service_config, load_rag_config
from rag.failure_contract import RAGServiceError
logger = logging.getLogger(__name__)
KB_NAME = "soil_anomaly"
SERVICE_ID = "soil_anomaly"
SOIL_ANOMALY_PROMPT = (
"شما یک دستیار تخصصی تحلیل ناهنجاری داده های خاک و سنسور مزرعه هستی. "
"ورودی شامل داده های ساختاریافته ناهنجاری، اطلاعات مزرعه، و متن های بازیابی شده از پایگاه دانش است. "
"فقط JSON معتبر برگردان و فقط این کلیدها را تولید کن: "
"summary, explanation, likely_cause, recommended_action, monitoring_priority, confidence. "
"monitoring_priority فقط یکی از low, medium, high, urgent باشد. "
"confidence عددی بین 0 و 1 باشد. "
"اگر ناهنجاری معناداری وجود ندارد، این موضوع را شفاف و بدون اغراق بیان کن."
)
def _clean_json(raw: str) -> dict[str, Any]:
cleaned = (raw or "").strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
if not cleaned:
raise 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:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, ValueError) as exc:
logger.warning("Invalid JSON returned by soil_anomaly LLM: %s", cleaned[:500])
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 RAGServiceError(
error_code="farm_not_found",
message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.",
source="farm_data",
details={"farm_uuid": farm_uuid},
http_status=404,
)
return farm_details
def _build_service_client(cfg: RAGConfig):
service = get_service_config(SERVICE_ID, cfg)
service_cfg = RAGConfig(
embedding=cfg.embedding,
qdrant=cfg.qdrant,
chunking=cfg.chunking,
llm=service.llm,
knowledge_bases=cfg.knowledge_bases,
services=cfg.services,
chromadb=cfg.chromadb,
)
client = get_chat_client(service_cfg)
return service, client, service.llm.model
def _validate_anomaly_insight(parsed: dict[str, Any]) -> dict[str, Any]:
required_keys = {
"summary",
"explanation",
"likely_cause",
"recommended_action",
"monitoring_priority",
"confidence",
}
missing = [key for key in required_keys if key not in parsed]
if 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
def _build_messages(
*,
service: Any,
cfg: RAGConfig,
query: str,
rag_context: str,
structured_context: dict[str, Any],
) -> tuple[str, list[dict[str, str]]]:
tone = _load_service_tone(service, cfg)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(SOIL_ANOMALY_PROMPT)
system_parts.append(
"[کانتکست ساختاریافته ناهنجاري خاک]\n"
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
)
if rag_context:
system_parts.append(rag_context)
system_prompt = "\n\n".join(part for part in system_parts if part)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": query},
]
return system_prompt, messages
def get_soil_anomaly_insight(
*,
farm_uuid: str,
anomaly_payload: dict[str, Any],
query: str | None = None,
) -> dict[str, Any]:
cfg = load_rag_config()
service, client, model = _build_service_client(cfg)
farm_details = _load_farm_or_error(farm_uuid)
user_query = query or "ناهنجاري هاي داده هاي خاک اين مزرعه را تفسير کن و اقدام مناسب پيشنهاد بده."
structured_context = {
"farm_uuid": farm_uuid,
"anomaly_payload": anomaly_payload,
}
rag_context = build_rag_context(
query=user_query,
sensor_uuid=farm_uuid,
config=cfg,
kb_name=KB_NAME,
service_id=SERVICE_ID,
farm_details=farm_details,
)
system_prompt, messages = _build_messages(
service=service,
cfg=cfg,
query=user_query,
rag_context=rag_context,
structured_context=structured_context,
)
audit_log = _create_audit_log(
farm_uuid=farm_uuid,
service_id=SERVICE_ID,
model=model,
query=user_query,
system_prompt=system_prompt,
messages=messages,
)
try:
response = client.chat.completions.create(model=model, messages=messages)
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 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
@@ -0,0 +1,211 @@
from __future__ import annotations
import json
import logging
from typing import Any
from farm_data.services import get_farm_details
from rag.api_provider import get_chat_client
from rag.chat import (
_complete_audit_log,
_create_audit_log,
_fail_audit_log,
_load_service_tone,
build_rag_context,
)
from rag.config import RAGConfig, get_service_config, load_rag_config
from rag.failure_contract import RAGServiceError
logger = logging.getLogger(__name__)
KB_NAME = "water_need_prediction"
SERVICE_ID = "water_need_prediction"
WATER_NEED_PROMPT = (
"شما یک دستیار تخصصی تحليل نياز آبي کوتاه مدت مزرعه هستي. "
"ورودي شامل محاسبات ساختاريافته نياز آبي، اطلاعات مزرعه و متن هاي بازيابي شده از پايگاه دانش است. "
"فقط JSON معتبر با اين کليدها برگردان: "
"summary, irrigation_outlook, recommended_action, risk_note, confidence. "
"confidence عددي بين 0 و 1 باشد. "
"اعداد اصلي را از داده ورودي بگير و عدد متناقض جديد نساز."
)
def _clean_json(raw: str) -> dict[str, Any]:
cleaned = (raw or "").strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
if not cleaned:
raise 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:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, ValueError) as exc:
logger.warning("Invalid JSON returned by water_need_prediction LLM: %s", cleaned[:500])
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 RAGServiceError(
error_code="farm_not_found",
message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.",
source="farm_data",
details={"farm_uuid": farm_uuid},
http_status=404,
)
return farm_details
def _build_service_client(cfg: RAGConfig):
service = get_service_config(SERVICE_ID, cfg)
service_cfg = RAGConfig(
embedding=cfg.embedding,
qdrant=cfg.qdrant,
chunking=cfg.chunking,
llm=service.llm,
knowledge_bases=cfg.knowledge_bases,
services=cfg.services,
chromadb=cfg.chromadb,
)
client = get_chat_client(service_cfg)
return service, client, service.llm.model
def _validate_prediction_insight(parsed: dict[str, Any]) -> dict[str, Any]:
required_keys = {
"summary",
"irrigation_outlook",
"recommended_action",
"risk_note",
"confidence",
}
missing = [key for key in required_keys if key not in parsed]
if 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
def _build_messages(
*,
service: Any,
cfg: RAGConfig,
query: str,
rag_context: str,
structured_context: dict[str, Any],
) -> tuple[str, list[dict[str, str]]]:
tone = _load_service_tone(service, cfg)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(WATER_NEED_PROMPT)
system_parts.append(
"[کانتکست ساختاريافته نياز آبي]\n"
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
)
if rag_context:
system_parts.append(rag_context)
system_prompt = "\n\n".join(part for part in system_parts if part)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": query},
]
return system_prompt, messages
def get_water_need_prediction_insight(
*,
farm_uuid: str,
prediction_payload: dict[str, Any],
query: str | None = None,
) -> dict[str, Any]:
cfg = load_rag_config()
service, client, model = _build_service_client(cfg)
farm_details = _load_farm_or_error(farm_uuid)
user_query = query or "نياز آبي کوتاه مدت اين مزرعه را تفسير کن و اقدام عملياتي پيشنهاد بده."
structured_context = {
"farm_uuid": farm_uuid,
"prediction_payload": prediction_payload,
}
rag_context = build_rag_context(
query=user_query,
sensor_uuid=farm_uuid,
config=cfg,
kb_name=KB_NAME,
service_id=SERVICE_ID,
farm_details=farm_details,
)
system_prompt, messages = _build_messages(
service=service,
cfg=cfg,
query=user_query,
rag_context=rag_context,
structured_context=structured_context,
)
audit_log = _create_audit_log(
farm_uuid=farm_uuid,
service_id=SERVICE_ID,
model=model,
query=user_query,
system_prompt=system_prompt,
messages=messages,
)
try:
response = client.chat.completions.create(model=model, messages=messages)
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 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
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import json
import logging
from typing import Any
from pydantic import BaseModel, Field, ValidationError
from rag.api_provider import get_chat_client
from rag.chat import (
_complete_audit_log,
_create_audit_log,
_fail_audit_log,
_load_service_tone,
)
from rag.config import RAGConfig, get_service_config, load_rag_config
from rag.failure_contract import RAGServiceError
logger = logging.getLogger(__name__)
SERVICE_ID = "yield_harvest"
YIELD_HARVEST_PROMPT = (
"You are an expert agronomist writing concise dashboard narratives for farmers. "
"Return only valid JSON matching this schema exactly: "
"{"
'"season_highlights_subtitle": string, '
'"yield_prediction_explanation": string, '
'"harvest_readiness_summary": string, '
'"operation_notes": [string, ...]'
"}. "
"Do not add markdown, explanations, or extra keys. "
"Strict Golden Rule: do not invent numbers, dates, prices, revenues, percentages, KPIs, scores, or measurements. "
"Use only values already present in the deterministic context. "
"If a fact is missing from the context, say less rather than guessing."
)
class YieldHarvestNarrativeSchema(BaseModel):
season_highlights_subtitle: str
yield_prediction_explanation: str
harvest_readiness_summary: str
operation_notes: list[str] = Field(default_factory=list)
class YieldHarvestRAGService:
def generate_narrative(
self,
deterministic_context: dict[str, Any],
) -> dict[str, Any]:
cfg = load_rag_config()
service, client, model = self._build_service_client(cfg)
structured_context = self._build_structured_context(
deterministic_context=deterministic_context,
)
user_prompt = (
"Generate short user-friendly narrative fields for the Yield & Harvest Summary dashboard "
"using only the deterministic context. Keep the language practical and agronomy-focused."
)
system_prompt, messages = self._build_messages(
service=service,
cfg=cfg,
structured_context=structured_context,
query=user_prompt,
)
farm_uuid = str(deterministic_context.get("farm_uuid") or "")
audit_log = None
if farm_uuid:
try:
audit_log = _create_audit_log(
farm_uuid=farm_uuid,
service_id=SERVICE_ID,
model=model,
query=user_prompt,
system_prompt=system_prompt,
messages=messages,
)
except Exception as exc:
logger.warning("Yield harvest audit log creation failed for %s: %s", farm_uuid, exc)
try:
response = client.chat.completions.create(
model=model,
messages=messages,
response_format={"type": "json_object"},
)
raw = (response.choices[0].message.content or "").strip()
parsed = self._clean_json(raw)
validated = YieldHarvestNarrativeSchema.model_validate(parsed)
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,
"operation_notes": validated.operation_notes,
}
except (ValidationError, ValueError, KeyError, IndexError) as exc:
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))
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))
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)
service_cfg = RAGConfig(
embedding=cfg.embedding,
qdrant=cfg.qdrant,
chunking=cfg.chunking,
llm=service.llm,
knowledge_bases=cfg.knowledge_bases,
services=cfg.services,
chromadb=cfg.chromadb,
)
client = get_chat_client(service_cfg)
return service, client, service.llm.model
def _build_messages(
self,
*,
service: Any,
cfg: RAGConfig,
structured_context: dict[str, Any],
query: str,
) -> tuple[str, list[dict[str, str]]]:
tone = _load_service_tone(service, cfg)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(YIELD_HARVEST_PROMPT)
system_parts.append(
"[deterministic_context]\n"
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
)
system_prompt = "\n\n".join(part for part in system_parts if part)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": query},
]
return system_prompt, messages
def _build_structured_context(
self,
*,
deterministic_context: dict[str, Any],
) -> dict[str, Any]:
season = deterministic_context.get("season_highlights_card") or {}
harvest = deterministic_context.get("harvest_prediction_card") or {}
operations = deterministic_context.get("harvest_operations_card") or {}
yield_prediction = deterministic_context.get("yield_prediction") or {}
readiness = deterministic_context.get("harvest_readiness_zones") or {}
operation_steps = []
for step in operations.get("steps") or []:
if not isinstance(step, dict):
continue
operation_steps.append(
{
"key": step.get("key"),
"title": step.get("title"),
"status": step.get("status"),
}
)
return {
"farm_context": deterministic_context.get("farm_context") or {},
"yield_prediction": {
"predicted_yield_tons": yield_prediction.get("predicted_yield_tons"),
"unit": yield_prediction.get("unit"),
"simulation_warning": yield_prediction.get("simulation_warning"),
"supporting_metrics": yield_prediction.get("supporting_metrics"),
},
"season_highlights_card": {
"title": season.get("title"),
"subtitle": season.get("subtitle"),
"total_predicted_yield": season.get("total_predicted_yield"),
"yield_unit": season.get("yield_unit"),
"target_harvest_date": season.get("target_harvest_date"),
"days_until_harvest": season.get("days_until_harvest"),
"average_readiness": season.get("average_readiness"),
"primary_quality_grade": season.get("primary_quality_grade"),
"estimated_revenue": season.get("estimated_revenue"),
},
"harvest_prediction_card": {
"harvest_date": harvest.get("harvest_date"),
"harvest_date_formatted": harvest.get("harvest_date_formatted"),
"days_until": harvest.get("days_until"),
"optimal_window_start": harvest.get("optimal_window_start"),
"optimal_window_end": harvest.get("optimal_window_end"),
"description": harvest.get("description"),
},
"harvest_readiness_zones": {
"average_readiness": readiness.get("averageReadiness"),
"mean_ndvi": readiness.get("meanNdvi"),
"ndvi_trend": readiness.get("ndviTrend"),
"zones": readiness.get("zones"),
},
"harvest_operations_card": {
"stage_label": operations.get("stage_label"),
"days_until_harvest": operations.get("days_until_harvest"),
"current_dvs": operations.get("current_dvs"),
"summary": operations.get("summary"),
"steps": operation_steps,
},
}
def _clean_json(self, raw: str) -> dict[str, Any]:
cleaned = (raw or "").strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
if not cleaned:
raise 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 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 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
+77
View File
@@ -0,0 +1,77 @@
"""
تسک‌های Celery برای RAG
"""
from config.celery import app
from .ingest import ingest
@app.task
def rag_ingest_task(recreate: bool = True):
"""
embed و ذخیره دیتای همه کاربران در Qdrant.
هر چند ساعت یکبار اجرا شود (از طریق Celery Beat).
recreate=True: collection از نو ساخته می‌شود تا دیتای قدیمی حذف شود.
"""
result = ingest(recreate=recreate)
return result
@app.task(bind=True)
def irrigation_recommendation_task(
self,
farm_uuid: str,
plant_name: str | None = None,
growth_stage: str | None = None,
irrigation_method_name: str | None = None,
query: str | None = None,
) -> dict:
"""
تسک Celery برای تولید توصیه آبیاری.
داده‌های سنسور، گیاه و روش آبیاری را از DB بارگذاری کرده
و از سرویس RAG توصیه می‌گیرد.
"""
from rag.services.irrigation import get_irrigation_recommendation
self.update_state(
state="PROGRESS",
meta={"message": "در حال پردازش توصیه آبیاری..."},
)
result = get_irrigation_recommendation(
farm_uuid=farm_uuid,
plant_name=plant_name,
growth_stage=growth_stage,
irrigation_method_name=irrigation_method_name,
query=query,
)
result["status"] = "completed"
return result
@app.task(bind=True)
def fertilization_recommendation_task(
self,
farm_uuid: str,
plant_name: str | None = None,
growth_stage: str | None = None,
query: str | None = None,
) -> dict:
"""
تسک Celery برای تولید توصیه کودهی.
داده‌های سنسور و گیاه را از DB بارگذاری کرده
و از سرویس RAG توصیه می‌گیرد.
"""
from rag.services.fertilization import get_fertilization_recommendation
self.update_state(
state="PROGRESS",
meta={"message": "در حال پردازش توصیه کودهی..."},
)
result = get_fertilization_recommendation(
farm_uuid=farm_uuid,
plant_name=plant_name,
growth_stage=growth_stage,
query=query,
)
result["status"] = "completed"
return result
+71
View File
@@ -0,0 +1,71 @@
from unittest.mock import patch
from django.test import SimpleTestCase
from rag.chat import _normalize_history_messages, build_rag_context
class ChatContextTests(SimpleTestCase):
@patch("rag.chat.search_with_texts")
@patch("rag.chat.chunk_text")
def test_build_rag_context_includes_full_farm_and_kb_results(
self,
mock_chunk_text,
mock_search_with_texts,
):
mock_chunk_text.return_value = ["farm chunk 1", "farm chunk 2"]
mock_search_with_texts.return_value = [
{"id": "kb-1", "score": 0.8, "text": "kb text 1", "metadata": {}},
{"id": "kb-2", "score": 0.7, "text": "kb text 2", "metadata": {}},
]
context = build_rag_context(
query="وضعیت مزرعه چطور است؟",
sensor_uuid="farm-123",
service_id="chat",
farm_details={"sensor_payload": {"sensor-7-1": {"soil_moisture": 30}}},
)
self.assertIn("[اطلاعات کامل مزرعه]", context)
self.assertIn("soil_moisture", context)
self.assertIn("[متن‌های مرجع]", context)
self.assertIn("kb text 1", context)
self.assertIn("kb text 2", context)
mock_search_with_texts.assert_called_once()
sent_texts = mock_search_with_texts.call_args.kwargs["texts"]
self.assertEqual(sent_texts[0], "وضعیت مزرعه چطور است؟")
self.assertIn("farm chunk 1", sent_texts)
self.assertIn("farm chunk 2", sent_texts)
def test_normalize_history_messages_supports_user_images(self):
messages = _normalize_history_messages(
[
{"role": "user", "content": "این تصویر مزرعه است", "image_urls": ["https://example.com/a.jpg"]},
{"role": "assistant", "content": "تصویر دریافت شد."},
]
)
self.assertEqual(len(messages), 2)
self.assertEqual(messages[0]["role"], "user")
self.assertIsInstance(messages[0]["content"], list)
self.assertEqual(messages[0]["content"][0]["type"], "text")
self.assertEqual(messages[0]["content"][1]["type"], "image_url")
self.assertEqual(messages[1]["role"], "assistant")
self.assertEqual(messages[1]["content"], "تصویر دریافت شد.")
@patch("rag.chat.search_with_texts", return_value=[])
@patch("rag.chat.chunk_text", return_value=["farm chunk"])
def test_build_rag_context_returns_full_farm_when_kb_empty(
self,
_mock_chunk_text,
_mock_search_with_texts,
):
context = build_rag_context(
query="رطوبت چقدر است؟",
sensor_uuid="farm-123",
service_id="chat",
farm_details={"sensor_payload": {"sensor-7-1": {"soil_moisture": 30}}},
)
self.assertIn("[اطلاعات کامل مزرعه]", context)
self.assertIn("soil_moisture", context)
@@ -0,0 +1,88 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import Mock, patch
from django.test import SimpleTestCase
from rag.failure_contract import RAGServiceError
from rag.services.pest_disease import get_pest_disease_detection
from rag.services.soil_anomaly import get_soil_anomaly_insight
from rag.services.water_need_prediction import get_water_need_prediction_insight
from rag.services.yield_harvest import YieldHarvestRAGService
class RAGFailureContractTests(SimpleTestCase):
@patch("rag.services.soil_anomaly._create_audit_log", return_value=object())
@patch("rag.services.soil_anomaly._fail_audit_log")
@patch("rag.services.soil_anomaly._build_service_client")
@patch("rag.services.soil_anomaly.build_rag_context", return_value="")
@patch("rag.services.soil_anomaly._load_farm_or_error", return_value={"farm_uuid": "farm-1"})
def test_soil_anomaly_invalid_json_raises_structured_error(
self,
_mock_load_farm,
_mock_context,
mock_build_client,
_mock_fail,
_mock_audit,
):
client = Mock()
client.chat.completions.create.return_value = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="not-json"))]
)
mock_build_client.return_value = (SimpleNamespace(system_prompt=""), client, "gpt-test")
with self.assertRaises(RAGServiceError) as exc_info:
get_soil_anomaly_insight(farm_uuid="farm-1", anomaly_payload={})
self.assertEqual(exc_info.exception.contract.error_code, "invalid_json")
@patch("rag.services.water_need_prediction._create_audit_log", return_value=object())
@patch("rag.services.water_need_prediction._fail_audit_log")
@patch("rag.services.water_need_prediction._build_service_client")
@patch("rag.services.water_need_prediction.build_rag_context", return_value="")
@patch("rag.services.water_need_prediction._load_farm_or_error", return_value={"farm_uuid": "farm-1"})
def test_water_need_invalid_json_raises_structured_error(
self,
_mock_load_farm,
_mock_context,
mock_build_client,
_mock_fail,
_mock_audit,
):
client = Mock()
client.chat.completions.create.return_value = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="not-json"))]
)
mock_build_client.return_value = (SimpleNamespace(system_prompt=""), client, "gpt-test")
with self.assertRaises(RAGServiceError) as exc_info:
get_water_need_prediction_insight(farm_uuid="farm-1", prediction_payload={})
self.assertEqual(exc_info.exception.contract.error_code, "invalid_json")
def test_pest_detection_requires_image_with_structured_error(self):
with self.assertRaises(RAGServiceError) as exc_info:
get_pest_disease_detection(farm_uuid="farm-1", images=[])
self.assertEqual(exc_info.exception.contract.error_code, "missing_images")
@patch("rag.services.yield_harvest._create_audit_log", return_value=object())
@patch("rag.services.yield_harvest._fail_audit_log")
@patch("rag.services.yield_harvest.YieldHarvestRAGService._build_service_client")
def test_yield_harvest_invalid_json_raises_structured_error(
self,
mock_build_client,
_mock_fail,
_mock_audit,
):
client = Mock()
client.chat.completions.create.return_value = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="not-json"))]
)
mock_build_client.return_value = (SimpleNamespace(system_prompt=""), client, "gpt-test")
with self.assertRaises(RAGServiceError) as exc_info:
YieldHarvestRAGService().generate_narrative({"farm_uuid": "farm-1"})
self.assertEqual(exc_info.exception.contract.error_code, "invalid_json")
@@ -0,0 +1,51 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import Mock, patch
from django.test import SimpleTestCase
from rag.embedding import embed_texts
from rag.ingest import ingest
from rag.observability import METRICS
from rag.retrieve import search_with_query
class RAGObservabilityTests(SimpleTestCase):
def tearDown(self):
METRICS.clear()
def test_embed_texts_records_empty_input_metric(self):
result = embed_texts([])
self.assertEqual(result, [])
self.assertEqual(METRICS["rag.embedding.empty_input|operation=embed_texts"], 1)
@patch("rag.retrieve.QdrantVectorStore")
@patch("rag.retrieve.embed_single", return_value=[0.1, 0.2])
@patch("rag.retrieve.load_rag_config")
def test_search_with_query_records_empty_result_metric(self, mock_load_config, _mock_embed, mock_store_cls):
mock_load_config.return_value = SimpleNamespace(
embedding=SimpleNamespace(provider="gapgpt"),
)
mock_store = Mock()
mock_store.search.return_value = []
mock_store_cls.return_value = mock_store
result = search_with_query("query")
self.assertEqual(result, [])
self.assertEqual(METRICS["rag.retrieve.empty_result|operation=search_with_query,service_id=None"], 1)
@patch("rag.ingest.load_sources", return_value=[])
@patch("rag.ingest.QdrantVectorStore")
@patch("rag.ingest.load_rag_config")
def test_ingest_records_empty_sources_metric(self, mock_load_config, _mock_store_cls, _mock_sources):
mock_load_config.return_value = SimpleNamespace(
embedding=SimpleNamespace(provider="gapgpt"),
)
result = ingest()
self.assertEqual(result["chunks_added"], 0)
self.assertEqual(METRICS["rag.ingest.empty_sources|kb_name=None"], 1)
@@ -0,0 +1,387 @@
import uuid
from datetime import date
from unittest.mock import Mock, patch
from django.test import TestCase
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 rag.services.fertilization import get_fertilization_recommendation
from rag.services.irrigation import get_irrigation_recommendation
from weather.models import WeatherForecast
class RecommendationServiceDefaultsTests(TestCase):
def setUp(self):
self.location = SoilLocation.objects.create(
latitude="35.700000",
longitude="51.400000",
farm_boundary={"type": "Polygon", "coordinates": []},
)
WeatherForecast.objects.create(
location=self.location,
forecast_date=date(2026, 4, 10),
temperature_min=12.0,
temperature_max=23.0,
temperature_mean=18.0,
)
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(
farm_uuid=self.farm_uuid,
center_location=self.location,
irrigation_method=self.irrigation_method,
sensor_payload={
"sensor-7-1": {
"soil_moisture": 30.0,
"nitrogen": 18.0,
"phosphorus": 12.0,
"potassium": 14.0,
"soil_ph": 6.9,
}
},
)
assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id])
def build_irrigation_optimizer_result(self):
return {
"engine": "crop_simulation_heuristic",
"context_text": "optimizer irrigation context",
"recommended_strategy": {
"code": "balanced",
"label": "آبیاری متعادل",
"score": 88.0,
"expected_yield_index": 91.0,
"total_irrigation_mm": 24.0,
"amount_per_event_mm": 8.0,
"events": 3,
"frequency_per_week": 3,
"event_dates": ["2026-04-10"],
"timing": "اوایل صبح",
"moisture_target_percent": 70,
"validity_period": "معتبر برای 3 روز آینده",
"reasoning": ["شبیه ساز این سناریو را برتر ارزیابی کرد."],
},
"alternatives": [
{
"code": "protective",
"label": "آبیاری حمایتی",
"score": 80.0,
"expected_yield_index": 85.0,
"total_irrigation_mm": 28.0,
}
],
}
def build_irrigation_llm_result(self):
return (
'{"plan": {"frequencyPerWeek": 3, "durationMinutes": 42, "bestTimeOfDay": "اوایل صبح", '
'"moistureLevel": 68, "warning": "بررسی شود"}, '
'"timeline": [{"step_number": 1, "title": "بازبینی", "description": "لاین ها بررسی شوند"}], '
'"sections": [{"type": "warning", "title": "هشدار", "icon": "alert-triangle", "content": "بررسی شود"}, '
'{"type": "tip", "title": "نکته", "icon": "bulb", "content": "مورد سفارشی"}]}'
)
def build_fertilization_optimizer_result(self):
return {
"engine": "crop_simulation_heuristic",
"context_text": "optimizer fertilization context",
"recommended_strategy": {
"code": "balanced",
"label": "تغذیه متعادل",
"score": 84.0,
"expected_yield_index": 88.0,
"fertilizer_type": "20-20-20",
"amount_kg_per_ha": 65.0,
"application_method": "کودآبیاری",
"timing": "صبح زود",
"validity_period": "معتبر برای 7 روز آینده",
"reasoning": ["کسری عناصر با این سناریو بهتر پوشش داده می شود."],
},
"alternatives": [
{
"code": "maintenance",
"label": "تغذیه نگهدارنده",
"score": 72.0,
"expected_yield_index": 78.0,
"fertilizer_type": "20-20-20",
"amount_kg_per_ha": 45.0,
"application_method": "کودآبیاری",
"timing": "صبح زود",
"reasoning": ["برای نگهداری تعادل تغذیه ای گزینه سبک تری است."],
}
],
}
@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_uses_farm_relations_when_request_omits_names(
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,
):
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["plan"]["frequencyPerWeek"], 3)
self.assertEqual(result["plan"]["bestTimeOfDay"], "اوایل صبح")
mock_build_rag_context.assert_called_once()
mock_build_plant_text.assert_called_once_with("گوجه‌فرنگی", "میوه‌دهی")
mock_build_irrigation_method_text.assert_called_once_with("آبیاری قطره‌ای")
self.assertEqual(
result["selected_irrigation_method"]["name"],
"آبیاری قطره‌ای",
)
self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic")
self.assertEqual(result["timeline"][0]["title"], "بازبینی")
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={})
@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_persists_selected_method_on_farm(
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,
):
sprinkler = IrrigationMethod.objects.create(name="بارانی")
self.farm.irrigation_method = None
self.farm.save(update_fields=["irrigation_method", "updated_at"])
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="میوه‌دهی",
irrigation_method_name="بارانی",
)
self.farm.refresh_from_db()
self.assertEqual(self.farm.irrigation_method_id, sprinkler.id)
self.assertEqual(result["selected_irrigation_method"]["id"], sprinkler.id)
mock_build_irrigation_method_text.assert_called_once_with("بارانی")
self.assertEqual(result["plan"]["warning"], "بررسی شود")
@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_falls_back_to_optimizer_when_llm_returns_invalid_payload(
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,
):
mock_get_optimizer.return_value.optimize_irrigation.return_value = (
self.build_irrigation_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_irrigation_recommendation(
farm_uuid=str(self.farm_uuid),
growth_stage="میوه‌دهی",
)
self.assertEqual(result["plan"]["frequencyPerWeek"], 3)
self.assertEqual(result["timeline"][0]["step_number"], 1)
self.assertEqual(result["sections"][0]["type"], "warning")
self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic")
@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_farm_plant_when_request_omits_name(
self,
mock_get_chat_client,
mock_get_optimizer,
_mock_build_rag_context,
mock_build_plant_text,
):
mock_get_optimizer.return_value.optimize_fertilization.return_value = (
self.build_fertilization_optimizer_result()
)
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='{"status": "success", "data": {"primary_recommendation": {"display_title": "کود کامل 20-20-20", "reasoning": "توضیح", "summary": "مصرف انجام شود"}, "nutrient_analysis": {"macro": [{"key": "n", "name": "نیتروژن (N)", "value": 20, "unit": "percent", "description": "..."}, {"key": "p", "name": "فسفر (P)", "value": 20, "unit": "percent", "description": "..."}, {"key": "k", "name": "پتاسیم (K)", "value": 20, "unit": "percent", "description": "..."}], "micro": []}, "application_guide": {"safety_warning": "از اختلاط نامناسب خودداری شود.", "steps": [{"step_number": 1, "title": "آماده سازی", "description": "مورد 1"}]}, "alternative_recommendations": []}}'))]
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_fertilization_recommendation(
farm_uuid=str(self.farm_uuid),
growth_stage="رویشی",
)
self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20")
self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0)
mock_build_plant_text.assert_called_once_with("گوجه‌فرنگی", "رویشی")
self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic")
self.assertEqual(result["data"]["application_guide"]["safety_warning"], "از اختلاط نامناسب خودداری شود.")
@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_resolves_requested_plant_from_catalog(
self,
mock_get_chat_client,
mock_get_optimizer,
_mock_build_rag_context,
mock_build_plant_text,
):
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), "پیاز")
self.assertEqual(optimizer_call["growth_stage"], "flowering")
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")
@patch("rag.services.fertilization.get_chat_client")
def test_fertilization_recommendation_falls_back_to_optimizer_when_llm_returns_invalid_payload(
self,
mock_get_chat_client,
mock_get_optimizer,
_mock_build_rag_context,
_mock_build_plant_text,
):
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),
growth_stage="رویشی",
)
self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20")
self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0)
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from .views import (
ChatView,
)
urlpatterns = [
path("chat/", ChatView.as_view()),
]
+205
View File
@@ -0,0 +1,205 @@
"""
ساخت دیتای خاک و هواشناسی کاربر از farm_data، location_data و weather — Schema-agnostic
هر سنسور = یک کاربر. شناسایی با farm_uuid.
مدل‌های Django داخل توابع import می‌شوند تا از AppRegistryNotReady جلوگیری شود.
"""
from datetime import date
from django.db.models import Model
EXCLUDE_FIELD_NAMES = {"id", "created_at", "updated_at", "task_id", "recorded_at", "fetched_at"}
def _model_to_data_fields(instance: Model, exclude: set[str] | None = None) -> dict:
"""
استخراج فیلدهای داده از یک instance با استفاده از introspection.
تغییرات بعدی در مدل باعث شکستن نمی‌شود.
"""
exclude = exclude or set()
out: dict = {}
for f in instance._meta.get_fields():
if f.many_to_many or f.one_to_many or f.one_to_one and f.auto_created:
continue
if f.name in exclude or f.name in EXCLUDE_FIELD_NAMES:
continue
if hasattr(f, "related_model") and f.related_model:
continue # FK
try:
val = getattr(instance, f.name, None)
if val is not None:
out[f.name] = val
except Exception:
pass
return out
def build_user_soil_text(sensor_uuid: str) -> str | None:
"""
ساخت متن قابل embed برای یک سنسور (کاربر).
از SensorData → SoilLocation → latest remote sensing snapshots خوانده می‌شود.
Returns:
متن متنی قابل چانک، یا None اگر سنسور یافت نشد.
"""
from farm_data.models import SensorData
from location_data.satellite_snapshot import build_location_block_satellite_snapshots
try:
sensor = SensorData.objects.select_related("center_location").get(
farm_uuid=sensor_uuid
)
except SensorData.DoesNotExist:
return None
parts: list[str] = []
# شناسه سنسور
parts.append(f"سنسور: {sensor.farm_uuid}")
# موقعیت مزرعه
loc = sensor.center_location
parts.append(
f"موقعیت مزرعه: عرض {loc.latitude}، طول {loc.longitude}"
)
# خوانش‌های سنسور (schema-agnostic)
sensor_fields = _model_to_data_fields(
sensor, exclude={"farm_uuid", "center_location_id", "center_location", "location"}
)
if sensor_fields:
sensor_lines = [f" {k}: {v}" for k, v in sorted(sensor_fields.items())]
parts.append("خوانش‌های سنسور:\n" + "\n".join(sensor_lines))
snapshots = build_location_block_satellite_snapshots(loc)
if snapshots:
snapshot_lines = []
for snapshot in snapshots:
metrics = snapshot.get("resolved_metrics") or {}
if not metrics:
continue
lines = [f" {k}: {v}" for k, v in sorted(metrics.items())]
snapshot_lines.append(
f" بلوک {snapshot.get('block_code') or 'farm'}:\n" + "\n".join(lines)
)
if snapshot_lines:
parts.append("داده‌های ماهواره‌ای:\n" + "\n".join(snapshot_lines))
return "\n\n".join(parts) if len(parts) > 1 else None
def get_all_sensor_uuids() -> list[str]:
"""لیست همه farm_uuid های موجود."""
from farm_data.models import SensorData
return [
str(u) for u in
SensorData.objects.values_list("farm_uuid", flat=True).distinct()
]
def build_user_weather_text(sensor_uuid: str) -> str | None:
"""
ساخت متن هواشناسی قابل embed برای یک سنسور (کاربر).
پیش‌بینی ۷ روز آینده از WeatherForecast خوانده می‌شود.
Returns:
متن فارسی ساختاریافته، یا None اگر داده‌ای نباشد.
"""
from farm_data.models import SensorData
from weather.models import WeatherForecast
try:
sensor = SensorData.objects.select_related("center_location").get(
farm_uuid=sensor_uuid
)
except SensorData.DoesNotExist:
return None
loc = sensor.center_location
forecasts = (
WeatherForecast.objects.filter(
location=loc,
forecast_date__gte=date.today(),
)
.order_by("forecast_date")[:7]
)
if not forecasts:
return None
parts: list[str] = []
parts.append(f"پیش‌بینی هواشناسی سنسور {sensor_uuid} (موقعیت: {loc.latitude}, {loc.longitude})")
for fc in forecasts:
fc_data = _model_to_data_fields(
fc, exclude={"location", "location_id", "forecast_date"}
)
lines = [f" {k}: {v}" for k, v in sorted(fc_data.items())]
day_text = f" تاریخ {fc.forecast_date}:\n" + "\n".join(lines)
parts.append(day_text)
return "\n\n".join(parts) if len(parts) > 1 else None
def load_user_sources() -> list[tuple[str, str]]:
"""
بارگذاری منابع دیتای کاربران از DB (خاک + هواشناسی).
Returns: [(source_id, content), ...]
source_id = user:{uuid} یا weather:{uuid}
"""
uuids = get_all_sensor_uuids()
sources: list[tuple[str, str]] = []
for uid in uuids:
text = build_user_soil_text(str(uid))
if text and text.strip():
sources.append((f"user:{uid}", text))
weather_text = build_user_weather_text(str(uid))
if weather_text and weather_text.strip():
sources.append((f"weather:{uid}", weather_text))
return sources
def build_plant_text(plant_name: str, growth_stage: str) -> str | None:
"""
ساخت متن اطلاعات گیاه از snapshotهای `farm_data` برای استفاده در context LLM.
"""
from farm_data.models import PlantCatalogSnapshot
from farm_data.services import build_plant_text_from_snapshot
plant = PlantCatalogSnapshot.objects.filter(name=plant_name).first()
if not plant:
return None
return build_plant_text_from_snapshot(plant, growth_stage)
def build_irrigation_method_text(method_name: str) -> str | None:
"""
ساخت متن مشخصات روش آبیاری از جدول IrrigationMethod برای استفاده در context LLM.
"""
from irrigation.models import IrrigationMethod
method = IrrigationMethod.objects.filter(name=method_name).first()
if not method:
return None
lines = [f"روش آبیاری: {method.name}"]
if method.category:
lines.append(f"دسته‌بندی: {method.category}")
if method.description:
lines.append(f"توضیحات: {method.description}")
if method.water_efficiency_percent is not None:
lines.append(f"راندمان مصرف آب: {method.water_efficiency_percent}%")
if method.water_pressure_required:
lines.append(f"فشار مورد نیاز: {method.water_pressure_required}")
if method.flow_rate:
lines.append(f"دبی جریان: {method.flow_rate}")
if method.coverage_area:
lines.append(f"مساحت پوشش: {method.coverage_area}")
if method.soil_type:
lines.append(f"نوع خاک مناسب: {method.soil_type}")
if method.climate_suitability:
lines.append(f"اقلیم مناسب: {method.climate_suitability}")
return "\n".join(lines)
+172
View File
@@ -0,0 +1,172 @@
"""
Qdrant Vector Store — ذخیره و جستجوی وکتورها
"""
from qdrant_client import QdrantClient
from qdrant_client.http import models as qmodels
from .client import get_qdrant_client
from .config import load_rag_config, RAGConfig
class QdrantVectorStore:
"""
ذخیره و جستجوی documents در Qdrant.
"""
def __init__(self, config: RAGConfig | None = None):
self.config = config or load_rag_config()
self.qdrant = self.config.qdrant
self._client: QdrantClient | None = None
@property
def client(self) -> QdrantClient:
if self._client is None:
self._client = get_qdrant_client(self.qdrant)
return self._client
def ensure_collection(self, recreate: bool = False) -> None:
"""
اطمینان از وجود collection با نام و اندازه مناسب.
"""
name = self.qdrant.collection_name
size = self.qdrant.vector_size
try:
self.client.get_collection(name)
if recreate:
self.client.delete_collection(name)
self.client.create_collection(
collection_name=name,
vectors_config=qmodels.VectorParams(
size=size,
distance=qmodels.Distance.COSINE,
),
)
except Exception:
self.client.create_collection(
collection_name=name,
vectors_config=qmodels.VectorParams(
size=size,
distance=qmodels.Distance.COSINE,
),
)
def add_documents(
self,
ids: list[str],
embeddings: list[list[float]],
documents: list[str],
metadatas: list[dict] | None = None,
) -> int:
"""
افزودن documents به collection.
metadata فقط str, int, float, bool پشتیبانی می‌شود.
"""
self.ensure_collection()
metas = metadatas or [{}] * len(ids)
def _serialize(m: dict) -> dict:
out = {}
for k, v in m.items():
if v is None:
continue
if isinstance(v, (str, int, float, bool)):
out[k] = v
else:
out[k] = str(v)
return out
payloads = [
{"text": doc, "doc_id": sid, **_serialize(m)}
for doc, m, sid in zip(documents, metas, ids)
]
self.client.upsert(
collection_name=self.qdrant.collection_name,
points=[
qmodels.PointStruct(id=pid, vector=emb, payload=pl)
for pid, emb, pl in zip(ids, embeddings, payloads)
],
)
return len(ids)
def search(
self,
query_vector: list[float],
limit: int = 5,
score_threshold: float | None = None,
sensor_uuid: str | None = None,
kb_name: str | None = None,
sensor_uuids: list[str] | None = None,
kb_names: list[str] | None = None,
) -> list[dict]:
"""
جستجوی شباهت بر اساس query vector.
روی نسخه‌های جدید از query_points و روی نسخه‌های قدیمی‌تر از search استفاده می‌کند.
sensor_uuid: اجباری — فقط chunks مربوط به این سنسور یا __global__ برگردانده می‌شود.
kb_name: اختیاری — فیلتر بر اساس پایگاه دانش (chat/irrigation/fertilization).
اگر مشخص شود، فقط chunks همان KB و __all__ برگردانده می‌شود.
"""
must_conditions = []
sensor_values = [value for value in (sensor_uuids or ([sensor_uuid] if sensor_uuid else [])) if value]
if sensor_values:
must_conditions.append(
qmodels.Filter(
should=[
qmodels.FieldCondition(
key="sensor_uuid",
match=qmodels.MatchValue(value=value),
)
for value in sensor_values
]
)
)
kb_values = [value for value in (kb_names or ([kb_name] if kb_name else [])) if value]
if kb_values:
must_conditions.append(
qmodels.Filter(
should=[
qmodels.FieldCondition(
key="kb_name",
match=qmodels.MatchValue(value=value),
)
for value in kb_values
]
)
)
query_filter = None
if must_conditions:
query_filter = qmodels.Filter(must=must_conditions)
if hasattr(self.client, "query_points"):
response = self.client.query_points(
collection_name=self.qdrant.collection_name,
query=query_vector,
limit=limit,
score_threshold=score_threshold,
query_filter=query_filter,
)
points = getattr(response, "points", []) or []
else:
points = self.client.search(
collection_name=self.qdrant.collection_name,
query_vector=query_vector,
limit=limit,
score_threshold=score_threshold,
query_filter=query_filter,
)
return [
{
"id": str(r.id),
"score": float(r.score) if r.score is not None else 0.0,
"text": (r.payload or {}).get("text", ""),
"metadata": {
k: v for k, v in (r.payload or {}).items() if k != "text"
},
}
for r in points
]
+205
View File
@@ -0,0 +1,205 @@
"""
ویوهای RAG — چت با استریم
"""
import json
import logging
from django.http import StreamingHttpResponse
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiExample,
OpenApiResponse,
extend_schema,
inline_serializer,
)
from rest_framework import status
from rest_framework import serializers as drf_serializers
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from config.openapi import (
build_envelope_serializer,
build_message_response_serializer,
build_response,
)
from .chat import chat_rag_stream, encode_uploaded_image
logger = logging.getLogger(__name__)
RagChatErrorResponseSerializer = build_message_response_serializer(
"RagChatErrorResponseSerializer"
)
RagValidationErrorResponseSerializer = build_envelope_serializer(
"RagValidationErrorResponseSerializer",
data_required=False,
allow_null=True,
)
class ChatView(APIView):
parser_classes = [JSONParser, MultiPartParser, FormParser]
def _parse_history(self, raw_history):
if raw_history in (None, "", []):
return []
if isinstance(raw_history, list):
return raw_history
if isinstance(raw_history, str):
try:
parsed = json.loads(raw_history)
except (json.JSONDecodeError, ValueError):
raise ValueError("history باید JSON معتبر باشد.")
if not isinstance(parsed, list):
raise ValueError("history باید آرایه باشد.")
return parsed
raise ValueError("history فرمت پشتیبانی شده ندارد.")
def _collect_uploaded_images(self, request: Request):
images = []
for uploaded in request.FILES.getlist("images"):
images.append(encode_uploaded_image(uploaded))
single_image = request.FILES.get("image")
if single_image is not None:
images.append(encode_uploaded_image(single_image))
image_urls = request.data.get("image_urls")
if isinstance(image_urls, str) and image_urls.strip():
try:
parsed_urls = json.loads(image_urls)
except (json.JSONDecodeError, ValueError):
parsed_urls = [image_urls]
image_urls = parsed_urls
if isinstance(image_urls, list):
for item in image_urls:
if isinstance(item, str) and item.strip():
images.append({"url": item.strip(), "detail": "auto"})
elif isinstance(item, dict) and isinstance(item.get("url"), str):
image_payload = {"url": item["url"].strip(), "detail": item.get("detail", "auto")}
images.append(image_payload)
return images
@extend_schema(
tags=["RAG Chat"],
summary="چت RAG با استریم",
description="پیام کاربر را دریافت و پاسخ را به صورت استریم برمی‌گرداند.",
request=inline_serializer(
name="ChatRequest",
fields={
"query": drf_serializers.CharField(required=False, help_text="متن سوال کاربر"),
"message": drf_serializers.CharField(required=False, help_text="نام قبلی فیلد query"),
"farm_uuid": drf_serializers.CharField(help_text="شناسه مزرعه"),
"history": drf_serializers.JSONField(required=False, help_text="آرایه پیام های قبلی با role=user/assistant"),
"image_urls": drf_serializers.JSONField(required=False, help_text="آرایه URL تصاویر برای پیام فعلی"),
"image": drf_serializers.FileField(required=False, help_text="یک تصویر برای پیام فعلی"),
"images": drf_serializers.ListField(
child=drf_serializers.FileField(),
required=False,
help_text="چند تصویر برای پیام فعلی",
),
},
),
responses={
200: OpenApiResponse(
response=OpenApiTypes.STR,
description="پاسخ استریم متنی (text/plain)",
),
400: build_response(
RagChatErrorResponseSerializer,
"پارامترهای ورودی نامعتبر هستند.",
),
404: build_response(
RagChatErrorResponseSerializer,
"مزرعه پیدا نشد.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"query": "وضعیت مزرعه من چطور است؟",
"history": [
{"role": "user", "content": "رطوبت خاک من پایین بود؟"},
{"role": "assistant", "content": "بله، رطوبت خاک کمتر از محدوده مطلوب بود."},
],
"image_urls": ["https://example.com/farm-photo.jpg"],
},
request_only=True,
),
],
)
def post(self, request: Request):
from farm_data.services import get_farm_details
from .config import load_rag_config
data = request.data if request.method == "POST" else request.query_params
message = data.get("query", data.get("message"))
farm_uuid = data.get("farm_uuid")
raw_history = data.get("history")
try:
images = self._collect_uploaded_images(request)
except ValueError as exc:
return Response(
{"code": 400, "msg": str(exc)},
status=status.HTTP_400_BAD_REQUEST,
)
if message is None and images:
message = "لطفا تصویر ارسالی را در کنار اطلاعات مزرعه بررسی کن."
if not message or not isinstance(message, str):
return Response(
{"code": 400, "msg": "پارامتر query الزامی است، مگر اینکه تصویر ارسال شده باشد."},
status=status.HTTP_400_BAD_REQUEST,
)
message = str(message).strip()
if not message:
return Response(
{"code": 400, "msg": "پیام نباید خالی باشد."},
status=status.HTTP_400_BAD_REQUEST,
)
if not farm_uuid or not isinstance(farm_uuid, str):
return Response(
{"code": 400, "msg": "پارامتر farm_uuid الزامی است."},
status=status.HTTP_400_BAD_REQUEST,
)
farm_uuid = str(farm_uuid).strip()
if not farm_uuid:
return Response(
{"code": 400, "msg": "farm_uuid نباید خالی باشد."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
history = self._parse_history(raw_history)
except ValueError as exc:
return Response(
{"code": 400, "msg": str(exc)},
status=status.HTTP_400_BAD_REQUEST,
)
cfg = load_rag_config()
farm_details = get_farm_details(farm_uuid)
if farm_details is None:
return Response(
{"code": 404, "msg": "farm پیدا نشد."},
status=status.HTTP_404_NOT_FOUND,
)
def generate():
try:
for chunk in chat_rag_stream(
message,
farm_uuid=farm_uuid,
config=cfg,
farm_details=farm_details,
history=history,
images=images,
):
yield chunk
except Exception as e:
yield f"\n[خطا: {e}]"
return StreamingHttpResponse(
generate(),
content_type="text/plain; charset=utf-8",
)