From bf244042a9128c35df11d3d7ed59c00288926775 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Fri, 27 Mar 2026 18:18:31 +0330 Subject: [PATCH] UPDATE --- .env.example | 1 + FARM_FEATURE_API_RESPONSES.md | 439 ++++++++++++++++++ .../json/ai/rag/chat-post_200_stream.json | 29 +- farm_ai_assistant/mock_data.py | 69 ++- .../postman/farm_ai_assistant.json | 94 +++- farm_ai_assistant/serializers.py | 76 ++- farm_ai_assistant/urls.py | 5 +- farm_ai_assistant/views.py | 310 +++++++++---- fertilization_recommendation/urls.py | 4 +- irrigation_recommendation/urls.py | 4 +- json/mock_data/rag/chat-post_200_stream.json | 29 +- 11 files changed, 901 insertions(+), 159 deletions(-) create mode 100644 FARM_FEATURE_API_RESPONSES.md diff --git a/.env.example b/.env.example index 258c733..2609aeb 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/FARM_FEATURE_API_RESPONSES.md b/FARM_FEATURE_API_RESPONSES.md new file mode 100644 index 0000000..5598fa5 --- /dev/null +++ b/FARM_FEATURE_API_RESPONSES.md @@ -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` diff --git a/external_api_adapter/json/ai/rag/chat-post_200_stream.json b/external_api_adapter/json/ai/rag/chat-post_200_stream.json index ead2147..2847fcd 100644 --- a/external_api_adapter/json/ai/rag/chat-post_200_stream.json +++ b/external_api_adapter/json/ai/rag/chat-post_200_stream.json @@ -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." + } + ] } diff --git a/farm_ai_assistant/mock_data.py b/farm_ai_assistant/mock_data.py index 2e8574d..dc41e2d 100644 --- a/farm_ai_assistant/mock_data.py +++ b/farm_ai_assistant/mock_data.py @@ -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": "15–20 L per plant", - "timing": "Early morning (05:00–07: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", diff --git a/farm_ai_assistant/postman/farm_ai_assistant.json b/farm_ai_assistant/postman/farm_ai_assistant.json index dca0409..7578707 100644 --- a/farm_ai_assistant/postman/farm_ai_assistant.json +++ b/farm_ai_assistant/postman/farm_ai_assistant.json @@ -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\": \"15–20 L per plant\",\n \"timing\": \"Early morning (05:00–07: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}" } ] } diff --git a/farm_ai_assistant/serializers.py b/farm_ai_assistant/serializers.py index 00be2b5..5d0f47a 100644 --- a/farm_ai_assistant/serializers.py +++ b/farm_ai_assistant/serializers.py @@ -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 diff --git a/farm_ai_assistant/urls.py b/farm_ai_assistant/urls.py index 28baefc..e20a754 100644 --- a/farm_ai_assistant/urls.py +++ b/farm_ai_assistant/urls.py @@ -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//", ChatDetailView.as_view(), name="farm-ai-assistant-chat-detail"), path("chats//messages/", ChatMessagesView.as_view(), name="farm-ai-assistant-chat-messages"), ] diff --git a/farm_ai_assistant/views.py b/farm_ai_assistant/views.py index 661d71f..e2fbd66 100644 --- a/farm_ai_assistant/views.py +++ b/farm_ai_assistant/views.py @@ -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, + ) diff --git a/fertilization_recommendation/urls.py b/fertilization_recommendation/urls.py index 229dfb7..e52d26d 100644 --- a/fertilization_recommendation/urls.py +++ b/fertilization_recommendation/urls.py @@ -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//status/", RecommendTaskStatusView.as_view(), name="fertilization-recommendation-task-status"), ] diff --git a/irrigation_recommendation/urls.py b/irrigation_recommendation/urls.py index 69fb85c..b3810bc 100644 --- a/irrigation_recommendation/urls.py +++ b/irrigation_recommendation/urls.py @@ -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//status/", RecommendTaskStatusView.as_view(), name="irrigation-recommendation-task-status"), ] diff --git a/json/mock_data/rag/chat-post_200_stream.json b/json/mock_data/rag/chat-post_200_stream.json index ead2147..2847fcd 100644 --- a/json/mock_data/rag/chat-post_200_stream.json +++ b/json/mock_data/rag/chat-post_200_stream.json @@ -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." + } + ] }