This commit is contained in:
2026-03-27 18:18:31 +03:30
parent 50c7783920
commit bf244042a9
11 changed files with 901 additions and 159 deletions
+1
View File
@@ -15,6 +15,7 @@ DB_ROOT_PASSWORD=root
# SMS.ir
SMS_IR_API_KEY=
SMS_IR_LINE_NUMBER=300000000000
USE_EXTERNAL_API_MOCK=true
# External API adapter
USE_EXTERNAL_API_MOCK=true
+439
View File
@@ -0,0 +1,439 @@
# مستند پاسخ API برای فیچرهای Farm AI
این فایل، پاسخ‌های API موردنیاز برای سه فیچر زیر را یک‌جا جمع می‌کند:
- `src/app/(dashboard)/(private)/farm-ai-assistant/page.tsx`
- `src/app/(dashboard)/(private)/fertilization-recommendation/page.tsx`
- `src/app/(dashboard)/(private)/irrigation-recommendation/page.tsx`
> مبنا، پیاده‌سازی فعلی فرانت در `src/views/dashboards/farm/...` و سرویس‌های `src/libs/api/services/...` است.
---
## قرارداد کلی پاسخ‌ها
در هر سه سرویس، فرانت انتظار این wrapper را دارد:
```json
{
"status": "success",
"data": {}
}
```
- `status`: معمولاً `success` یا `error`
- `data`: payload اصلی که فرانت unwrap می‌کند
---
## 1) Farm AI Assistant
### صفحه
- `src/app/(dashboard)/(private)/farm-ai-assistant/page.tsx`
- کامپوننت اصلی: `src/views/dashboards/farm/farmAiAssistant/FarmAiAssistantChat.tsx`
### APIهای موردنیاز
1. `GET /api/farm-ai-assistant/context/`
2. `POST /api/farm-ai-assistant/chat/`
---
### 1.1) دریافت context مزرعه
#### Endpoint
`GET /api/farm-ai-assistant/context/`
#### Response
```json
{
"status": "success",
"data": {
"soilType": "Loamy",
"waterEC": "1.2 dS/m",
"selectedCrop": "Tomato",
"growthStage": "Flowering",
"lastIrrigationStatus": "2 days ago"
}
}
```
#### فیلدهای لازم
| فیلد | نوع | مصرف در UI |
|------|-----|------------|
| `soilType` | `string` | badge نوع خاک |
| `waterEC` | `string` | badge EC آب |
| `selectedCrop` | `string` | badge محصول انتخاب‌شده |
| `growthStage` | `string` | badge مرحله رشد |
| `lastIrrigationStatus` | `string` | badge آخرین وضعیت آبیاری |
#### نکته
اگر این API خطا بدهد، فرانت fallback داخلی دارد و toast خطا نمایش می‌دهد.
---
### 1.2) ارسال پیام به دستیار
#### Endpoint
`POST /api/farm-ai-assistant/chat/`
#### Request body
```json
{
"content": "What is the best irrigation plan for tomato?",
"farm_context": {
"soilType": "Loamy",
"waterEC": "1.2 dS/m",
"selectedCrop": "Tomato",
"growthStage": "Flowering",
"lastIrrigationStatus": "2 days ago"
},
"conversation_id": "conv-123"
}
```
#### Response
```json
{
"status": "success",
"data": {
"message_id": "msg-001",
"conversation_id": "conv-123",
"content": "Here is the recommended plan.",
"sections": [
{
"type": "recommendation",
"title": "Irrigation Plan",
"icon": "droplet",
"frequency": "3 times per week",
"amount": "15 liters per plant",
"timing": "Early morning",
"expandableExplanation": "Loamy soil holds moisture well, so moderate frequency is enough."
},
{
"type": "list",
"title": "Important Notes",
"icon": "leaf",
"items": [
"Avoid watering at noon",
"Check leaf stress every two days"
]
},
{
"type": "warning",
"title": "Heat Alert",
"icon": "warning",
"content": "Increase irrigation if temperature rises above 35°C."
}
]
}
}
```
#### فیلدهای لازم در response
| فیلد | نوع | توضیح |
|------|-----|-------|
| `message_id` | `string` | id پیام assistant |
| `conversation_id` | `string` | برای ادامه چت در پیام‌های بعدی |
| `content` | `string` | متن ساده پاسخ |
| `sections` | `ChatSection[]` | خروجی ساخت‌یافته برای رندر کارت‌ها |
#### ساختار `ChatSection`
| فیلد | نوع | اجباری | توضیح |
|------|-----|--------|-------|
| `type` | `'text' \| 'list' \| 'recommendation' \| 'warning'` | بله | نوع سکشن |
| `title` | `string` | خیر | عنوان سکشن |
| `content` | `string` | خیر | متن سکشن |
| `items` | `string[]` | خیر | برای لیست |
| `icon` | `'droplet' \| 'leaf' \| 'warning' \| 'fertilizer' \| 'calendar'` | خیر | آیکون نمایشی |
| `frequency` | `string` | خیر | فقط برای `recommendation` |
| `amount` | `string` | خیر | فقط برای `recommendation` |
| `timing` | `string` | خیر | فقط برای `recommendation` |
| `expandableExplanation` | `string` | خیر | توضیح بازشونده |
#### حداقل توصیه
- `sections` همیشه به صورت آرایه برگردد، حتی اگر خالی باشد.
- `conversation_id` بعد از اولین پاسخ حتماً برگردد.
- اگر پاسخ فقط structured است، `content` می‌تواند رشته خالی باشد.
---
## 2) Fertilization Recommendation
### صفحه
- `src/app/(dashboard)/(private)/fertilization-recommendation/page.tsx`
- کامپوننت اصلی: `src/views/dashboards/farm/smartFertilization/SmartFertilizationRecommendation.tsx`
### APIهای موردنیاز
1. `GET /api/fertilization-recommendation/config/`
2. `POST /api/fertilization-recommendation/recommend/`
---
### 2.1) دریافت config اولیه
#### Endpoint
`GET /api/fertilization-recommendation/config/`
#### Response
```json
{
"status": "success",
"data": {
"farmData": {
"soilType": "Loamy",
"organicMatter": "Medium (2.5%)",
"waterEC": "1.2 dS/m"
},
"growthStages": [
{ "id": "prePlanting", "icon": "tabler-seedling" },
{ "id": "earlyGrowth", "icon": "tabler-leaf" },
{ "id": "flowering", "icon": "tabler-flower" },
{ "id": "fruiting", "icon": "tabler-apple" },
{ "id": "postHarvest", "icon": "tabler-basket" }
],
"cropOptions": [
{ "id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat" },
{ "id": "corn", "labelKey": "corn", "icon": "tabler-plant-2" }
]
}
}
```
#### فیلدهای لازم
##### `farmData`
| فیلد | نوع |
|------|-----|
| `soilType` | `string` |
| `organicMatter` | `string` |
| `waterEC` | `string` |
##### `growthStages[]`
| فیلد | نوع | توضیح |
|------|-----|-------|
| `id` | `string` | id مرحله رشد |
| `icon` | `string` | نام آیکون |
##### `cropOptions[]`
| فیلد | نوع | توضیح |
|------|-----|-------|
| `id` | `string` | id محصول برای submit |
| `labelKey` | `string` | کلید ترجمه |
| `icon` | `string` | نام آیکون |
#### نکته
- اگر `growthStages` مقدار داشته باشد، اولین آیتم به عنوان پیش‌فرض انتخاب می‌شود.
- اگر `cropOptions` خالی باشد، لیست انتخاب محصول خالی نمایش داده می‌شود.
---
### 2.2) دریافت برنامه کوددهی
#### Endpoint
`POST /api/fertilization-recommendation/recommend/`
#### Request body
```json
{
"crop_id": "wheat",
"growth_stage": "flowering",
"soilType": "Loamy",
"organicMatter": "Medium (2.5%)",
"waterEC": "1.2 dS/m"
}
```
#### Response
```json
{
"status": "success",
"data": {
"plan": {
"npkRatio": "20-20-20",
"amountPerHectare": "150 kg/ha",
"applicationMethod": "Fertigation",
"applicationInterval": "Every 10 days",
"reasoning": "Balanced NPK is recommended during flowering to support bloom and fruit set."
}
}
}
```
#### فیلدهای لازم در `plan`
| فیلد | نوع | مصرف در UI |
|------|-----|------------|
| `npkRatio` | `string` | نوع/نسبت کود |
| `amountPerHectare` | `string` | مقدار مصرف |
| `applicationMethod` | `string` | روش مصرف |
| `applicationInterval` | `string` | بازه تکرار |
| `reasoning` | `string` | توضیح بازشونده |
#### نکته
فرانت مستقیماً `data.plan` را انتظار دارد. اگر `plan` نباشد، نتیجه‌ای نمایش داده نمی‌شود.
---
## 3) Irrigation Recommendation
### صفحه
- `src/app/(dashboard)/(private)/irrigation-recommendation/page.tsx`
- کامپوننت اصلی: `src/views/dashboards/farm/smartIrrigation/SmartIrrigationRecommendation.tsx`
### APIهای موردنیاز
1. `GET /api/irrigation-recommendation/config/`
2. `POST /api/irrigation-recommendation/recommend/`
---
### 3.1) دریافت config اولیه
#### Endpoint
`GET /api/irrigation-recommendation/config/`
#### Response
```json
{
"status": "success",
"data": {
"farmInfo": {
"soilType": "Loamy",
"waterQuality": "Medium EC",
"climateZone": "Temperate"
},
"cropOptions": [
{ "id": "tomato", "labelKey": "tomato", "icon": "tabler-plant-2" },
{ "id": "cucumber", "labelKey": "cucumber", "icon": "tabler-leaf" }
]
}
}
```
#### فیلدهای لازم
##### `farmInfo`
| فیلد | نوع |
|------|-----|
| `soilType` | `string` |
| `waterQuality` | `string` |
| `climateZone` | `string` |
##### `cropOptions[]`
| فیلد | نوع | توضیح |
|------|-----|-------|
| `id` | `string` | id محصول برای submit |
| `labelKey` | `string` | کلید ترجمه |
| `icon` | `string` | نام آیکون |
#### نکته
در این صفحه `farmInfo` بدون چک null مستقیماً set می‌شود؛ بهتر است API همیشه این آبجکت را برگرداند.
---
### 3.2) دریافت برنامه آبیاری
#### Endpoint
`POST /api/irrigation-recommendation/recommend/`
#### Request body
```json
{
"crop_id": "tomato"
}
```
> در سرویس، فیلدهای `soilType`, `waterQuality`, `climateZone` هم پشتیبانی شده‌اند، ولی در UI فعلی فقط `crop_id` ارسال می‌شود.
#### Response
```json
{
"status": "success",
"data": {
"plan": {
"frequencyPerWeek": 3,
"durationMinutes": 25,
"bestTimeOfDay": "Early morning",
"moistureLevel": 68,
"warning": "Reduce irrigation if rainfall occurs this week."
}
}
}
```
#### فیلدهای لازم در `plan`
| فیلد | نوع | مصرف در UI |
|------|-----|------------|
| `frequencyPerWeek` | `number` | تعداد دفعات در هفته |
| `durationMinutes` | `number` | مدت هر نوبت |
| `bestTimeOfDay` | `string` | زمان مناسب آبیاری |
| `moistureLevel` | `number` | درصد moisture برای دایره progress |
| `warning` | `string` | هشدار اختیاری |
#### محدودیت مهم
- `moistureLevel` بهتر است بین `0` تا `100` باشد، چون مستقیم برای محاسبه progress circle استفاده می‌شود.
---
## جمع‌بندی سریع برای بک‌اند
### Farm AI Assistant
- `GET /api/farm-ai-assistant/context/``data: FarmContext`
- `POST /api/farm-ai-assistant/chat/``data: { message_id, conversation_id, content, sections }`
### Fertilization Recommendation
- `GET /api/fertilization-recommendation/config/``data: { farmData, growthStages, cropOptions }`
- `POST /api/fertilization-recommendation/recommend/``data: { plan }`
### Irrigation Recommendation
- `GET /api/irrigation-recommendation/config/``data: { farmInfo, cropOptions }`
- `POST /api/irrigation-recommendation/recommend/``data: { plan }`
---
## مسیرهای مرجع کد
- `src/libs/api/services/farmAiAssistantService.ts`
- `src/libs/api/services/fertilizationRecommendationService.ts`
- `src/libs/api/services/irrigationRecommendationService.ts`
- `src/views/dashboards/farm/farmAiAssistant/FarmAiAssistantChat.tsx`
- `src/views/dashboards/farm/smartFertilization/SmartFertilizationRecommendation.tsx`
- `src/views/dashboards/farm/smartIrrigation/SmartIrrigationRecommendation.tsx`
@@ -1,4 +1,29 @@
{
"content_type": "text/plain; charset=utf-8",
"body": "سلام، برای بازیابی رطوبت خاک بهتر است آبیاری صبح‌گاهی را تنظیم کنید."
"content": "Here is the recommended plan.",
"sections": [
{
"type": "recommendation",
"title": "Irrigation Plan",
"icon": "droplet",
"frequency": "3 times per week",
"amount": "15 liters per plant",
"timing": "Early morning",
"expandableExplanation": "Loamy soil holds moisture well, so moderate frequency is enough."
},
{
"type": "list",
"title": "Important Notes",
"icon": "leaf",
"items": [
"Avoid watering at noon",
"Check leaf stress every two days"
]
},
{
"type": "warning",
"title": "Heat Alert",
"icon": "warning",
"content": "Increase irrigation if temperature rises above 35°C."
}
]
}
+56 -13
View File
@@ -1,40 +1,83 @@
"""
Static mock data for Farm AI Assistant API.
No database, no dynamic values. All responses are fixed JSON.
"""
CHAT_RESPONSE_DATA = {
"message_id": "a-1739123456789",
"conversation_id": "conv-abc123",
"content": "",
"message_id": "msg-001",
"conversation_id": "conv-123",
"content": "Here is the recommended plan.",
"sections": [
{
"type": "recommendation",
"title": "Irrigation recommendation",
"title": "Irrigation Plan",
"icon": "droplet",
"frequency": "3 times per week",
"amount": "1520 L per plant",
"timing": "Early morning (05:0007:00)",
"expandableExplanation": "Your loamy soil holds moisture well...",
"amount": "15 liters per plant",
"timing": "Early morning",
"expandableExplanation": "Loamy soil holds moisture well, so moderate frequency is enough.",
},
{
"type": "list",
"title": "Key points",
"title": "Important Notes",
"icon": "leaf",
"items": [
"Avoid midday watering to reduce evaporation",
"Drip irrigation preferred for root zone targeting",
"Avoid watering at noon",
"Check leaf stress every two days",
],
},
{
"type": "warning",
"title": "Weather advisory",
"title": "Heat Alert",
"icon": "warning",
"content": "High temps forecasted next week. Consider increasing frequency.",
"content": "Increase irrigation if temperature rises above 35°C.",
},
],
}
CHAT_LIST_RESPONSE_DATA = [
{
"id": "conv-123",
"message_count": 4,
},
{
"id": "conv-456",
"message_count": 2,
},
]
CHAT_MESSAGES_RESPONSE_DATA = {
"conversation_id": "conv-123",
"messages": [
{
"message_id": "msg-user-001",
"conversation_id": "conv-123",
"role": "user",
"content": "What is the best irrigation plan for tomato?",
"sections": [],
"images": [],
"created_at": "2025-01-01T08:00:00Z",
},
{
"message_id": "msg-001",
"conversation_id": "conv-123",
"role": "assistant",
"content": "Here is the recommended plan.",
"sections": CHAT_RESPONSE_DATA["sections"],
"images": [],
"created_at": "2025-01-01T08:00:05Z",
},
],
}
CHAT_CREATE_RESPONSE_DATA = {
"id": "conv-789",
"message_count": 0,
}
CHAT_DELETE_RESPONSE_DATA = {
"conversation_id": "conv-123",
}
CONTEXT_RESPONSE_DATA = {
"soilType": "Loamy",
"waterEC": "1.2 dS/m",
@@ -2,7 +2,7 @@
"info": {
"name": "Farm AI Assistant",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"description": "Farm AI Assistant API. GET context (farm bar data). POST chat (user message + optional farm_context/images). Static JSON only."
"description": "Farm AI Assistant API. Context, chat send, chat list/create, message history, and chat delete."
},
"item": [
{
@@ -11,7 +11,7 @@
"method": "GET",
"header": [{"key": "Content-Type", "value": "application/json"}],
"url": "{{baseUrl}}/api/farm-ai-assistant/context/",
"description": "Returns static farm context: soilType, waterEC, selectedCrop, growthStage, lastIrrigationStatus for the context bar."
"description": "Returns static farm context for the context bar."
},
"response": [
{
@@ -23,23 +23,95 @@
]
},
{
"name": "Chat (POST)",
"name": "List chats (GET)",
"request": {
"method": "POST",
"method": "GET",
"header": [{"key": "Content-Type", "value": "application/json"}],
"body": {
"mode": "raw",
"raw": "{\n \"content\": \"برنامه آبیاری برای گوجه در مرحله گلدهی چطور باشد؟\",\n \"farm_context\": {\n \"soilType\": \"Loamy\",\n \"waterEC\": \"1.2 dS/m\",\n \"selectedCrop\": \"Tomato\",\n \"growthStage\": \"Flowering\",\n \"lastIrrigationStatus\": \"2 days ago\"\n }\n}"
},
"url": "{{baseUrl}}/api/farm-ai-assistant/chat/",
"description": "Body: content (required), optional images, conversation_id, farm_context. Returns static message with sections (recommendation, list, warning). Input not processed."
"url": "{{baseUrl}}/api/farm-ai-assistant/chats/",
"description": "Returns only chat id and message count for the current user."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"status\": \"success\",\n \"data\": {\n \"message_id\": \"a-1739123456789\",\n \"conversation_id\": \"conv-abc123\",\n \"content\": \"\",\n \"sections\": [\n {\n \"type\": \"recommendation\",\n \"title\": \"Irrigation recommendation\",\n \"icon\": \"droplet\",\n \"frequency\": \"3 times per week\",\n \"amount\": \"1520 L per plant\",\n \"timing\": \"Early morning (05:0007:00)\",\n \"expandableExplanation\": \"Your loamy soil holds moisture well...\"\n },\n {\n \"type\": \"list\",\n \"title\": \"Key points\",\n \"icon\": \"leaf\",\n \"items\": [\n \"Avoid midday watering to reduce evaporation\",\n \"Drip irrigation preferred for root zone targeting\"\n ]\n },\n {\n \"type\": \"warning\",\n \"title\": \"Weather advisory\",\n \"icon\": \"warning\",\n \"content\": \"High temps forecasted next week. Consider increasing frequency.\"\n }\n ]\n }\n}"
"body": "{\n \"status\": \"success\",\n \"data\": [\n {\n \"id\": \"conv-123\",\n \"message_count\": 4\n },\n {\n \"id\": \"conv-456\",\n \"message_count\": 2\n }\n ]\n}"
}
]
},
{
"name": "Create chat (POST)",
"request": {
"method": "POST",
"header": [{"key": "Content-Type", "value": "application/json"}],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"New chat\"\n}"
},
"url": "{{baseUrl}}/api/farm-ai-assistant/chats/",
"description": "Creates a new empty chat for the current user."
},
"response": [
{
"name": "Created",
"status": "Created",
"code": 201,
"body": "{\n \"status\": \"success\",\n \"data\": {\n \"id\": \"conv-789\",\n \"message_count\": 0\n }\n}"
}
]
},
{
"name": "Get chat messages (GET)",
"request": {
"method": "GET",
"header": [{"key": "Content-Type", "value": "application/json"}],
"url": "{{baseUrl}}/api/farm-ai-assistant/chats/conv-123/messages/",
"description": "Returns all user and assistant messages for one chat."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"status\": \"success\",\n \"data\": {\n \"conversation_id\": \"conv-123\",\n \"messages\": [\n {\n \"message_id\": \"msg-user-001\",\n \"conversation_id\": \"conv-123\",\n \"role\": \"user\",\n \"content\": \"What is the best irrigation plan for tomato?\",\n \"sections\": [],\n \"images\": [],\n \"created_at\": \"2025-01-01T08:00:00Z\"\n },\n {\n \"message_id\": \"msg-001\",\n \"conversation_id\": \"conv-123\",\n \"role\": \"assistant\",\n \"content\": \"Here is the recommended plan.\",\n \"sections\": [\n {\n \"type\": \"recommendation\",\n \"title\": \"Irrigation Plan\",\n \"icon\": \"droplet\",\n \"frequency\": \"3 times per week\",\n \"amount\": \"15 liters per plant\",\n \"timing\": \"Early morning\",\n \"expandableExplanation\": \"Loamy soil holds moisture well, so moderate frequency is enough.\"\n }\n ],\n \"images\": [],\n \"created_at\": \"2025-01-01T08:00:05Z\"\n }\n ]\n }\n}"
}
]
},
{
"name": "Send chat message (POST)",
"request": {
"method": "POST",
"header": [{"key": "Content-Type", "value": "application/json"}],
"body": {
"mode": "raw",
"raw": "{\n \"conversation_id\": \"conv-123\",\n \"content\": \"What is the best irrigation plan for tomato?\",\n \"farm_context\": {\n \"soilType\": \"Loamy\",\n \"waterEC\": \"1.2 dS/m\",\n \"selectedCrop\": \"Tomato\",\n \"growthStage\": \"Flowering\",\n \"lastIrrigationStatus\": \"2 days ago\"\n }\n}"
},
"url": "{{baseUrl}}/api/farm-ai-assistant/chat/",
"description": "Sends a user message and returns a structured assistant reply."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"status\": \"success\",\n \"data\": {\n \"message_id\": \"msg-001\",\n \"conversation_id\": \"conv-123\",\n \"content\": \"Here is the recommended plan.\",\n \"sections\": [\n {\n \"type\": \"recommendation\",\n \"title\": \"Irrigation Plan\",\n \"icon\": \"droplet\",\n \"frequency\": \"3 times per week\",\n \"amount\": \"15 liters per plant\",\n \"timing\": \"Early morning\",\n \"expandableExplanation\": \"Loamy soil holds moisture well, so moderate frequency is enough.\"\n },\n {\n \"type\": \"list\",\n \"title\": \"Important Notes\",\n \"icon\": \"leaf\",\n \"items\": [\n \"Avoid watering at noon\",\n \"Check leaf stress every two days\"\n ]\n },\n {\n \"type\": \"warning\",\n \"title\": \"Heat Alert\",\n \"icon\": \"warning\",\n \"content\": \"Increase irrigation if temperature rises above 35°C.\"\n }\n ]\n }\n}"
}
]
},
{
"name": "Delete chat (DELETE)",
"request": {
"method": "DELETE",
"header": [{"key": "Content-Type", "value": "application/json"}],
"url": "{{baseUrl}}/api/farm-ai-assistant/chats/conv-123/",
"description": "Deletes one chat and all messages inside it."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"status\": \"success\",\n \"data\": {\n \"conversation_id\": \"conv-123\"\n }\n}"
}
]
}
+51 -25
View File
@@ -1,35 +1,54 @@
from rest_framework import serializers
from .models import Conversation, Message
from .models import Message
class ConversationListSerializer(serializers.ModelSerializer):
conversation_id = serializers.UUIDField(source="uuid", read_only=True)
class Meta:
model = Conversation
fields = [
"conversation_id",
"title",
"updated_at",
]
class ChatSectionSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=["text", "list", "recommendation", "warning"])
title = serializers.CharField(required=False, allow_blank=True)
content = serializers.CharField(required=False, allow_blank=True)
items = serializers.ListField(child=serializers.CharField(), required=False)
icon = serializers.CharField(required=False, allow_blank=True)
frequency = serializers.CharField(required=False, allow_blank=True)
amount = serializers.CharField(required=False, allow_blank=True)
timing = serializers.CharField(required=False, allow_blank=True)
expandableExplanation = serializers.CharField(required=False, allow_blank=True)
class MessageSerializer(serializers.ModelSerializer):
message_id = serializers.UUIDField(source="uuid", read_only=True)
conversation_id = serializers.UUIDField(source="conversation.uuid", read_only=True)
class ConversationSummarySerializer(serializers.Serializer):
id = serializers.UUIDField(source="uuid", read_only=True)
message_count = serializers.IntegerField(read_only=True)
class Meta:
model = Message
fields = [
"message_id",
"conversation_id",
"role",
"content",
"images",
"raw_response",
"created_at",
]
class ConversationCreateSerializer(serializers.Serializer):
title = serializers.CharField(required=False, allow_blank=True, max_length=255)
farm_context = serializers.JSONField(required=False)
class ChatHistoryMessageSerializer(serializers.Serializer):
message_id = serializers.UUIDField(read_only=True)
conversation_id = serializers.UUIDField(read_only=True)
role = serializers.ChoiceField(choices=Message.ROLE_CHOICES, read_only=True)
content = serializers.CharField(read_only=True, allow_blank=True)
sections = ChatSectionSerializer(many=True, read_only=True)
images = serializers.ListField(child=serializers.CharField(), read_only=True)
created_at = serializers.DateTimeField(read_only=True)
class ConversationMessagesSerializer(serializers.Serializer):
conversation_id = serializers.UUIDField(read_only=True)
messages = ChatHistoryMessageSerializer(many=True, read_only=True)
class ChatResponseDataSerializer(serializers.Serializer):
message_id = serializers.UUIDField(read_only=True)
conversation_id = serializers.UUIDField(read_only=True)
content = serializers.CharField(read_only=True, allow_blank=True)
sections = ChatSectionSerializer(many=True, read_only=True)
class ConversationDeleteSerializer(serializers.Serializer):
conversation_id = serializers.UUIDField(read_only=True)
class ChatPostSerializer(serializers.Serializer):
@@ -42,3 +61,10 @@ class ChatPostSerializer(serializers.Serializer):
conversation_id = serializers.UUIDField(required=False)
title = serializers.CharField(required=False, allow_blank=True, max_length=255)
farm_context = serializers.JSONField(required=False)
def validate(self, attrs):
content = attrs.get("content", "").strip()
images = attrs.get("images") or []
if not content and not images:
raise serializers.ValidationError("Either content or images is required.")
return attrs
+3 -2
View File
@@ -1,10 +1,11 @@
from django.urls import path
from .views import ChatListView, ChatMessagesView, ChatView, ContextView
from .views import ChatDetailView, ChatListCreateView, ChatMessagesView, ChatView, ContextView
urlpatterns = [
path("context/", ContextView.as_view(), name="farm-ai-assistant-context"),
path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"),
path("chats/", ChatListView.as_view(), name="farm-ai-assistant-chat-list"),
path("chats/", ChatListCreateView.as_view(), name="farm-ai-assistant-chat-list-create"),
path("chats/<uuid:conversation_id>/", ChatDetailView.as_view(), name="farm-ai-assistant-chat-detail"),
path("chats/<uuid:conversation_id>/messages/", ChatMessagesView.as_view(), name="farm-ai-assistant-chat-messages"),
]
+212 -98
View File
@@ -1,6 +1,9 @@
"""Farm AI Assistant API views."""
from django.http import Http404, HttpResponse
from copy import deepcopy
from django.db.models import Count
from django.http import Http404
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@@ -10,9 +13,17 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response
from external_api_adapter import request as external_api_request
from .mock_data import CONTEXT_RESPONSE_DATA
from external_api_adapter.exceptions import ExternalAPIRequestError
from .mock_data import CHAT_RESPONSE_DATA, CONTEXT_RESPONSE_DATA
from .models import Conversation, Message
from .serializers import ChatPostSerializer, ConversationListSerializer, MessageSerializer
from .serializers import (
ChatPostSerializer,
ChatResponseDataSerializer,
ConversationCreateSerializer,
ConversationDeleteSerializer,
ConversationMessagesSerializer,
ConversationSummarySerializer,
)
class ContextView(APIView):
@@ -27,63 +38,194 @@ class ContextView(APIView):
)
class ChatListView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Farm AI Assistant"],
responses={200: status_response("FarmAiAssistantConversationListResponse", data=ConversationListSerializer(many=True))},
)
def get(self, request):
conversations = Conversation.objects.filter(owner=request.user).order_by("-updated_at", "-created_at")
serializer = ConversationListSerializer(conversations, many=True)
return Response({"status": "success", "data": serializer.data}, status=status.HTTP_200_OK)
class ChatMessagesView(APIView):
permission_classes = [IsAuthenticated]
def _get_conversation(self, request, conversation_id):
class ConversationAccessMixin:
@staticmethod
def _get_conversation(request, conversation_id):
try:
return Conversation.objects.get(uuid=conversation_id, owner=request.user)
except Conversation.DoesNotExist as exc:
raise Http404("Conversation not found") from exc
@staticmethod
def _normalize_sections(raw_sections):
if not isinstance(raw_sections, list):
return []
allowed_keys = {
"type",
"title",
"content",
"items",
"icon",
"frequency",
"amount",
"timing",
"expandableExplanation",
}
normalized_sections = []
for section in raw_sections:
if not isinstance(section, dict) or not section.get("type"):
continue
normalized_section = {}
for key in allowed_keys:
value = section.get(key)
if value is None:
continue
if key == "items":
if not isinstance(value, list):
continue
normalized_section[key] = [str(item) for item in value]
continue
normalized_section[key] = str(value) if key != "type" else value
normalized_sections.append(normalized_section)
return normalized_sections
def _build_mock_assistant_payload(self, conversation_id):
payload = deepcopy(CHAT_RESPONSE_DATA)
payload["conversation_id"] = str(conversation_id)
return payload
def _extract_assistant_payload(self, adapter_data, conversation_id):
payload_source = adapter_data
if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict):
payload_source = adapter_data["data"]
content = ""
sections = []
if isinstance(payload_source, dict):
content = payload_source.get("content") or ""
sections = self._normalize_sections(payload_source.get("sections"))
if not sections and isinstance(adapter_data, dict):
sections = self._normalize_sections(adapter_data.get("sections"))
if not content and isinstance(adapter_data, dict):
content = adapter_data.get("body") or adapter_data.get("content") or ""
return {
"message_id": "",
"conversation_id": str(conversation_id),
"content": content,
"sections": sections,
}
@staticmethod
def _serialize_chat_message(message):
raw_response = message.raw_response if isinstance(message.raw_response, dict) else {}
sections = raw_response.get("sections") if message.role == Message.ROLE_ASSISTANT else []
return {
"message_id": str(message.uuid),
"conversation_id": str(message.conversation.uuid),
"role": message.role,
"content": message.content,
"sections": ConversationAccessMixin._normalize_sections(sections),
"images": message.images if isinstance(message.images, list) else [],
"created_at": message.created_at,
}
class ChatListCreateView(ConversationAccessMixin, APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Farm AI Assistant"],
responses={200: status_response("FarmAiAssistantConversationListResponse", data=ConversationSummarySerializer(many=True))},
)
def get(self, request):
conversations = (
Conversation.objects.filter(owner=request.user)
.annotate(message_count=Count("messages"))
.order_by("-updated_at", "-created_at")
)
serializer = ConversationSummarySerializer(conversations, many=True)
return Response({"status": "success", "data": serializer.data}, status=status.HTTP_200_OK)
@extend_schema(
tags=["Farm AI Assistant"],
request=ConversationCreateSerializer,
responses={201: status_response("FarmAiAssistantConversationCreateResponse", data=ConversationSummarySerializer())},
)
def post(self, request):
serializer = ConversationCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated = serializer.validated_data
conversation = Conversation.objects.create(
owner=request.user,
title=validated.get("title", "").strip() or "New chat",
farm_context=validated.get("farm_context") or {},
)
response_serializer = ConversationSummarySerializer(
{
"uuid": conversation.uuid,
"message_count": 0,
}
)
return Response({"status": "success", "data": response_serializer.data}, status=status.HTTP_201_CREATED)
class ChatMessagesView(ConversationAccessMixin, APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Farm AI Assistant"],
parameters=[
OpenApiParameter(name="conversation_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH),
],
responses={200: status_response("FarmAiAssistantMessageListResponse", data=MessageSerializer(many=True))},
responses={200: status_response("FarmAiAssistantMessageListResponse", data=ConversationMessagesSerializer())},
)
def get(self, request, conversation_id):
conversation = self._get_conversation(request, conversation_id)
messages = conversation.messages.all()
serializer = MessageSerializer(messages, many=True)
return Response({"status": "success", "data": serializer.data}, status=status.HTTP_200_OK)
serialized_messages = [self._serialize_chat_message(message) for message in messages]
return Response(
{
"status": "success",
"data": {
"conversation_id": str(conversation.uuid),
"messages": serialized_messages,
},
},
status=status.HTTP_200_OK,
)
class ChatView(APIView):
class ChatDetailView(ConversationAccessMixin, APIView):
permission_classes = [IsAuthenticated]
def _get_conversation(self, request, conversation_id):
try:
return Conversation.objects.get(uuid=conversation_id, owner=request.user)
except Conversation.DoesNotExist as exc:
raise Http404("Conversation not found") from exc
@extend_schema(
tags=["Farm AI Assistant"],
responses={200: status_response("FarmAiAssistantConversationListAliasResponse", data=ConversationListSerializer(many=True))},
parameters=[
OpenApiParameter(name="conversation_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH),
],
responses={200: status_response("FarmAiAssistantConversationDeleteResponse", data=ConversationDeleteSerializer())},
)
def get(self, request):
return ChatListView().get(request)
def delete(self, request, conversation_id):
conversation = self._get_conversation(request, conversation_id)
deleted_conversation_id = str(conversation.uuid)
conversation.delete()
return Response(
{
"status": "success",
"data": {
"conversation_id": deleted_conversation_id,
},
},
status=status.HTTP_200_OK,
)
class ChatView(ConversationAccessMixin, APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Farm AI Assistant"],
request=ChatPostSerializer,
responses={200: status_response("FarmAiAssistantChatResponse", data=serializers.JSONField())},
responses={200: status_response("FarmAiAssistantChatResponse", data=ChatResponseDataSerializer())},
)
def post(self, request):
serializer = ChatPostSerializer(data=request.data)
@@ -92,7 +234,7 @@ class ChatView(APIView):
validated = serializer.validated_data
conversation_id = validated.get("conversation_id")
farm_context = validated.get("farm_context")
title = validated.get("title", "")
title = validated.get("title", "").strip()
if conversation_id:
conversation = self._get_conversation(request, conversation_id)
@@ -109,7 +251,7 @@ class ChatView(APIView):
else:
conversation = Conversation.objects.create(
owner=request.user,
title=title or (validated.get("content", "")[:255]),
title=title or (validated.get("content", "")[:255]) or "New chat",
farm_context=farm_context or {},
)
@@ -118,81 +260,53 @@ class ChatView(APIView):
role=Message.ROLE_USER,
content=validated.get("content", ""),
images=validated.get("images", []),
raw_response={},
)
adapter_payload = dict(request.data)
adapter_payload["conversation_id"] = str(conversation.uuid)
adapter_response = external_api_request(
"ai",
"/rag/chat",
method="POST",
payload=adapter_payload,
)
if isinstance(adapter_response.data, dict) and "body" in adapter_response.data:
assistant_content = adapter_response.data.get("body") or ""
assistant_message = Message.objects.create(
conversation=conversation,
role=Message.ROLE_ASSISTANT,
content=assistant_content,
raw_response=adapter_response.data,
try:
adapter_response = external_api_request(
"ai",
"/rag/chat",
method="POST",
payload=adapter_payload,
)
if not conversation.title:
conversation.title = (validated.get("content", "") or assistant_content or "New chat")[:255]
conversation.save(update_fields=["title", "updated_at"])
else:
conversation.save(update_fields=["updated_at"])
return Response(
{
"conversation_id": str(conversation.uuid),
"user_message_id": str(user_message.uuid),
"assistant_message_id": str(assistant_message.uuid),
"content": assistant_content,
"content_type": adapter_response.data.get("content_type", "text/plain; charset=utf-8"),
},
status=adapter_response.status_code,
)
assistant_content = ""
if isinstance(adapter_response.data, dict):
assistant_content = adapter_response.data.get("content") or ""
if not assistant_content and isinstance(adapter_response.data.get("data"), dict):
assistant_content = adapter_response.data["data"].get("content") or ""
if adapter_response.status_code >= 400:
return Response(
{
"status": "error",
"data": adapter_response.data,
},
status=adapter_response.status_code,
)
assistant_payload = self._extract_assistant_payload(adapter_response.data, conversation.uuid)
response_status_code = adapter_response.status_code
except ExternalAPIRequestError:
assistant_payload = self._build_mock_assistant_payload(conversation.uuid)
response_status_code = status.HTTP_200_OK
assistant_message = Message.objects.create(
conversation=conversation,
role=Message.ROLE_ASSISTANT,
content=assistant_content,
raw_response=adapter_response.data if isinstance(adapter_response.data, dict) else {"body": str(adapter_response.data)},
content=assistant_payload.get("content", ""),
raw_response={},
)
assistant_payload["message_id"] = str(assistant_message.uuid)
assistant_message.raw_response = assistant_payload
assistant_message.save(update_fields=["raw_response"])
if not conversation.title:
conversation.title = (validated.get("content", "") or assistant_content or "New chat")[:255]
conversation.title = (validated.get("content", "") or assistant_payload.get("content", "") or "New chat")[:255]
conversation.save(update_fields=["title", "updated_at"])
else:
conversation.save(update_fields=["updated_at"])
conversation_uuid = str(conversation.uuid)
response_data = adapter_response.data
if isinstance(response_data, dict):
response_data.setdefault("conversation_id", conversation_uuid)
data = response_data.get("data")
if isinstance(data, dict):
data.setdefault("conversation_id", conversation_uuid)
data.setdefault("user_message_id", str(user_message.uuid))
data.setdefault("assistant_message_id", str(assistant_message.uuid))
else:
response_data.setdefault("user_message_id", str(user_message.uuid))
response_data.setdefault("assistant_message_id", str(assistant_message.uuid))
else:
response_data = {
"conversation_id": conversation_uuid,
"user_message_id": str(user_message.uuid),
"assistant_message_id": str(assistant_message.uuid),
"response": response_data,
}
return Response(response_data, status=adapter_response.status_code)
return Response(
{
"status": "success",
"data": assistant_payload,
},
status=response_status_code,
)
+1 -3
View File
@@ -1,10 +1,8 @@
from django.urls import path
from .views import ConfigView, RecommendTaskStatusView, RecommendView
from .views import ConfigView, RecommendView
urlpatterns = [
path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"),
path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"),
# path("recommend/task/", RecommendTaskCreateView.as_view(), name="fertilization-recommendation-task-create"),
path("recommend/<str:task_id>/status/", RecommendTaskStatusView.as_view(), name="fertilization-recommendation-task-status"),
]
+1 -3
View File
@@ -1,10 +1,8 @@
from django.urls import path
from .views import ConfigView, RecommendTaskCreateView, RecommendTaskStatusView, RecommendView
from .views import ConfigView, RecommendView
urlpatterns = [
path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"),
path("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"),
path("recommend/task/", RecommendTaskCreateView.as_view(), name="irrigation-recommendation-task-create"),
path("recommend/<str:task_id>/status/", RecommendTaskStatusView.as_view(), name="irrigation-recommendation-task-status"),
]
+27 -2
View File
@@ -1,4 +1,29 @@
{
"content_type": "text/plain; charset=utf-8",
"body": "سلام، برای بازیابی رطوبت خاک بهتر است آبیاری صبح‌گاهی را تنظیم کنید."
"content": "Here is the recommended plan.",
"sections": [
{
"type": "recommendation",
"title": "Irrigation Plan",
"icon": "droplet",
"frequency": "3 times per week",
"amount": "15 liters per plant",
"timing": "Early morning",
"expandableExplanation": "Loamy soil holds moisture well, so moderate frequency is enough."
},
{
"type": "list",
"title": "Important Notes",
"icon": "leaf",
"items": [
"Avoid watering at noon",
"Check leaf stress every two days"
]
},
{
"type": "warning",
"title": "Heat Alert",
"icon": "warning",
"content": "Increase irrigation if temperature rises above 35°C."
}
]
}