first commit
This commit is contained in:
+393
@@ -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)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Adapter Pattern برای API providers — سوئیچ بین GapGPT و Avalai
|
||||
تنظیمات فعلی: GapGPT بهعنوان provider اصلی
|
||||
Avalai بهعنوان fallback نگه داشته شده.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from .config import RAGConfig, load_rag_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_embedding_client(config: RAGConfig | None = None) -> OpenAI:
|
||||
"""
|
||||
ساخت کلاینت OpenAI برای Embedding بر اساس provider فعال.
|
||||
provider از config.embedding.provider خوانده میشود: "gapgpt" یا "avalai"
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
emb = cfg.embedding
|
||||
logger.info(emb.provider)
|
||||
|
||||
if emb.provider == "avalai":
|
||||
env_var = emb.avalai_api_key_env or emb.api_key_env or "AVALAI_API_KEY"
|
||||
api_key = os.environ.get(env_var)
|
||||
base_url = emb.avalai_base_url or emb.base_url or "https://api.avalai.ir/v1"
|
||||
else:
|
||||
env_var = emb.api_key_env or "GAPGPT_API_KEY"
|
||||
api_key = os.environ.get(env_var)
|
||||
base_url = emb.base_url or "https://api.gapgpt.app/v1"
|
||||
logger.info(api_key+" "+base_url)
|
||||
|
||||
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.embedding.provider خوانده میشود (مشترک بین embedding و chat).
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
llm = cfg.llm
|
||||
provider = cfg.embedding.provider
|
||||
|
||||
|
||||
logger.info(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(api_key,base_url)
|
||||
|
||||
return OpenAI(api_key=api_key, base_url=base_url)
|
||||
+92
-26
@@ -1,63 +1,108 @@
|
||||
"""
|
||||
چت RAG با استریم — استفاده از دیتای embed شده کاربر و Avalai API
|
||||
چت RAG با استریم — استفاده از دیتای embed شده کاربر و Adapter API (GapGPT / Avalai)
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from .config import load_rag_config, RAGConfig
|
||||
from .api_provider import get_chat_client
|
||||
from .retrieve import search_with_query
|
||||
from .user_data import build_user_soil_text
|
||||
from .user_data import build_user_soil_text, build_user_weather_text
|
||||
|
||||
|
||||
def _get_chat_client(config: RAGConfig | None) -> OpenAI:
|
||||
"""ساخت کلاینت OpenAI برای Avalai Chat API."""
|
||||
cfg = config or load_rag_config()
|
||||
llm = cfg.llm
|
||||
env_var = llm.api_key_env or "AVALAI_API_KEY"
|
||||
api_key = os.environ.get(env_var)
|
||||
base_url = llm.base_url or os.environ.get(
|
||||
"AVALAI_BASE_URL", "https://api.avalai.ir/v1"
|
||||
)
|
||||
return OpenAI(api_key=api_key, base_url=base_url)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_tone(config: RAGConfig | None) -> str:
|
||||
"""بارگذاری فایل لحن."""
|
||||
"""بارگذاری فایل لحن پیشفرض (chat KB)."""
|
||||
cfg = config or load_rag_config()
|
||||
base = Path(__file__).resolve().parent.parent
|
||||
tone_path = base / cfg.tone_file
|
||||
if tone_path.exists():
|
||||
return tone_path.read_text(encoding="utf-8").strip()
|
||||
chat_kb = cfg.knowledge_bases.get("chat")
|
||||
if chat_kb:
|
||||
tone_path = base / chat_kb.tone_file
|
||||
logger.debug("Loading default tone from path=%s", tone_path)
|
||||
if tone_path.exists():
|
||||
logger.debug("Default tone file found: %s", tone_path)
|
||||
return tone_path.read_text(encoding="utf-8").strip()
|
||||
logger.warning("Default tone file not found: %s", tone_path)
|
||||
return ""
|
||||
|
||||
|
||||
def _load_kb_tone(kb_name: str, config: RAGConfig | None = None) -> str:
|
||||
"""بارگذاری فایل لحن مخصوص یک پایگاه دانش."""
|
||||
cfg = config or load_rag_config()
|
||||
kb_cfg = cfg.knowledge_bases.get(kb_name)
|
||||
if not kb_cfg:
|
||||
return ""
|
||||
base = Path(__file__).resolve().parent.parent
|
||||
tone_path = base / kb_cfg.tone_file
|
||||
logger.debug("Loading kb tone for kb=%s path=%s", kb_name, tone_path)
|
||||
if tone_path.exists():
|
||||
logger.debug("KB tone file found for kb=%s", kb_name)
|
||||
return tone_path.read_text(encoding="utf-8").strip()
|
||||
logger.warning("KB tone file not found for kb=%s path=%s", kb_name, tone_path)
|
||||
return ""
|
||||
|
||||
|
||||
def _detect_kb_intent(query: str) -> str:
|
||||
"""تشخیص ساده نوع پایگاه دانش مورد نیاز از روی متن سوال."""
|
||||
q = query.lower()
|
||||
irrigation_keywords = {"آبیاری", "آب", "رطوبت", "irrigation", "water", "et0", "بارش", "خشکی"}
|
||||
fertilization_keywords = {"کود", "کودهی", "fertiliz", "npk", "ازت", "فسفر", "پتاسیم", "nitrogen", "phosphorus", "potassium"}
|
||||
if any(kw in q for kw in irrigation_keywords):
|
||||
return "irrigation"
|
||||
if any(kw in q for kw in fertilization_keywords):
|
||||
logger.info("Detected KB intent=fertilization")
|
||||
return "fertilization"
|
||||
logger.info("Detected KB intent=chat")
|
||||
return "chat"
|
||||
|
||||
|
||||
def build_rag_context(
|
||||
query: str,
|
||||
sensor_uuid: str,
|
||||
config: RAGConfig | None = None,
|
||||
limit: int = 8,
|
||||
kb_name: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
ساخت context برای LLM: دیتای فعلی خاک کاربر + متنهای مرتبط از RAG.
|
||||
دیتای کاربر همیشه اول میآید تا LLM مقادیر واقعی (مثل pH) را ببیند.
|
||||
"""
|
||||
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] = []
|
||||
|
||||
# ۱. دیتای فعلی خاک کاربر از DB — همیشه اول (برای سوالاتی مثل «pH خاک من چند است»)
|
||||
user_soil = build_user_soil_text(sensor_uuid)
|
||||
if user_soil and user_soil.strip():
|
||||
parts.append("[دادههای فعلی خاک شما]\n" + user_soil.strip())
|
||||
logger.debug("Included user soil section sensor_uuid=%s", sensor_uuid)
|
||||
else:
|
||||
logger.info("No user soil data found sensor_uuid=%s", sensor_uuid)
|
||||
|
||||
weather_text = build_user_weather_text(sensor_uuid)
|
||||
if weather_text and weather_text.strip():
|
||||
parts.append("[پیشبینی هواشناسی]\n" + weather_text.strip())
|
||||
logger.debug("Included weather section sensor_uuid=%s", sensor_uuid)
|
||||
else:
|
||||
logger.info("No weather data found sensor_uuid=%s", sensor_uuid)
|
||||
|
||||
# ۲. متنهای مرتبط از RAG
|
||||
results = search_with_query(
|
||||
query, sensor_uuid=sensor_uuid, limit=limit, config=config
|
||||
query, sensor_uuid=sensor_uuid, limit=limit, config=config,
|
||||
kb_name=kb_name,
|
||||
)
|
||||
if results:
|
||||
logger.info("Retrieved RAG results count=%s sensor_uuid=%s", len(results), sensor_uuid)
|
||||
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))
|
||||
logger.debug("Included RAG reference texts count=%s", len(rag_texts))
|
||||
else:
|
||||
logger.info("No RAG results found sensor_uuid=%s kb_name=%s", sensor_uuid, kb_name)
|
||||
|
||||
return "\n\n---\n\n".join(parts) if parts else ""
|
||||
|
||||
@@ -68,7 +113,15 @@ def chat_rag_stream(
|
||||
config: RAGConfig | None = None,
|
||||
limit: int = 5,
|
||||
system_override: str | None = None,
|
||||
kb_name: str | None = None,
|
||||
):
|
||||
logger.info(
|
||||
"chat_rag_stream started sensor_uuid=%s kb_name=%s limit=%s query_len=%s",
|
||||
sensor_uuid,
|
||||
kb_name,
|
||||
limit,
|
||||
len(query or ""),
|
||||
)
|
||||
"""
|
||||
چت RAG با استریم: دیتای embed شده را بازیابی میکند و با LLM جواب میدهد.
|
||||
فقط دیتای همان کاربر (sensor_uuid) قابل دسترسی است.
|
||||
@@ -84,19 +137,28 @@ def chat_rag_stream(
|
||||
تکتک deltaهای content بهصورت رشته
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
client = _get_chat_client(cfg)
|
||||
client = get_chat_client(cfg)
|
||||
model = cfg.llm.model
|
||||
logger.debug("Loaded RAG config with model=%s", model)
|
||||
|
||||
context = build_rag_context(query, sensor_uuid, config=cfg, limit=limit)
|
||||
detected_kb = kb_name or _detect_kb_intent(query)
|
||||
logger.info("Using knowledge base=%s", detected_kb)
|
||||
context = build_rag_context(
|
||||
query, sensor_uuid, config=cfg, limit=limit, kb_name=detected_kb,
|
||||
)
|
||||
logger.debug("Built context length=%s", len(context))
|
||||
|
||||
if system_override is not None:
|
||||
system_content = system_override
|
||||
else:
|
||||
tone = _load_tone(cfg)
|
||||
tone = _load_kb_tone(detected_kb, cfg)
|
||||
if not tone:
|
||||
tone = _load_tone(cfg)
|
||||
system_parts = [tone] if tone else []
|
||||
system_parts.append(
|
||||
"با استفاده از بخش «دادههای فعلی خاک شما» و «متنهای مرجع» زیر به سوال کاربر پاسخ بده. "
|
||||
"برای سوالاتی درباره خاک کاربر (مثل pH، رطوبت، NPK) حتماً از دادههای فعلی استفاده کن. "
|
||||
"اطلاعات هواشناسی در بخش «پیشبینی هواشناسی» آمده. "
|
||||
"پاسخ را به زبان کاربر بنویس."
|
||||
)
|
||||
if context:
|
||||
@@ -107,15 +169,19 @@ def chat_rag_stream(
|
||||
{"role": "system", "content": system_content},
|
||||
{"role": "user", "content": query},
|
||||
]
|
||||
logger.info("Prepared messages for model=%s message=%s", model,messages)
|
||||
|
||||
stream = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
stream=True,
|
||||
)
|
||||
logger.info("Started streaming response from model=%s", model)
|
||||
|
||||
for chunk in stream:
|
||||
delta = chunk.choices[0].delta if chunk.choices else None
|
||||
content = delta.content if delta else ""
|
||||
if content:
|
||||
logger.debug("Streaming chunk len=%s", len(content))
|
||||
yield content
|
||||
logger.info("chat_rag_stream completed sensor_uuid=%s", sensor_uuid)
|
||||
|
||||
+27
-5
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
بارگذاری تنظیمات RAG از rag_config.yaml
|
||||
بارگذاری تنظیمات RAG از rag_config.yaml — با پشتیبانی از چند provider و چند پایگاه دانش
|
||||
"""
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
@@ -16,6 +16,8 @@ class EmbeddingConfig:
|
||||
batch_size: int = 32
|
||||
api_key_env: str | None = None
|
||||
base_url: str | None = None
|
||||
avalai_base_url: str | None = None
|
||||
avalai_api_key_env: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -37,6 +39,15 @@ class LLMConfig:
|
||||
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
|
||||
@@ -45,8 +56,7 @@ class RAGConfig:
|
||||
qdrant: QdrantConfig
|
||||
chunking: ChunkingConfig
|
||||
llm: LLMConfig = field(default_factory=LLMConfig)
|
||||
tone_file: str = "config/tone.txt"
|
||||
knowledge_base_path: str = "config/knowledge_base"
|
||||
knowledge_bases: dict[str, KnowledgeBaseConfig] = field(default_factory=dict)
|
||||
chromadb: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -73,6 +83,8 @@ def load_rag_config(config_path: str | Path | None = None) -> RAGConfig:
|
||||
batch_size=emb.get("batch_size", 32),
|
||||
api_key_env=emb.get("api_key_env"),
|
||||
base_url=emb.get("base_url"),
|
||||
avalai_base_url=emb.get("avalai_base_url"),
|
||||
avalai_api_key_env=emb.get("avalai_api_key_env"),
|
||||
)
|
||||
|
||||
qd = data.get("qdrant", {})
|
||||
@@ -94,14 +106,24 @@ def load_rag_config(config_path: str | Path | None = None) -> RAGConfig:
|
||||
model=llm_data.get("model", "gpt-4o"),
|
||||
base_url=llm_data.get("base_url"),
|
||||
api_key_env=llm_data.get("api_key_env"),
|
||||
avalai_base_url=llm_data.get("avalai_base_url"),
|
||||
avalai_api_key_env=llm_data.get("avalai_api_key_env"),
|
||||
)
|
||||
|
||||
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", ""),
|
||||
)
|
||||
|
||||
return RAGConfig(
|
||||
embedding=embedding,
|
||||
qdrant=qdrant,
|
||||
chunking=chunking,
|
||||
llm=llm,
|
||||
tone_file=data.get("tone_file", "config/tone.txt"),
|
||||
knowledge_base_path=data.get("knowledge_base_path", "config/knowledge_base"),
|
||||
knowledge_bases=knowledge_bases,
|
||||
chromadb=data.get("chromadb", {}),
|
||||
)
|
||||
|
||||
+4
-20
@@ -1,24 +1,8 @@
|
||||
"""
|
||||
سرویس تعبیهسازی متن با Avalai API (OpenAI-compatible)
|
||||
سرویس تعبیهسازی متن — از Adapter Pattern برای سوئیچ بین providers استفاده میکند
|
||||
"""
|
||||
import os
|
||||
from typing import overload
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from .config import load_rag_config, RAGConfig
|
||||
|
||||
|
||||
def _get_avalai_client(config: RAGConfig | None) -> OpenAI:
|
||||
"""ساخت کلاینت OpenAI برای Avalai API."""
|
||||
cfg = config or load_rag_config()
|
||||
emb = cfg.embedding
|
||||
env_var = emb.api_key_env or "AVALAI_API_KEY"
|
||||
api_key = os.environ.get(env_var)
|
||||
base_url = emb.base_url or os.environ.get(
|
||||
"AVALAI_BASE_URL", "https://api.avalai.ir/v1"
|
||||
)
|
||||
return OpenAI(api_key=api_key, base_url=base_url)
|
||||
from .api_provider import get_embedding_client
|
||||
from .config import RAGConfig, load_rag_config
|
||||
|
||||
|
||||
def embed_texts(
|
||||
@@ -43,7 +27,7 @@ def embed_texts(
|
||||
return []
|
||||
|
||||
cfg = config or load_rag_config()
|
||||
client = _get_avalai_client(cfg)
|
||||
client = get_embedding_client(cfg)
|
||||
model_name = model or cfg.embedding.model
|
||||
batch_size = cfg.embedding.batch_size
|
||||
|
||||
|
||||
+49
-32
@@ -1,10 +1,10 @@
|
||||
"""
|
||||
پایپلاین ورودی RAG: خواندن، چانک، embed و ذخیره در vector store
|
||||
پایپلاین ورودی RAG: خواندن، چانک، embed و ذخیره در vector store — با پشتیبانی از چند پایگاه دانش
|
||||
|
||||
سه منبع:
|
||||
۱. لحن (tone) — sensor_uuid=__global__
|
||||
۲. پایگاه دانش (knowledge base) — sensor_uuid=__global__
|
||||
۳. دیتای خاک هر کاربر از DB (sensor_data + soil_data) — sensor_uuid=uuid
|
||||
منابع:
|
||||
۱. لحن هر پایگاه دانش (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
|
||||
@@ -12,14 +12,15 @@ from pathlib import Path
|
||||
from .chunker import chunk_text, chunk_texts
|
||||
from .config import load_rag_config, RAGConfig
|
||||
from .embedding import embed_texts
|
||||
from .user_data import load_user_sources
|
||||
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 پروژه."""
|
||||
@@ -57,49 +58,64 @@ def _load_files_from_dir(dir_path: Path, prefix: str = "kb") -> list[tuple[str,
|
||||
return out
|
||||
|
||||
|
||||
def load_sources(config: RAGConfig | None = None) -> list[tuple[str, str, str]]:
|
||||
def load_sources(
|
||||
config: RAGConfig | None = None,
|
||||
kb_name: str | None = None,
|
||||
) -> list[tuple[str, str, str, str]]:
|
||||
"""
|
||||
بارگذاری سه منبع: لحن، پایگاه دانش، دیتای کاربر از DB.
|
||||
بارگذاری منابع: لحنها، پایگاههای دانش سهگانه، دیتای کاربران.
|
||||
اگر kb_name مشخص شود، فقط آن پایگاه دانش لود میشود.
|
||||
|
||||
Returns:
|
||||
[(source_id, content, sensor_uuid), ...]
|
||||
sensor_uuid: __global__ برای tone/kb، uuid سنسور برای user
|
||||
[(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]] = []
|
||||
sources: list[tuple[str, str, str, str]] = []
|
||||
|
||||
# ۱. لحن
|
||||
tone_path = _resolve_path(base, cfg.tone_file)
|
||||
content = _load_file(tone_path)
|
||||
if content:
|
||||
sources.append(("tone", content, SENSOR_UUID_GLOBAL))
|
||||
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]
|
||||
|
||||
# ۲. پایگاه دانش
|
||||
kb_path = _resolve_path(base, cfg.knowledge_base_path)
|
||||
for sid, c in _load_files_from_dir(kb_path, prefix="kb"):
|
||||
sources.append((sid, c, SENSOR_UUID_GLOBAL))
|
||||
if kb_path.is_file():
|
||||
content = _load_file(kb_path)
|
||||
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"kb:{kb_path.name}", content, SENSOR_UUID_GLOBAL))
|
||||
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))
|
||||
|
||||
# ۳. دیتای کاربران از sensor_data + soil_data
|
||||
for sid, content in load_user_sources():
|
||||
sensor_uuid = sid.replace("user:", "")
|
||||
sources.append((sid, content, sensor_uuid))
|
||||
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) -> dict:
|
||||
def ingest(
|
||||
recreate: bool = False,
|
||||
config: RAGConfig | None = None,
|
||||
kb_name: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
ورودی کامل: منابع را میخواند، چانک میکند، embed میکند و به vector store میفرستد.
|
||||
دیتای هر کاربر (sensor_uuid) جدا embed و با metadata ذخیره میشود.
|
||||
ورودی کامل: منابع را میخواند، چانک، embed و به vector store میفرستد.
|
||||
kb_name اختیاری: اگر مشخص شود فقط آن پایگاه دانش ingest میشود.
|
||||
|
||||
Args:
|
||||
recreate: اگر True باشد، collection را از نو میسازد
|
||||
config: تنظیمات RAG
|
||||
kb_name: نام پایگاه دانش (chat/irrigation/fertilization) — اختیاری
|
||||
|
||||
Returns:
|
||||
آمار ورودی (تعداد چانک، منبعها، خطاها)
|
||||
@@ -109,7 +125,7 @@ def ingest(recreate: bool = False, config: RAGConfig | None = None) -> dict:
|
||||
if recreate:
|
||||
store.ensure_collection(recreate=True)
|
||||
|
||||
sources = load_sources(config=cfg)
|
||||
sources = load_sources(config=cfg, kb_name=kb_name)
|
||||
if not sources:
|
||||
return {"chunks_added": 0, "sources": [], "error": "هیچ منبعی یافت نشد"}
|
||||
|
||||
@@ -117,7 +133,7 @@ def ingest(recreate: bool = False, config: RAGConfig | None = None) -> dict:
|
||||
all_metas: list[dict] = []
|
||||
all_ids: list[str] = []
|
||||
|
||||
for source_id, content, sensor_uuid in sources:
|
||||
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())
|
||||
@@ -127,6 +143,7 @@ def ingest(recreate: bool = False, config: RAGConfig | None = None) -> dict:
|
||||
"source": source_id,
|
||||
"chunk_index": i,
|
||||
"sensor_uuid": sensor_uuid,
|
||||
"kb_name": src_kb,
|
||||
})
|
||||
|
||||
if not all_chunks:
|
||||
|
||||
@@ -12,13 +12,16 @@ def search_with_query(
|
||||
limit: int = 5,
|
||||
score_threshold: float | None = None,
|
||||
config: RAGConfig | None = None,
|
||||
kb_name: str | 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
|
||||
@@ -31,4 +34,5 @@ def search_with_query(
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
sensor_uuid=sensor_uuid,
|
||||
kb_name=kb_name,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
سرویسهای RAG — آبیاری و کودهی
|
||||
بدون API — قابل استفاده از سایر سرویسها
|
||||
"""
|
||||
from .irrigation import get_irrigation_recommendation
|
||||
from .fertilization import get_fertilization_recommendation
|
||||
|
||||
__all__ = [
|
||||
"get_irrigation_recommendation",
|
||||
"get_fertilization_recommendation",
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
سرویس توصیه کودهی — بدون API، قابل فراخوانی از سایر سرویسها
|
||||
از RAG با پایگاه دانش fertilization و لحن مخصوص کودهی استفاده میکند.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import build_rag_context, _load_kb_tone
|
||||
from rag.config import load_rag_config, RAGConfig
|
||||
from rag.user_data import build_plant_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KB_NAME = "fertilization"
|
||||
|
||||
DEFAULT_FERTILIZATION_PROMPT = (
|
||||
"بر اساس دادههای خاک (NPK، pH)، مشخصات گیاه، مرحله رشد و پایگاه دانش کودهی، "
|
||||
"یک توصیه کودهی دقیق بده. "
|
||||
"پاسخ حتماً به فرمت JSON با فیلدهای زیر باشد:\n"
|
||||
"fertilizer_needed (bool), fertilizer_type (str), amount_kg_per_hectare (float), "
|
||||
"reason (str), npk_status (dict با کلیدهای nitrogen, phosphorus, potassium و مقادیر low/normal/high)\n"
|
||||
"فقط JSON خروجی بده، بدون توضیح اضافی."
|
||||
)
|
||||
|
||||
|
||||
def get_fertilization_recommendation(
|
||||
sensor_uuid: str,
|
||||
plant_name: str | None = None,
|
||||
growth_stage: str | None = None,
|
||||
query: str | None = None,
|
||||
config: RAGConfig | None = None,
|
||||
limit: int = 8,
|
||||
) -> dict:
|
||||
"""
|
||||
توصیه کودهی برای یک سنسور (کاربر).
|
||||
از RAG با پایگاه دانش fertilization استفاده میکند.
|
||||
|
||||
Args:
|
||||
sensor_uuid: شناسه سنسور کاربر
|
||||
plant_name: نام گیاه (برای بارگذاری مشخصات از جدول Plant)
|
||||
growth_stage: مرحله رشد گیاه
|
||||
query: سوال اختیاری
|
||||
config: تنظیمات RAG
|
||||
limit: تعداد چانکهای بازیابیشده
|
||||
|
||||
Returns:
|
||||
dict با کلیدهای fertilizer_needed, fertilizer_type, amount_kg_per_hectare, reason, npk_status, raw_response
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
client = get_chat_client(cfg)
|
||||
model = cfg.llm.model
|
||||
|
||||
user_query = query or "توصیه کودهی برای مزرعه من چیست؟"
|
||||
|
||||
context = build_rag_context(
|
||||
user_query, sensor_uuid, config=cfg, limit=limit, kb_name=KB_NAME,
|
||||
)
|
||||
|
||||
extra_parts: list[str] = []
|
||||
if plant_name and growth_stage:
|
||||
plant_text = build_plant_text(plant_name, growth_stage)
|
||||
if plant_text:
|
||||
extra_parts.append("[اطلاعات گیاه]\n" + plant_text)
|
||||
if extra_parts:
|
||||
context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "")
|
||||
|
||||
tone = _load_kb_tone(KB_NAME, cfg)
|
||||
system_parts = [tone] if tone else []
|
||||
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},
|
||||
]
|
||||
|
||||
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", sensor_uuid, exc)
|
||||
return {
|
||||
"fertilizer_needed": None,
|
||||
"fertilizer_type": None,
|
||||
"amount_kg_per_hectare": None,
|
||||
"reason": f"خطا در دریافت توصیه: {exc}",
|
||||
"npk_status": None,
|
||||
"raw_response": None,
|
||||
}
|
||||
|
||||
try:
|
||||
cleaned = raw
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`").removeprefix("json").strip()
|
||||
result = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
result = {
|
||||
"fertilizer_needed": None,
|
||||
"fertilizer_type": None,
|
||||
"amount_kg_per_hectare": None,
|
||||
"reason": raw,
|
||||
"npk_status": None,
|
||||
}
|
||||
|
||||
result["raw_response"] = raw
|
||||
return result
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
سرویس توصیه آبیاری — بدون API، قابل فراخوانی از سایر سرویسها
|
||||
از RAG با پایگاه دانش irrigation و لحن مخصوص آبیاری استفاده میکند.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import build_rag_context, _load_kb_tone
|
||||
from rag.config import load_rag_config, RAGConfig
|
||||
from rag.user_data import build_plant_text, build_irrigation_method_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KB_NAME = "irrigation"
|
||||
|
||||
DEFAULT_IRRIGATION_PROMPT = (
|
||||
"بر اساس دادههای خاک، هواشناسی، مشخصات گیاه، روش آبیاری و پایگاه دانش آبیاری، "
|
||||
"یک توصیه آبیاری دقیق بده. "
|
||||
"پاسخ حتماً به فرمت JSON با فیلدهای زیر باشد:\n"
|
||||
"irrigation_needed (bool), amount_mm (float), reason (str), next_check_date (str)\n"
|
||||
"فقط JSON خروجی بده، بدون توضیح اضافی."
|
||||
)
|
||||
|
||||
|
||||
def get_irrigation_recommendation(
|
||||
sensor_uuid: str,
|
||||
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,
|
||||
) -> dict:
|
||||
"""
|
||||
توصیه آبیاری برای یک سنسور (کاربر).
|
||||
از RAG با پایگاه دانش irrigation استفاده میکند.
|
||||
|
||||
Args:
|
||||
sensor_uuid: شناسه سنسور کاربر
|
||||
plant_name: نام گیاه (برای بارگذاری مشخصات از جدول Plant)
|
||||
growth_stage: مرحله رشد گیاه
|
||||
irrigation_method_name: نام روش آبیاری (برای بارگذاری از جدول IrrigationMethod)
|
||||
query: سوال اختیاری
|
||||
config: تنظیمات RAG
|
||||
limit: تعداد چانکهای بازیابیشده
|
||||
|
||||
Returns:
|
||||
dict با کلیدهای irrigation_needed, amount_mm, reason, next_check_date, raw_response
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
client = get_chat_client(cfg)
|
||||
model = cfg.llm.model
|
||||
|
||||
user_query = query or "توصیه آبیاری برای مزرعه من چیست؟"
|
||||
|
||||
context = build_rag_context(
|
||||
user_query, sensor_uuid, config=cfg, limit=limit, kb_name=KB_NAME,
|
||||
)
|
||||
|
||||
extra_parts: list[str] = []
|
||||
if plant_name and growth_stage:
|
||||
plant_text = build_plant_text(plant_name, growth_stage)
|
||||
if plant_text:
|
||||
extra_parts.append("[اطلاعات گیاه]\n" + plant_text)
|
||||
if irrigation_method_name:
|
||||
method_text = build_irrigation_method_text(irrigation_method_name)
|
||||
if method_text:
|
||||
extra_parts.append("[روش آبیاری انتخابی]\n" + method_text)
|
||||
if extra_parts:
|
||||
context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "")
|
||||
|
||||
tone = _load_kb_tone(KB_NAME, cfg)
|
||||
system_parts = [tone] if tone else []
|
||||
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},
|
||||
]
|
||||
|
||||
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", sensor_uuid, exc)
|
||||
return {
|
||||
"irrigation_needed": None,
|
||||
"amount_mm": None,
|
||||
"reason": f"خطا در دریافت توصیه: {exc}",
|
||||
"next_check_date": None,
|
||||
"raw_response": None,
|
||||
}
|
||||
|
||||
try:
|
||||
cleaned = raw
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`").removeprefix("json").strip()
|
||||
result = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
result = {
|
||||
"irrigation_needed": None,
|
||||
"amount_mm": None,
|
||||
"reason": raw,
|
||||
"next_check_date": None,
|
||||
}
|
||||
|
||||
result["raw_response"] = raw
|
||||
return result
|
||||
@@ -15,3 +15,63 @@ def rag_ingest_task(recreate: bool = True):
|
||||
"""
|
||||
result = ingest(recreate=recreate)
|
||||
return result
|
||||
|
||||
|
||||
@app.task(bind=True)
|
||||
def irrigation_recommendation_task(
|
||||
self,
|
||||
sensor_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(
|
||||
sensor_uuid=sensor_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,
|
||||
sensor_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(
|
||||
sensor_uuid=sensor_uuid,
|
||||
plant_name=plant_name,
|
||||
growth_stage=growth_stage,
|
||||
query=query,
|
||||
)
|
||||
result["status"] = "completed"
|
||||
return result
|
||||
|
||||
+11
-1
@@ -1,7 +1,17 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import ChatView
|
||||
from .views import (
|
||||
ChatView,
|
||||
IrrigationRecommendationView,
|
||||
IrrigationRecommendationStatusView,
|
||||
FertilizationRecommendationView,
|
||||
FertilizationRecommendationStatusView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("chat/", ChatView.as_view()),
|
||||
path("recommend/irrigation/", IrrigationRecommendationView.as_view(), name="recommend-irrigation"),
|
||||
path("recommend/irrigation/<str:task_id>/status/", IrrigationRecommendationStatusView.as_view(), name="recommend-irrigation-status"),
|
||||
path("recommend/fertilization/", FertilizationRecommendationView.as_view(), name="recommend-fertilization"),
|
||||
path("recommend/fertilization/<str:task_id>/status/", FertilizationRecommendationStatusView.as_view(), name="recommend-fertilization-status"),
|
||||
]
|
||||
|
||||
+119
-7
@@ -1,14 +1,15 @@
|
||||
"""
|
||||
ساخت دیتای خاک کاربر از sensor_data و soil_data — Schema-agnostic
|
||||
ساخت دیتای خاک و هواشناسی کاربر از sensor_data، location_data و weather — Schema-agnostic
|
||||
هر سنسور = یک کاربر. شناسایی با uuid_sensor.
|
||||
|
||||
مدلهای Django داخل توابع import میشوند تا از AppRegistryNotReady جلوگیری شود.
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
from django.db.models import Model
|
||||
|
||||
|
||||
# فیلدهایی که در متن embed نباید بیایند (شناسهها، رابطهها)
|
||||
EXCLUDE_FIELD_NAMES = {"id", "created_at", "updated_at", "task_id", "recorded_at"}
|
||||
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:
|
||||
@@ -43,7 +44,7 @@ def build_user_soil_text(sensor_uuid: str) -> str | None:
|
||||
متن متنی قابل چانک، یا None اگر سنسور یافت نشد.
|
||||
"""
|
||||
from sensor_data.models import SensorData
|
||||
from soil_data.models import SoilDepthData
|
||||
from location_data.models import SoilDepthData
|
||||
|
||||
try:
|
||||
sensor = SensorData.objects.select_related("location").get(
|
||||
@@ -89,7 +90,7 @@ def build_user_soil_text(sensor_uuid: str) -> str | None:
|
||||
if depth_parts:
|
||||
parts.append("دادههای خاک:\n" + "\n".join(depth_parts))
|
||||
|
||||
return "\n\n".join(parts) if parts else None
|
||||
return "\n\n".join(parts) if len(parts) > 1 else None
|
||||
|
||||
|
||||
def get_all_sensor_uuids() -> list[str]:
|
||||
@@ -102,11 +103,54 @@ def get_all_sensor_uuids() -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def build_user_weather_text(sensor_uuid: str) -> str | None:
|
||||
"""
|
||||
ساخت متن هواشناسی قابل embed برای یک سنسور (کاربر).
|
||||
پیشبینی ۷ روز آینده از WeatherForecast خوانده میشود.
|
||||
|
||||
Returns:
|
||||
متن فارسی ساختاریافته، یا None اگر دادهای نباشد.
|
||||
"""
|
||||
from sensor_data.models import SensorData
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
try:
|
||||
sensor = SensorData.objects.select_related("location").get(
|
||||
uuid_sensor=sensor_uuid
|
||||
)
|
||||
except SensorData.DoesNotExist:
|
||||
return None
|
||||
|
||||
loc = sensor.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.
|
||||
بارگذاری منابع دیتای کاربران از DB (خاک + هواشناسی).
|
||||
Returns: [(source_id, content), ...]
|
||||
source_id = user:{sensor_uuid}
|
||||
source_id = user:{uuid} یا weather:{uuid}
|
||||
"""
|
||||
uuids = get_all_sensor_uuids()
|
||||
sources: list[tuple[str, str]] = []
|
||||
@@ -114,4 +158,72 @@ def load_user_sources() -> list[tuple[str, str]]:
|
||||
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:
|
||||
"""
|
||||
ساخت متن اطلاعات گیاه از جدول Plant برای استفاده در context LLM.
|
||||
"""
|
||||
from plant.models import Plant
|
||||
|
||||
plant = Plant.objects.filter(name=plant_name).first()
|
||||
if not plant:
|
||||
return None
|
||||
|
||||
lines = [
|
||||
f"نام گیاه: {plant.name}",
|
||||
f"مرحله رشد: {growth_stage}",
|
||||
]
|
||||
if plant.light:
|
||||
lines.append(f"نور مورد نیاز: {plant.light}")
|
||||
if plant.watering:
|
||||
lines.append(f"آبیاری: {plant.watering}")
|
||||
if plant.soil:
|
||||
lines.append(f"خاک مناسب: {plant.soil}")
|
||||
if plant.temperature:
|
||||
lines.append(f"دمای مناسب: {plant.temperature}")
|
||||
if plant.planting_season:
|
||||
lines.append(f"فصل کاشت: {plant.planting_season}")
|
||||
if plant.harvest_time:
|
||||
lines.append(f"زمان برداشت: {plant.harvest_time}")
|
||||
if plant.spacing:
|
||||
lines.append(f"فاصله کاشت: {plant.spacing}")
|
||||
if plant.fertilizer:
|
||||
lines.append(f"کود مناسب: {plant.fertilizer}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
+38
-12
@@ -96,27 +96,53 @@ class QdrantVectorStore:
|
||||
limit: int = 5,
|
||||
score_threshold: float | None = None,
|
||||
sensor_uuid: str | None = None,
|
||||
kb_name: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
جستجوی شباهت بر اساس query vector.
|
||||
از query_points استفاده میکند (qdrant-client >= 2.0).
|
||||
sensor_uuid: اجباری — فقط chunks مربوط به این سنسور یا __global__ برگردانده میشود.
|
||||
kb_name: اختیاری — فیلتر بر اساس پایگاه دانش (chat/irrigation/fertilization).
|
||||
اگر مشخص شود، فقط chunks همان KB و __all__ برگردانده میشود.
|
||||
"""
|
||||
query_filter = None
|
||||
must_conditions = []
|
||||
|
||||
if sensor_uuid:
|
||||
query_filter = qmodels.Filter(
|
||||
should=[
|
||||
qmodels.FieldCondition(
|
||||
key="sensor_uuid",
|
||||
match=qmodels.MatchValue(value=sensor_uuid),
|
||||
),
|
||||
qmodels.FieldCondition(
|
||||
key="sensor_uuid",
|
||||
match=qmodels.MatchValue(value="__global__"),
|
||||
),
|
||||
]
|
||||
must_conditions.append(
|
||||
qmodels.Filter(
|
||||
should=[
|
||||
qmodels.FieldCondition(
|
||||
key="sensor_uuid",
|
||||
match=qmodels.MatchValue(value=sensor_uuid),
|
||||
),
|
||||
qmodels.FieldCondition(
|
||||
key="sensor_uuid",
|
||||
match=qmodels.MatchValue(value="__global__"),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if kb_name:
|
||||
must_conditions.append(
|
||||
qmodels.Filter(
|
||||
should=[
|
||||
qmodels.FieldCondition(
|
||||
key="kb_name",
|
||||
match=qmodels.MatchValue(value=kb_name),
|
||||
),
|
||||
qmodels.FieldCondition(
|
||||
key="kb_name",
|
||||
match=qmodels.MatchValue(value="__all__"),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
query_filter = None
|
||||
if must_conditions:
|
||||
query_filter = qmodels.Filter(must=must_conditions)
|
||||
|
||||
response = self.client.query_points(
|
||||
collection_name=self.qdrant.collection_name,
|
||||
query=query_vector,
|
||||
|
||||
+297
-1
@@ -2,14 +2,25 @@
|
||||
ویوهای RAG — چت با استریم
|
||||
"""
|
||||
from django.http import StreamingHttpResponse
|
||||
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.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
import logging
|
||||
|
||||
from .chat import chat_rag_stream
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatView(APIView):
|
||||
"""
|
||||
چت RAG با استریم.
|
||||
@@ -17,11 +28,38 @@ class ChatView(APIView):
|
||||
sensor_uuid اجباری — هر کاربر فقط به دیتای خودش دسترسی دارد.
|
||||
"""
|
||||
|
||||
@extend_schema(
|
||||
tags=["RAG Chat"],
|
||||
summary="چت RAG با استریم",
|
||||
description="پیام کاربر را دریافت و پاسخ را به صورت استریم برمیگرداند.",
|
||||
request=inline_serializer(
|
||||
name="ChatRequest",
|
||||
fields={
|
||||
"message": drf_serializers.CharField(help_text="متن سوال کاربر"),
|
||||
"sensor_uuid": drf_serializers.CharField(help_text="شناسه یکتای سنسور"),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="پاسخ استریم متنی (text/plain)",
|
||||
),
|
||||
400: OpenApiResponse(
|
||||
description="پارامتر ورودی نامعتبر",
|
||||
),
|
||||
},
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"نمونه درخواست",
|
||||
value={"message": "وضعیت خاک من چطوره؟", "sensor_uuid": "550e8400-e29b-41d4-a716-446655440000"},
|
||||
request_only=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def post(self, request: Request):
|
||||
data = request.data if request.method == "POST" else request.query_params
|
||||
message = data.get("message")
|
||||
sensor_uuid = data.get("sensor_uuid")
|
||||
|
||||
logging.info("jhh")
|
||||
if not message or not isinstance(message, str):
|
||||
return Response(
|
||||
{"code": 400, "msg": "پارامتر message الزامی است."},
|
||||
@@ -44,6 +82,7 @@ class ChatView(APIView):
|
||||
{"code": 400, "msg": "sensor_uuid نباید خالی باشد."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
def generate():
|
||||
try:
|
||||
@@ -56,3 +95,260 @@ class ChatView(APIView):
|
||||
generate(),
|
||||
content_type="text/plain; charset=utf-8",
|
||||
)
|
||||
|
||||
|
||||
class IrrigationRecommendationView(APIView):
|
||||
"""
|
||||
توصیه آبیاری با Celery.
|
||||
POST با sensor_uuid، plant_name، growth_stage، irrigation_method_name.
|
||||
تسک در صف قرار میگیرد و task_id برگشت داده میشود.
|
||||
"""
|
||||
|
||||
@extend_schema(
|
||||
tags=["RAG Recommendations"],
|
||||
summary="درخواست توصیه آبیاری",
|
||||
description=(
|
||||
"دادههای سنسور، گیاه و روش آبیاری را دریافت کرده و یک تسک Celery "
|
||||
"برای تولید توصیه آبیاری در صف قرار میدهد."
|
||||
),
|
||||
request=inline_serializer(
|
||||
name="IrrigationRecommendationRequest",
|
||||
fields={
|
||||
"sensor_uuid": drf_serializers.CharField(help_text="شناسه یکتای سنسور (اجباری)"),
|
||||
"plant_name": drf_serializers.CharField(required=False, help_text="نام گیاه"),
|
||||
"growth_stage": drf_serializers.CharField(required=False, help_text="مرحله رشد گیاه"),
|
||||
"irrigation_method_name": drf_serializers.CharField(required=False, help_text="نام روش آبیاری"),
|
||||
"query": drf_serializers.CharField(required=False, help_text="سوال اختیاری"),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
202: inline_serializer(
|
||||
name="IrrigationRecommendationResponse",
|
||||
fields={
|
||||
"code": drf_serializers.IntegerField(),
|
||||
"msg": drf_serializers.CharField(),
|
||||
"data": inline_serializer(
|
||||
name="IrrigationRecommendationData",
|
||||
fields={
|
||||
"task_id": drf_serializers.CharField(),
|
||||
"status_url": drf_serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
400: OpenApiResponse(description="پارامتر ورودی نامعتبر"),
|
||||
},
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"نمونه درخواست",
|
||||
value={
|
||||
"sensor_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"plant_name": "گوجهفرنگی",
|
||||
"growth_stage": "میوهدهی",
|
||||
"irrigation_method_name": "آبیاری قطرهای",
|
||||
},
|
||||
request_only=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def post(self, request: Request):
|
||||
from rag.tasks import irrigation_recommendation_task
|
||||
|
||||
sensor_uuid = request.data.get("sensor_uuid")
|
||||
if not sensor_uuid:
|
||||
return Response(
|
||||
{"code": 400, "msg": "پارامتر sensor_uuid الزامی است.", "data": None},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
task = irrigation_recommendation_task.delay(
|
||||
sensor_uuid=str(sensor_uuid),
|
||||
plant_name=request.data.get("plant_name"),
|
||||
growth_stage=request.data.get("growth_stage"),
|
||||
irrigation_method_name=request.data.get("irrigation_method_name"),
|
||||
query=request.data.get("query"),
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"code": 202,
|
||||
"msg": "تسک توصیه آبیاری در صف قرار گرفت.",
|
||||
"data": {
|
||||
"task_id": task.id,
|
||||
"status_url": f"/api/rag/recommend/irrigation/{task.id}/status/",
|
||||
},
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
|
||||
class IrrigationRecommendationStatusView(APIView):
|
||||
"""وضعیت تسک توصیه آبیاری."""
|
||||
|
||||
@extend_schema(
|
||||
tags=["RAG Recommendations"],
|
||||
summary="وضعیت تسک توصیه آبیاری",
|
||||
description="وضعیت تسک Celery توصیه آبیاری را برمیگرداند.",
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="IrrigationRecommendationStatusResponse",
|
||||
fields={
|
||||
"code": drf_serializers.IntegerField(),
|
||||
"msg": drf_serializers.CharField(),
|
||||
"data": inline_serializer(
|
||||
name="IrrigationRecommendationStatusData",
|
||||
fields={
|
||||
"task_id": drf_serializers.CharField(),
|
||||
"status": drf_serializers.CharField(),
|
||||
"result": drf_serializers.JSONField(required=False),
|
||||
"progress": drf_serializers.DictField(required=False),
|
||||
"error": drf_serializers.CharField(required=False),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, request, task_id):
|
||||
from celery.result import AsyncResult
|
||||
|
||||
result = AsyncResult(task_id)
|
||||
data = {"task_id": task_id, "status": result.state}
|
||||
if result.state == "PENDING":
|
||||
data["message"] = "تسک در صف یا یافت نشد."
|
||||
elif result.state == "PROGRESS":
|
||||
data["progress"] = result.info
|
||||
elif result.state == "SUCCESS":
|
||||
data["result"] = result.result
|
||||
elif result.state == "FAILURE":
|
||||
data["error"] = str(result.result)
|
||||
return Response(
|
||||
{"code": 200, "msg": "success", "data": data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class FertilizationRecommendationView(APIView):
|
||||
"""
|
||||
توصیه کودهی با Celery.
|
||||
POST با sensor_uuid، plant_name، growth_stage.
|
||||
تسک در صف قرار میگیرد و task_id برگشت داده میشود.
|
||||
"""
|
||||
|
||||
@extend_schema(
|
||||
tags=["RAG Recommendations"],
|
||||
summary="درخواست توصیه کودهی",
|
||||
description=(
|
||||
"دادههای سنسور و گیاه را دریافت کرده و یک تسک Celery "
|
||||
"برای تولید توصیه کودهی در صف قرار میدهد."
|
||||
),
|
||||
request=inline_serializer(
|
||||
name="FertilizationRecommendationRequest",
|
||||
fields={
|
||||
"sensor_uuid": drf_serializers.CharField(help_text="شناسه یکتای سنسور (اجباری)"),
|
||||
"plant_name": drf_serializers.CharField(required=False, help_text="نام گیاه"),
|
||||
"growth_stage": drf_serializers.CharField(required=False, help_text="مرحله رشد گیاه"),
|
||||
"query": drf_serializers.CharField(required=False, help_text="سوال اختیاری"),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
202: inline_serializer(
|
||||
name="FertilizationRecommendationResponse",
|
||||
fields={
|
||||
"code": drf_serializers.IntegerField(),
|
||||
"msg": drf_serializers.CharField(),
|
||||
"data": inline_serializer(
|
||||
name="FertilizationRecommendationData",
|
||||
fields={
|
||||
"task_id": drf_serializers.CharField(),
|
||||
"status_url": drf_serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
400: OpenApiResponse(description="پارامتر ورودی نامعتبر"),
|
||||
},
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"نمونه درخواست",
|
||||
value={
|
||||
"sensor_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"plant_name": "گوجهفرنگی",
|
||||
"growth_stage": "رویشی",
|
||||
},
|
||||
request_only=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def post(self, request: Request):
|
||||
from rag.tasks import fertilization_recommendation_task
|
||||
|
||||
sensor_uuid = request.data.get("sensor_uuid")
|
||||
if not sensor_uuid:
|
||||
return Response(
|
||||
{"code": 400, "msg": "پارامتر sensor_uuid الزامی است.", "data": None},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
task = fertilization_recommendation_task.delay(
|
||||
sensor_uuid=str(sensor_uuid),
|
||||
plant_name=request.data.get("plant_name"),
|
||||
growth_stage=request.data.get("growth_stage"),
|
||||
query=request.data.get("query"),
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"code": 202,
|
||||
"msg": "تسک توصیه کودهی در صف قرار گرفت.",
|
||||
"data": {
|
||||
"task_id": task.id,
|
||||
"status_url": f"/api/rag/recommend/fertilization/{task.id}/status/",
|
||||
},
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
|
||||
class FertilizationRecommendationStatusView(APIView):
|
||||
"""وضعیت تسک توصیه کودهی."""
|
||||
|
||||
@extend_schema(
|
||||
tags=["RAG Recommendations"],
|
||||
summary="وضعیت تسک توصیه کودهی",
|
||||
description="وضعیت تسک Celery توصیه کودهی را برمیگرداند.",
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="FertilizationRecommendationStatusResponse",
|
||||
fields={
|
||||
"code": drf_serializers.IntegerField(),
|
||||
"msg": drf_serializers.CharField(),
|
||||
"data": inline_serializer(
|
||||
name="FertilizationRecommendationStatusData",
|
||||
fields={
|
||||
"task_id": drf_serializers.CharField(),
|
||||
"status": drf_serializers.CharField(),
|
||||
"result": drf_serializers.JSONField(required=False),
|
||||
"progress": drf_serializers.DictField(required=False),
|
||||
"error": drf_serializers.CharField(required=False),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
def get(self, request, task_id):
|
||||
from celery.result import AsyncResult
|
||||
|
||||
result = AsyncResult(task_id)
|
||||
data = {"task_id": task_id, "status": result.state}
|
||||
if result.state == "PENDING":
|
||||
data["message"] = "تسک در صف یا یافت نشد."
|
||||
elif result.state == "PROGRESS":
|
||||
data["progress"] = result.info
|
||||
elif result.state == "SUCCESS":
|
||||
data["result"] = result.result
|
||||
elif result.state == "FAILURE":
|
||||
data["error"] = str(result.result)
|
||||
return Response(
|
||||
{"code": 200, "msg": "success", "data": data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user