This commit is contained in:
2026-04-24 01:23:56 +03:30
parent 5acee1fa2c
commit 31f4bf5d38
16 changed files with 518 additions and 192 deletions
+7 -3
View File
@@ -1,21 +1,25 @@
# تنظیمات RAG برای پایگاه دانش CropLogic
embedding:
provider: "gapgpt" # gapgpt یا avalai
model: "text-embedding-3-small"
provider: "arvancloud" # gapgpt یا avalai یا arvancloud
model: "Bge-m3-smka5"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
batch_size: 32
# تنظیمات Avalai (برای fallback)
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
# تنظیمات ArvanCloud AI برای BGE-M3
arvancloud_api_key: "7c4c4eb9-5183-530a-b589-d31c79472847"
arvancloud_base_url: "https://arvancloudai.ir/gateway/models/Bge-m3/rBA2PgcTC2sfhXwamupI4NvQ8crddUGTYXOsuKVye91PoNuGhbRgpHHNY8sMHBVQWWerZSAi4a0AijUL6YBqY9EW-Y1LhW_0ec6Mxr85GQy41lXiV6M8Od4mvLIeDF-wLRUHIervod0O5ZqGj2MOX8z1zdUpXkCrIS2uDjHlfHBZofledZjsOVDmFZU7IYfvkA__ljQqNeKXSFgpwUR7SmsbRUXGTDB2moLdeRq9zBpQIw/v1"
arvancloud_api_key_env: "ARVANCLOUD_EMBEDDING_API_KEY"
# فاز یک: Qdrant به‌عنوان vector store
qdrant:
host: "localhost" # یا qdrant در Docker
port: 6333
collection_name: "croplogic_kb"
vector_size: 1536 # متناسب با text-embedding-3-small
vector_size: 1024 # متناسب با BGE-M3
chunking:
max_chunk_tokens: 500
+38 -6
View File
@@ -1,7 +1,39 @@
# فایل لحن / سبک پاسخ‌های RAG
**قالب خروجی (Output Format):**
شما موظف هستید پاسخ خود را **فقط و فقط** در قالب یک شیء JSON معتبر برگردانید. هیچ متن اضافی قبل یا بعد از JSON نباید وجود داشته باشد. ساختار JSON باید به شکل زیر باشد:
لحن و سبک پاسخ‌ها:
- سطح: دوستانه و تخصصی؛ با کشاورز به زبان ساده و علمی صحبت کن.
- واژگان: از اصطلاحات رایج کشاورزی و خاک‌شناسی استفاده کن، در صورت نیاز معادل فارسی بیاور.
- طول: پاسخ‌ها مختصر و کاربردی؛ در صورت لزوم با بولت یا شماره ساختاربندی کن.
- هشدار: اگر موضوع ایمنی یا سلامتی گیاه/خاک باشد، صریحاً هشدار بده.
{
"content": "متن کلی و دوستانه پاسخ به کشاورز",
"sections": [
// نکته بسیار مهم: این آرایه می‌تواند شامل یک، دو یا هر سه نوع بخش زیر باشد. هر بخش را **فقط و فقط در صورتی** اضافه کن که برای پاسخ به سوال کشاورز ضروری و مرتبط باشد:
// ۱. فقط در صورت نیاز به ارائه توصیه یا برنامه اجرایی:
{
"type": "recommendation",
"title": "عنوان توصیه یا برنامه (مثلاً برنامه آبیاری یا یک توصیه کلی)",
"icon": "نام آیکون مناسب مثل droplet یا sprout",
"content": "در صورتی که توصیه فقط یک متن ساده است، آن را اینجا بنویسید (اختیاری)",
"frequency": "میزان تکرار (اختیاری - فقط اگر برنامه دقیق است)",
"amount": "مقدار مصرف (اختیاری - فقط اگر برنامه دقیق است)",
"timing": "زمان‌بندی مناسب (اختیاری - فقط اگر برنامه دقیق است)",
"expandableExplanation": "توضیح علمی و ساده برای این توصیه (اختیاری)"
},
// ۲. فقط در صورت وجود نکات مهم که باید لیست شوند:
{
"type": "list",
"title": "عنوان لیست",
"icon": "نام آیکون مناسب",
"items": ["نکته اول", "نکته دوم"]
},
// ۳. فقط در صورت وجود خطر برای گیاه/خاک و نیاز به هشدار:
{
"type": "warning",
"title": "عنوان هشدار",
"icon": "نام آیکون مثل alert-triangle",
"content": "متن صریح و هشداردهنده در مورد خطر موجود"
}
]
}
**قانون مهم:** در بخش `recommendation`، اگر توصیه شما صرفاً یک متن ساده است، فقط از فیلدهای `title`، `icon` و `content` استفاده کنید. اما اگر یک برنامه دقیق است، می‌توانید از فیلدهای `frequency`، `amount` و `timing` استفاده کنید. فیلدهای خالی یا نامرتبط را از JSON حذف نکنید، بلکه مقدار آن‌ها را null قرار دهید یا به کلی از شیء حذف کنید (حذف کردن فیلدهای غیرضروری ترجیح داده می‌شود).
+1 -1
View File
@@ -24,7 +24,7 @@ services:
PMA_PORT: 3306
UPLOAD_LIMIT: 64M
ports:
- "8082:80"
- "8083:80"
depends_on:
db:
condition: service_healthy
+122
View File
@@ -0,0 +1,122 @@
2026-04-06 11:26:32,124 [INFO] django.utils.autoreload: /app/location_data/urls.py changed, reloading.
2026-04-06 11:26:34,398 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 11:39:14,251 [INFO] django.utils.autoreload: /app/sensor_data/urls.py changed, reloading.
2026-04-06 11:39:16,822 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 11:42:41,500 [INFO] django.utils.autoreload: /app/sensor_data/views.py changed, reloading.
2026-04-06 11:42:43,947 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 11:43:26,823 [INFO] django.utils.autoreload: /app/sensor_data/models.py changed, reloading.
2026-04-06 11:43:29,150 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 11:45:54,258 [INFO] django.utils.autoreload: /app/sensor_data/apps.py changed, reloading.
2026-04-06 11:45:56,525 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 11:46:06,483 [INFO] django.utils.autoreload: /app/sensor_data/models.py changed, reloading.
2026-04-06 11:46:09,070 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 11:46:11,650 [INFO] django.utils.autoreload: /app/location_data/models.py changed, reloading.
2026-04-06 11:46:14,185 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:18:40,264 [INFO] django.utils.autoreload: /app/rag/user_data.py changed, reloading.
2026-04-06 12:18:41,538 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:24:14,956 [INFO] django.utils.autoreload: /app/location_data/models.py changed, reloading.
2026-04-06 12:24:16,211 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:24:42,989 [INFO] django.utils.autoreload: /app/sensor_data/models.py changed, reloading.
2026-04-06 12:24:44,253 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:25:21,172 [INFO] django.utils.autoreload: /app/sensor_data/serializers.py changed, reloading.
2026-04-06 12:25:22,430 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:27:52,025 [INFO] django.utils.autoreload: /app/sensor_data/views.py changed, reloading.
2026-04-06 12:27:53,320 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:29:13,931 [INFO] django.utils.autoreload: /app/dashboard_data/context.py changed, reloading.
2026-04-06 12:29:15,202 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:29:52,134 [INFO] django.utils.autoreload: /app/dashboard_data/services.py changed, reloading.
2026-04-06 12:29:53,502 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:30:32,458 [INFO] django.utils.autoreload: /app/dashboard_data/cards/soil_moisture_heatmap.py changed, reloading.
2026-04-06 12:30:33,743 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:31:21,793 [INFO] django.utils.autoreload: /app/rag/user_data.py changed, reloading.
2026-04-06 12:31:23,054 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:32:42,612 [INFO] django.utils.autoreload: /app/sensor_data/admin.py changed, reloading.
2026-04-06 12:32:43,862 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:37:47,634 [INFO] django.utils.autoreload: /app/sensor_data/models.py changed, reloading.
2026-04-06 12:37:48,919 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:38:37,993 [INFO] django.utils.autoreload: /app/sensor_data/serializers.py changed, reloading.
2026-04-06 12:38:39,289 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:39:26,803 [INFO] django.server: "GET /api/docs/ HTTP/1.1" 200 4633
2026-04-06 12:39:26,837 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui.css HTTP/1.1" 304 0
2026-04-06 12:39:26,839 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui-bundle.js HTTP/1.1" 304 0
2026-04-06 12:39:26,840 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui-standalone-preset.js HTTP/1.1" 304 0
2026-04-06 12:39:27,022 [INFO] django.server: "GET /api/schema/ HTTP/1.1" 200 146171
2026-04-06 12:39:27,033 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/favicon-32x32.png HTTP/1.1" 304 0
2026-04-06 12:39:31,562 [INFO] django.utils.autoreload: /app/sensor_data/views.py changed, reloading.
2026-04-06 12:39:32,855 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:40:00,782 [INFO] django.utils.autoreload: /app/sensor_data/admin.py changed, reloading.
2026-04-06 12:40:02,109 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:44:16,444 [INFO] django.utils.autoreload: /app/sensor_data/serializers.py changed, reloading.
2026-04-06 12:44:17,780 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:45:00,763 [INFO] django.utils.autoreload: /app/sensor_data/views.py changed, reloading.
2026-04-06 12:45:02,047 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:45:10,529 [INFO] django.utils.autoreload: /app/sensor_data/urls.py changed, reloading.
2026-04-06 12:45:11,817 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:51:28,201 [INFO] django.utils.autoreload: /app/config/urls.py changed, reloading.
2026-04-06 12:51:29,679 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 12:54:04,072 [INFO] django.utils.autoreload: /app/dashboard_data/cards/soil_moisture_heatmap.py changed, reloading.
2026-04-06 12:54:05,375 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 19:42:33,233 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 19:45:04,902 [INFO] django.utils.autoreload: /app/farm_data/models.py changed, reloading.
2026-04-06 19:45:07,113 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 19:49:48,250 [INFO] django.utils.autoreload: /app/location_data/models.py changed, reloading.
2026-04-06 19:49:50,545 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 19:51:47,708 [INFO] django.server: "GET /api/docs/ HTTP/1.1" 200 4633
2026-04-06 19:51:48,041 [INFO] django.server: "GET /api/schema/ HTTP/1.1" 200 151490
2026-04-06 19:52:16,066 [ERROR] django.request: Internal Server Error: /api/farm-data/11111111-1111-1111-1111-111111111111/detail/
Traceback (most recent call last):
File "/usr/local/lib/python3.10/site-packages/django/core/handlers/exception.py", line 55, in inner
response = get_response(request)
File "/usr/local/lib/python3.10/site-packages/django/core/handlers/base.py", line 197, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
File "/usr/local/lib/python3.10/site-packages/django/views/decorators/csrf.py", line 65, in _view_wrapper
return view_func(request, *args, **kwargs)
File "/usr/local/lib/python3.10/site-packages/django/views/generic/base.py", line 105, in view
return self.dispatch(request, *args, **kwargs)
File "/usr/local/lib/python3.10/site-packages/rest_framework/views.py", line 509, in dispatch
response = self.handle_exception(exc)
File "/usr/local/lib/python3.10/site-packages/rest_framework/views.py", line 469, in handle_exception
self.raise_uncaught_exception(exc)
File "/usr/local/lib/python3.10/site-packages/rest_framework/views.py", line 480, in raise_uncaught_exception
raise exc
File "/usr/local/lib/python3.10/site-packages/rest_framework/views.py", line 506, in dispatch
response = handler(request, *args, **kwargs)
File "/app/farm_data/views.py", line 257, in get
data = get_farm_details(str(farm_uuid))
File "/app/farm_data/services.py", line 47, in get_farm_details
"ideal_sensor_profile": center_location.ideal_sensor_profile,
AttributeError: 'SoilLocation' object has no attribute 'ideal_sensor_profile'
2026-04-06 19:52:16,072 [ERROR] django.server: "GET /api/farm-data/11111111-1111-1111-1111-111111111111/detail/ HTTP/1.1" 500 18299
2026-04-06 19:53:58,048 [INFO] django.utils.autoreload: /app/farm_data/services.py changed, reloading.
2026-04-06 19:54:00,346 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 19:54:30,811 [INFO] django.utils.autoreload: /app/dashboard_data/cards/sensor_radar_chart.py changed, reloading.
2026-04-06 19:54:33,051 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 19:56:59,327 [INFO] django.utils.autoreload: /app/farm_data/serializers.py changed, reloading.
2026-04-06 19:57:01,484 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 19:57:23,727 [INFO] django.utils.autoreload: /app/farm_data/services.py changed, reloading.
2026-04-06 19:57:25,926 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 19:57:52,204 [INFO] django.utils.autoreload: /app/farm_data/views.py changed, reloading.
2026-04-06 19:57:54,397 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 19:59:14,870 [INFO] django.server: "GET /api/docs/ HTTP/1.1" 200 4633
2026-04-06 19:59:15,305 [INFO] django.server: "GET /api/schema/ HTTP/1.1" 200 153398
2026-04-06 19:59:38,242 [INFO] django.utils.autoreload: /app/farm_data/services.py changed, reloading.
2026-04-06 19:59:40,459 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 20:01:11,430 [INFO] django.utils.autoreload: /app/farm_data/services.py changed, reloading.
2026-04-06 20:01:13,631 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 20:05:55,681 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 20:06:01,544 [INFO] django.server: "GET /api/docs/ HTTP/1.1" 200 4633
2026-04-06 20:06:01,906 [INFO] django.server: "GET /api/schema/ HTTP/1.1" 200 153398
2026-04-06 20:06:22,223 [INFO] django.server: "GET /api/farm-data/11111111-1111-1111-1111-111111111111/detail/ HTTP/1.1" 200 2088
2026-04-06 20:09:19,767 [INFO] django.utils.autoreload: /app/farm_data/serializers.py changed, reloading.
2026-04-06 20:09:21,982 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 20:09:27,799 [INFO] django.utils.autoreload: /app/farm_data/services.py changed, reloading.
2026-04-06 20:09:29,995 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 20:09:45,822 [INFO] django.server: "GET /api/farm-data/11111111-1111-1111-1111-111111111111/detail/ HTTP/1.1" 200 3125
2026-04-06 20:10:27,291 [INFO] django.server: "POST /api/farm-data/ HTTP/1.1" 201 407
2026-04-06 20:11:07,920 [INFO] django.server: "GET /api/farm-data/550e8400-e29b-41d4-a716-446655440000/detail/ HTTP/1.1" 200 919
2026-04-06 20:26:57,235 [INFO] django.utils.autoreload: /app/location_data/tasks.py changed, reloading.
2026-04-06 20:26:59,781 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 20:27:32,325 [INFO] django.utils.autoreload: /app/farm_data/views.py changed, reloading.
2026-04-06 20:27:34,615 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-06 21:09:58,465 [ERROR] django.request: Bad Gateway: /api/farm-data/
2026-04-06 21:09:58,465 [ERROR] django.server: "POST /api/farm-data/ HTTP/1.1" 502 654
+1
View File
@@ -0,0 +1 @@
2026-04-07 07:48:04,983 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
+3
View File
@@ -0,0 +1,3 @@
2026-04-08 16:22:28,505 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-08 16:23:17,551 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-04-08 16:25:19,939 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
+2 -27
View File
@@ -1,30 +1,5 @@
"""
ماژول RAG — پایگاه دانش CropLogic
فاز یک: Qdrant به‌عنوان vector store
ماژول RAG — برای جلوگیری از AppRegistryNotReady این فایل import سنگین انجام نمی‌دهد.
"""
from .chat import chat_rag_stream
from .chunker import chunk_text, chunk_texts
from .client import get_qdrant_client
from .config import load_rag_config
from .embedding import embed_single, embed_texts
from .ingest import ingest, load_sources
from .retrieve import search_with_query
from .user_data import build_user_soil_text, load_user_sources
from .vector_store import QdrantVectorStore
__all__ = [
"chat_rag_stream",
"chunk_text",
"chunk_texts",
"embed_single",
"embed_texts",
"get_qdrant_client",
"ingest",
"load_rag_config",
"load_sources",
"load_user_sources",
"build_user_soil_text",
"QdrantVectorStore",
"search_with_query",
]
__all__: list[str] = []
+21 -8
View File
@@ -1,7 +1,5 @@
"""
Adapter Pattern برای API providers — سوئیچ بین GapGPT و Avalai
تنظیمات فعلی: GapGPT به‌عنوان provider اصلی
Avalai به‌عنوان fallback نگه داشته شده.
Adapter Pattern برای API providers — سوئیچ بین GapGPT، Avalai و ArvanCloud AI.
"""
import logging
import os
@@ -20,22 +18,37 @@ def _mask_secret(value: str | None) -> str:
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 خوانده می‌شود: "gapgpt" یا "avalai"
provider از config.embedding.provider خوانده می‌شود.
"""
cfg = config or load_rag_config()
emb = cfg.embedding
logger.info("embedding provider=%s", emb.provider)
provider = emb.provider or "gapgpt"
logger.info("embedding provider=%s", provider)
if emb.provider == "avalai":
if 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)
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 = os.environ.get(env_var)
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",
+164 -97
View File
@@ -1,13 +1,12 @@
"""
چت RAG با استریم — استفاده از دیتای embed شده کاربر و Adapter API (GapGPT / Avalai)
چت RAG برای API چت عمومی — استفاده مستقیم از داده مزرعه بدون retrieval/embedding.
"""
import json
import logging
from pathlib import Path
from .config import load_rag_config, RAGConfig, get_service_config, ServiceConfig
from .api_provider import get_chat_client
from .retrieve import search_with_query
from .user_data import build_user_soil_text, build_user_weather_text
from .config import RAGConfig, ServiceConfig, get_service_config, load_rag_config
logger = logging.getLogger(__name__)
@@ -19,30 +18,12 @@ def _load_tone(config: RAGConfig | None) -> str:
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 _load_service_tone(service: ServiceConfig, config: RAGConfig | None = None) -> str:
cfg = config or load_rag_config()
if service.tone_file:
@@ -50,21 +31,84 @@ def _load_service_tone(service: ServiceConfig, config: RAGConfig | None = None)
tone_path = base / service.tone_file
if tone_path.exists():
return tone_path.read_text(encoding="utf-8").strip()
return _load_kb_tone(service.knowledge_base, cfg)
logger.warning("Service tone file not found: %s", tone_path)
return _load_tone(cfg)
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 _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 _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 build_rag_context(
@@ -76,9 +120,12 @@ def build_rag_context(
service_id: str | None = None,
) -> str:
"""
ساخت context برای LLM: دیتای فعلی خاک کاربر + متن‌های مرتبط از RAG.
دیتای کاربر همیشه اول می‌آید تا LLM مقادیر واقعی (مثل pH) را ببیند.
ساخت context برای سرویس‌های توصیه با استفاده از RAG قدیمی.
این تابع برای سازگاری با irrigation/fertilization حفظ شده است.
"""
from .retrieve import search_with_query
from .user_data import build_user_soil_text, build_user_weather_text
logger.info(
"Building RAG context sensor_uuid=%s kb_name=%s limit=%s query_len=%s",
sensor_uuid,
@@ -96,16 +143,10 @@ def build_rag_context(
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)
results = search_with_query(
query,
@@ -117,50 +158,35 @@ def build_rag_context(
use_user_embeddings=include_user_embeddings,
)
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 ""
def chat_rag_stream(
query: str,
sensor_uuid: str | None = None,
farm_uuid: str,
config: RAGConfig | None = None,
limit: int = 5,
system_override: str | None = None,
kb_name: str | None = None,
service_id: str | None = None,
farm_details: dict | 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) قابل دسترسی است.
چت استریمی با سرویس ثابت `chat` و context مستقیم مزرعه.
Args:
query: پیام کاربر
sensor_uuid: شناسه سنسور کاربر — اجباری
farm_uuid: شناسه مزرعه
config: تنظیمات RAG
limit: تعداد چانک‌های بازیابی‌شده
system_override: جایگزین system prompt (اختیاری)
Yields:
تک‌تک deltaهای content به‌صورت رشته
chunk های استریم پاسخ مدل
"""
cfg = config or load_rag_config()
resolved_service_id = service_id or kb_name or _detect_kb_intent(query)
service = get_service_config(resolved_service_id, cfg)
service_id = "chat"
service = get_service_config(service_id, cfg)
service_llm_config = service.llm
service_cfg = RAGConfig(
embedding=cfg.embedding,
@@ -173,56 +199,97 @@ def chat_rag_stream(
)
client = get_chat_client(service_cfg)
model = service_llm_config.model
logger.debug("Loaded service config service_id=%s model=%s", resolved_service_id, model)
detected_kb = kb_name or service.knowledge_base
logger.info("Using knowledge base=%s for service_id=%s", detected_kb, resolved_service_id)
context = build_rag_context(
query,
sensor_uuid,
config=cfg,
limit=limit,
kb_name=detected_kb,
service_id=resolved_service_id,
logger.info(
"chat_rag_stream started service_id=%s farm_uuid=%s query_len=%s",
service_id,
farm_uuid,
len(query or ""),
)
if farm_details is None:
farm_context = _format_farm_context(farm_uuid)
else:
farm_context = _format_farm_context_from_details(farm_details)
logger.info(
"Loaded farm context for farm_uuid=%s context_len=%s",
farm_uuid,
len(farm_context),
)
logger.debug("Built context length=%s", len(context))
if system_override is not None:
system_content = system_override
system_prompt = system_override
else:
tone = _load_service_tone(service, cfg)
if not tone:
tone = _load_tone(cfg)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(
"با استفاده از بخش «داده‌های فعلی خاک شما» و «متن‌های مرجع» زیر به سوال کاربر پاسخ بده. "
"برای سوالاتی درباره خاک کاربر (مثل pH، رطوبت، NPK) حتماً از داده‌های فعلی استفاده کن. "
"اطلاعات هواشناسی در بخش «پیش‌بینی هواشناسی» آمده. "
"پاسخ را به زبان کاربر بنویس."
)
if context:
system_parts.append("\n\n" + context)
system_content = "\n".join(system_parts)
system_prompt = _build_system_prompt(service, query, farm_context, cfg)
messages = [
{"role": "system", "content": system_content},
{"role": "system", "content": system_prompt},
{"role": "user", "content": query},
]
logger.info("Prepared messages for model=%s service_id=%s", model, resolved_service_id)
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 from model=%s", model)
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:
logger.debug("Streaming chunk len=%s", len(content))
response_chunks.append(content)
yield content
logger.info("chat_rag_stream completed sensor_uuid=%s", sensor_uuid)
full_response = "".join(response_chunks)
audit_log.response_text = full_response
audit_log.status = ChatAuditLog.STATUS_COMPLETED
audit_log.save(update_fields=["response_text", "status", "updated_at"])
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)
audit_log.response_text = partial_response
audit_log.error_message = str(exc)
audit_log.status = ChatAuditLog.STATUS_FAILED
audit_log.save(
update_fields=["response_text", "error_message", "status", "updated_at"]
)
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
+10
View File
@@ -15,10 +15,15 @@ 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
@@ -116,10 +121,15 @@ def load_rag_config(config_path: str | Path | None = None) -> RAGConfig:
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", {})
+3
View File
@@ -3,7 +3,9 @@
"""
from .api_provider import get_embedding_client
from .config import RAGConfig, load_rag_config
import logging
logger = logging.getLogger(__name__)
def embed_texts(
texts: list[str],
@@ -29,6 +31,7 @@ def embed_texts(
cfg = config or load_rag_config()
client = get_embedding_client(cfg)
model_name = model or cfg.embedding.model
logger.info(model_name)
batch_size = cfg.embedding.batch_size
all_embeddings: list[list[float]] = []
+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}"
+10 -1
View File
@@ -102,7 +102,7 @@ class QdrantVectorStore:
) -> list[dict]:
"""
جستجوی شباهت بر اساس query vector.
از query_points استفاده می‌کند (qdrant-client >= 2.0).
روی نسخه‌های جدید از query_points و روی نسخه‌های قدیمی‌تر از search استفاده می‌کند.
sensor_uuid: اجباری — فقط chunks مربوط به این سنسور یا __global__ برگردانده می‌شود.
kb_name: اختیاری — فیلتر بر اساس پایگاه دانش (chat/irrigation/fertilization).
اگر مشخص شود، فقط chunks همان KB و __all__ برگردانده می‌شود.
@@ -141,6 +141,7 @@ class QdrantVectorStore:
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,
@@ -149,6 +150,14 @@ class QdrantVectorStore:
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 [
{
+23 -32
View File
@@ -59,8 +59,8 @@ RagValidationErrorResponseSerializer = build_envelope_serializer(
class ChatView(APIView):
"""
چت RAG با استریم.
POST با {"service_id": "...", "query": "متن سوال", "user_id": "شناسه کاربر"}
service_id اجباری است. user_id فقط برای سرویس‌هایی که user embeddings دارند اجباری می‌شود.
POST با {"query": "متن سوال", "farm_uuid": "شناسه مزرعه"}.
همیشه از سرویس ثابت `chat` استفاده می‌کند و اطلاعات مزرعه را مستقیم به مدل می‌فرستد.
"""
@extend_schema(
@@ -70,11 +70,9 @@ class ChatView(APIView):
request=inline_serializer(
name="ChatRequest",
fields={
"service_id": drf_serializers.CharField(help_text="شناسه سرویس"),
"query": drf_serializers.CharField(required=False, help_text="متن سوال کاربر"),
"message": drf_serializers.CharField(required=False, help_text="نام قبلی فیلد query"),
"user_id": drf_serializers.CharField(required=False, help_text="شناسه کاربر"),
"sensor_uuid": drf_serializers.CharField(required=False, help_text="نام قبلی فیلد user_id"),
"farm_uuid": drf_serializers.CharField(help_text="شناسه مزرعه"),
},
),
responses={
@@ -86,26 +84,29 @@ class ChatView(APIView):
RagChatErrorResponseSerializer,
"پارامترهای ورودی نامعتبر هستند.",
),
404: build_response(
RagChatErrorResponseSerializer,
"مزرعه پیدا نشد.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست",
value={
"service_id": "support_bot",
"user_id": "12345",
"query": "How do I reset my password?",
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"query": "وضعیت مزرعه من چطور است؟",
},
request_only=True,
),
],
)
def post(self, request: Request):
from .config import load_rag_config, get_service_config
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
service_id = data.get("service_id")
message = data.get("query", data.get("message"))
user_id = data.get("user_id", data.get("sensor_uuid"))
farm_uuid = data.get("farm_uuid")
if not message or not isinstance(message, str):
return Response(
{"code": 400, "msg": "پارامتر query الزامی است."},
@@ -117,42 +118,32 @@ class ChatView(APIView):
{"code": 400, "msg": "پیام نباید خالی باشد."},
status=status.HTTP_400_BAD_REQUEST,
)
if not service_id or not isinstance(service_id, str):
if not farm_uuid or not isinstance(farm_uuid, str):
return Response(
{"code": 400, "msg": "پارامتر service_id الزامی است."},
{"code": 400, "msg": "پارامتر farm_uuid الزامی است."},
status=status.HTTP_400_BAD_REQUEST,
)
service_id = str(service_id).strip()
if not service_id:
farm_uuid = str(farm_uuid).strip()
if not farm_uuid:
return Response(
{"code": 400, "msg": "service_id نباید خالی باشد."},
{"code": 400, "msg": "farm_uuid نباید خالی باشد."},
status=status.HTTP_400_BAD_REQUEST,
)
cfg = load_rag_config()
try:
service = get_service_config(service_id, cfg)
except KeyError:
farm_details = get_farm_details(farm_uuid)
if farm_details is None:
return Response(
{"code": 400, "msg": f"service_id نامعتبر است: {service_id}"},
status=status.HTTP_400_BAD_REQUEST,
)
if user_id is not None:
user_id = str(user_id).strip()
if not user_id:
user_id = None
if service.use_user_embeddings and not user_id:
return Response(
{"code": 400, "msg": "برای این service_id، پارامتر user_id الزامی است."},
status=status.HTTP_400_BAD_REQUEST,
{"code": 404, "msg": "farm پیدا نشد."},
status=status.HTTP_404_NOT_FOUND,
)
def generate():
try:
for chunk in chat_rag_stream(
message,
sensor_uuid=user_id,
service_id=service_id,
farm_uuid=farm_uuid,
config=cfg,
farm_details=farm_details,
):
yield chunk
except Exception as e: