UPDATE
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
# تغییرات API چت در `farm_ai_assistant/urls.py`
|
||||
|
||||
این فایل تغییرات مربوط به API چت را در `farm_ai_assistant/urls.py` نسبت به **۶ کامیت قبل** (`HEAD~6`) توضیح میدهد.
|
||||
|
||||
## بازه مقایسه
|
||||
- مبدا مقایسه: `HEAD~6`
|
||||
- مقصد مقایسه: `HEAD`
|
||||
|
||||
کامیت مبنا:
|
||||
- `2a77f90` - `Update Docker Compose ports to 8081 and add new apps and URL routes for crop zoning, plant simulator, pest detection, irrigation recommendation, fertilization recommendation, and farm AI assistant.`
|
||||
|
||||
کامیتهای داخل این بازه:
|
||||
- `2846db1` - `UPDATE`
|
||||
- `bf24404` - `UPDATE`
|
||||
- `cef1b53` - `UPDATE`
|
||||
- `24cb87d` - `UPDATE`
|
||||
- `2cd96ce` - `UPDATE`
|
||||
|
||||
## خلاصه تغییر اصلی
|
||||
در این بازه، ساختار API چت از حالت **task-based / async polling** به حالت **direct chat endpoint** تغییر کرده است.
|
||||
|
||||
به زبان ساده:
|
||||
- قبلاً endpoint اصلی `chat/` غیرفعال بود.
|
||||
- قبلاً برای ارسال درخواست چت، یک task ساخته میشد.
|
||||
- سپس وضعیت آن task با یک endpoint جداگانه بررسی میشد.
|
||||
- الان این مدل حذف شده و بهجای آن endpoint مستقیم `chat/` فعال شده است.
|
||||
|
||||
## تغییرات دقیق در مسیرها
|
||||
|
||||
### 1) فعال شدن endpoint مستقیم چت
|
||||
مسیر زیر فعال شده است:
|
||||
|
||||
- `POST/GET farm_ai_assistant/chat/`
|
||||
- view متناظر: `ChatView`
|
||||
- name: `farm-ai-assistant-chat`
|
||||
|
||||
وضعیت قبلی:
|
||||
- این خط در فایل وجود داشت اما کامنت شده بود:
|
||||
- `# path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"),`
|
||||
|
||||
وضعیت جدید:
|
||||
- این endpoint از حالت comment خارج شده و فعال شده است:
|
||||
- `path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"),`
|
||||
|
||||
### 2) حذف endpoint ساخت task برای چت
|
||||
این مسیر حذف شده است:
|
||||
|
||||
- `chat/task/`
|
||||
- view: `ChatTaskCreateView`
|
||||
- name: `farm-ai-assistant-chat-task-create`
|
||||
|
||||
هدف قبلی این endpoint:
|
||||
- ایجاد یک task برای پردازش درخواست چت
|
||||
|
||||
### 3) حذف endpoint بررسی وضعیت task
|
||||
این مسیر هم حذف شده است:
|
||||
|
||||
- `chat/task/<str:task_id>/status/`
|
||||
- view: `ChatTaskStatusView`
|
||||
- name: `farm-ai-assistant-chat-task-status`
|
||||
|
||||
هدف قبلی این endpoint:
|
||||
- بررسی وضعیت پردازش task چت با استفاده از `task_id`
|
||||
|
||||
### 4) حذف import های مربوط به task-based flow
|
||||
این importها از فایل حذف شدهاند:
|
||||
|
||||
- `ChatTaskCreateView`
|
||||
- `ChatTaskStatusView`
|
||||
|
||||
این یعنی دیگر routeای در `urls.py` برای این دو view تعریف نشده است.
|
||||
|
||||
## چیزهایی که تغییری نکردهاند
|
||||
این endpointها در بازه مقایسه بدون تغییر باقی ماندهاند:
|
||||
|
||||
- `context/` -> `ContextView`
|
||||
- `chats/` -> `ChatListCreateView`
|
||||
- `chats/<uuid:conversation_id>/` -> `ChatDetailView`
|
||||
- `chats/<uuid:conversation_id>/messages/` -> `ChatMessagesView`
|
||||
|
||||
## نتیجه فنی تغییر
|
||||
این تغییر نشان میدهد طراحی API چت از این الگو:
|
||||
|
||||
1. ساخت task
|
||||
2. دریافت `task_id`
|
||||
3. polling برای status
|
||||
|
||||
به این الگو تغییر کرده است:
|
||||
|
||||
1. ارسال مستقیم درخواست به `chat/`
|
||||
2. دریافت مستقیم پاسخ از `ChatView`
|
||||
|
||||
## اثر احتمالی روی فرانت یا کلاینتها
|
||||
اگر فرانت یا کلاینت قبلاً با flow تسکمحور کار میکرده، باید این تغییرات را اعمال کند:
|
||||
|
||||
- دیگر نباید به `chat/task/` درخواست بزند.
|
||||
- دیگر نباید `task_id` دریافت و status را polling کند.
|
||||
- باید مستقیماً از `chat/` برای عملیات چت استفاده کند.
|
||||
|
||||
## جمعبندی
|
||||
مهمترین تغییر در ۶ کامیت اخیر برای `farm_ai_assistant/urls.py` این است که:
|
||||
|
||||
- endpoint مستقیم `chat/` فعال شده
|
||||
- endpointهای task-based حذف شدهاند
|
||||
- معماری API چت از حالت asynchronous polling به حالت direct request/response تغییر کرده است
|
||||
@@ -0,0 +1,9 @@
|
||||
CONTEXT_RESPONSE_TEMPLATE = {
|
||||
"soilType": None,
|
||||
"waterEC": None,
|
||||
"selectedCrop": None,
|
||||
"growthStage": None,
|
||||
"lastIrrigationStatus": None,
|
||||
"status": "success",
|
||||
"source": "default_template",
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Conversation",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
|
||||
("title", models.CharField(blank=True, default="", max_length=255)),
|
||||
("farm_context", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("owner", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="farm_ai_conversations", to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
"db_table": "farm_ai_conversations",
|
||||
"ordering": ["-updated_at", "-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Message",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
|
||||
("role", models.CharField(choices=[("user", "User"), ("assistant", "Assistant")], max_length=32)),
|
||||
("content", models.TextField(blank=True, default="")),
|
||||
("images", models.JSONField(blank=True, default=list)),
|
||||
("raw_response", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("conversation", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="messages", to="farm_ai_assistant.conversation")),
|
||||
],
|
||||
options={
|
||||
"db_table": "farm_ai_messages",
|
||||
"ordering": ["created_at", "id"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("farm_hub", "0002_seed_default_catalog"),
|
||||
("farm_ai_assistant", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="conversation",
|
||||
name="farm",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ai_conversations",
|
||||
to="farm_hub.farmhub",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="message",
|
||||
name="farm",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ai_messages",
|
||||
to="farm_hub.farmhub",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Static mock data for Farm AI Assistant API.
|
||||
"""
|
||||
|
||||
CHAT_RESPONSE_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.",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
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",
|
||||
"selectedCrop": "Tomato",
|
||||
"growthStage": "Flowering",
|
||||
"lastIrrigationStatus": "2 days ago",
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from farm_hub.models import FarmHub
|
||||
|
||||
|
||||
class Conversation(models.Model):
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="farm_ai_conversations",
|
||||
)
|
||||
farm = models.ForeignKey(
|
||||
FarmHub,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="ai_conversations",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
title = models.CharField(max_length=255, blank=True, default="")
|
||||
farm_context = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_ai_conversations"
|
||||
ordering = ["-updated_at", "-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return self.title or f"Conversation {self.uuid}"
|
||||
|
||||
|
||||
class Message(models.Model):
|
||||
ROLE_USER = "user"
|
||||
ROLE_ASSISTANT = "assistant"
|
||||
ROLE_CHOICES = (
|
||||
(ROLE_USER, "User"),
|
||||
(ROLE_ASSISTANT, "Assistant"),
|
||||
)
|
||||
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
|
||||
conversation = models.ForeignKey(
|
||||
Conversation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="messages",
|
||||
)
|
||||
farm = models.ForeignKey(
|
||||
FarmHub,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="ai_messages",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
role = models.CharField(max_length=32, choices=ROLE_CHOICES)
|
||||
content = models.TextField(blank=True, default="")
|
||||
images = models.JSONField(default=list, blank=True)
|
||||
raw_response = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "farm_ai_messages"
|
||||
ordering = ["created_at", "id"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.role}: {self.uuid}"
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "Farm AI Assistant",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"description": "Farm AI Assistant API. Context, chat send, chat list/create, message history, and chat delete."
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Get farm context (GET)",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [{"key": "Content-Type", "value": "application/json"}],
|
||||
"url": "{{baseUrl}}/api/farm-ai-assistant/context/",
|
||||
"description": "Returns static farm context for the context bar."
|
||||
},
|
||||
"response": [
|
||||
{
|
||||
"name": "Success",
|
||||
"status": "OK",
|
||||
"code": 200,
|
||||
"body": "{\n \"status\": \"success\",\n \"data\": {\n \"soilType\": \"Loamy\",\n \"waterEC\": \"1.2 dS/m\",\n \"selectedCrop\": \"Tomato\",\n \"growthStage\": \"Flowering\",\n \"lastIrrigationStatus\": \"2 days ago\"\n }\n}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "List chats (GET)",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [{"key": "Content-Type", "value": "application/json"}],
|
||||
"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 {\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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variable": [{"key": "baseUrl", "value": "http://localhost:8000"}]
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import Message
|
||||
|
||||
|
||||
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)
|
||||
primaryAction = serializers.CharField(required=False, allow_blank=True)
|
||||
validityPeriod = serializers.CharField(required=False, allow_blank=True)
|
||||
expandableExplanation = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class ConversationSummarySerializer(serializers.Serializer):
|
||||
id = serializers.UUIDField(source="uuid", read_only=True)
|
||||
title = serializers.CharField(read_only=True)
|
||||
farm_uuid = serializers.UUIDField(source="farm.farm_uuid", read_only=True, allow_null=True)
|
||||
message_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
|
||||
class ConversationCreateSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField(required=False, allow_null=True)
|
||||
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)
|
||||
farm_uuid = serializers.UUIDField(read_only=True, allow_null=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)
|
||||
farm_uuid = serializers.UUIDField(read_only=True, allow_null=True)
|
||||
messages = ChatHistoryMessageSerializer(many=True, read_only=True)
|
||||
|
||||
|
||||
class ChatResponseDataSerializer(serializers.JSONField):
|
||||
pass
|
||||
|
||||
|
||||
class ConversationDeleteSerializer(serializers.Serializer):
|
||||
conversation_id = serializers.UUIDField(read_only=True)
|
||||
farm_uuid = serializers.UUIDField(read_only=True, allow_null=True)
|
||||
|
||||
|
||||
class ChatPostSerializer(serializers.Serializer):
|
||||
farm_uuid = serializers.UUIDField(required=True)
|
||||
query = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
history = serializers.JSONField(required=False)
|
||||
image_urls = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
default=list,
|
||||
)
|
||||
images = serializers.ListField(
|
||||
child=serializers.CharField(),
|
||||
required=False,
|
||||
default=list,
|
||||
)
|
||||
conversation_id = serializers.UUIDField(required=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
query = (attrs.get("query") or "").strip()
|
||||
image_urls = attrs.get("image_urls") or []
|
||||
images = attrs.get("images") or []
|
||||
history = attrs.get("history", [])
|
||||
|
||||
if isinstance(history, str):
|
||||
try:
|
||||
history = serializers.JSONField().to_internal_value(history)
|
||||
except serializers.ValidationError as exc:
|
||||
raise serializers.ValidationError({"history": exc.detail}) from exc
|
||||
|
||||
if history in (None, ""):
|
||||
history = []
|
||||
if not isinstance(history, list):
|
||||
raise serializers.ValidationError({"history": ["History must be an array or a valid JSON array string."]})
|
||||
|
||||
if not query and not image_urls and not images:
|
||||
raise serializers.ValidationError({"query": ["This field may not be blank unless an image is sent."]})
|
||||
|
||||
attrs["query"] = query
|
||||
attrs["history"] = history
|
||||
return attrs
|
||||
@@ -0,0 +1,412 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||
from unittest.mock import patch
|
||||
|
||||
from external_api_adapter.adapter import AdapterResponse
|
||||
from farm_hub.models import FarmHub, FarmType
|
||||
|
||||
from .models import Conversation, Message
|
||||
from .views import (
|
||||
ChatDetailView,
|
||||
ChatListCreateView,
|
||||
ChatMessagesView,
|
||||
ChatTaskCreateView,
|
||||
ChatTaskStatusView,
|
||||
ContextView,
|
||||
ChatView,
|
||||
)
|
||||
|
||||
|
||||
@override_settings(USE_EXTERNAL_API_MOCK=True)
|
||||
class FarmAiAssistantOptionalFarmUuidTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="farmer",
|
||||
password="secret123",
|
||||
email="farmer@example.com",
|
||||
phone_number="09120000000",
|
||||
)
|
||||
self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی")
|
||||
self.farm = FarmHub.objects.create(
|
||||
owner=self.user,
|
||||
farm_type=self.farm_type,
|
||||
name="Farm 1",
|
||||
)
|
||||
|
||||
def test_context_allows_missing_farm_uuid(self):
|
||||
request = self.factory.get("/api/farm-ai-assistant/context/")
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = ContextView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["status"], "success")
|
||||
self.assertIsNone(response.data["data"]["farm_uuid"])
|
||||
|
||||
def test_chat_task_create_allows_missing_farm_uuid_for_landing_chat(self):
|
||||
request = self.factory.post(
|
||||
"/api/farm-ai-assistant/chat/task/",
|
||||
{"content": "Give me a landing page recommendation"},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = ChatTaskCreateView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertEqual(response.data["status"], "success")
|
||||
self.assertEqual(response.data["data"]["task_id"], "farm-ai-chat-task-123")
|
||||
self.assertIsNone(response.data["data"]["farm_uuid"])
|
||||
|
||||
conversation = Conversation.objects.get(uuid=response.data["data"]["conversation_id"])
|
||||
self.assertIsNone(conversation.farm)
|
||||
self.assertEqual(conversation.owner_id, self.user.id)
|
||||
|
||||
user_message = conversation.messages.get(role=Message.ROLE_USER)
|
||||
self.assertIsNone(user_message.farm)
|
||||
self.assertIsNone(user_message.raw_response["farm_uuid"])
|
||||
|
||||
def test_status_success_without_farm_uuid_persists_assistant_message(self):
|
||||
conversation = Conversation.objects.create(
|
||||
owner=self.user,
|
||||
farm=None,
|
||||
title="Landing chat",
|
||||
farm_context={},
|
||||
)
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
farm=None,
|
||||
role=Message.ROLE_USER,
|
||||
content="What should I plant?",
|
||||
raw_response={
|
||||
"task_id": "farm-ai-chat-task-123",
|
||||
"status": "PENDING",
|
||||
"status_url": "/api/tasks/farm-ai-chat-task-123/status/",
|
||||
"farm_uuid": None,
|
||||
},
|
||||
)
|
||||
|
||||
request = self.factory.get("/api/farm-ai-assistant/chat/task/farm-ai-chat-task-123/status/")
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = ChatTaskStatusView.as_view()(request, task_id="farm-ai-chat-task-123")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["status"], "success")
|
||||
self.assertEqual(response.data["data"]["task_id"], "farm-ai-chat-task-123")
|
||||
self.assertEqual(response.data["data"]["status"], "SUCCESS")
|
||||
self.assertEqual(response.data["data"]["conversation_id"], str(conversation.uuid))
|
||||
self.assertIsNone(response.data["data"]["farm_uuid"])
|
||||
self.assertEqual(response.data["data"]["result"]["content"], "Here is the recommended plan.")
|
||||
self.assertEqual(response.data["data"]["result"]["task_id"], "farm-ai-chat-task-123")
|
||||
|
||||
assistant_message = (
|
||||
conversation.messages.filter(role=Message.ROLE_ASSISTANT)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
self.assertIsNotNone(assistant_message)
|
||||
self.assertIsNone(assistant_message.farm)
|
||||
self.assertEqual(assistant_message.content, "Here is the recommended plan.")
|
||||
self.assertIsNone(assistant_message.raw_response["farm_uuid"])
|
||||
self.assertEqual(assistant_message.raw_response["task_id"], "farm-ai-chat-task-123")
|
||||
|
||||
def test_status_success_with_farm_uuid_still_works_for_farm_chat(self):
|
||||
conversation = Conversation.objects.create(
|
||||
owner=self.user,
|
||||
farm=self.farm,
|
||||
title="Farm chat",
|
||||
farm_context={},
|
||||
)
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
farm=self.farm,
|
||||
role=Message.ROLE_USER,
|
||||
content="What is the best irrigation plan?",
|
||||
raw_response={
|
||||
"task_id": "farm-ai-chat-task-123",
|
||||
"status": "PENDING",
|
||||
"status_url": "/api/tasks/farm-ai-chat-task-123/status/",
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
},
|
||||
)
|
||||
|
||||
request = self.factory.get(
|
||||
f"/api/farm-ai-assistant/chat/task/farm-ai-chat-task-123/status/?farm_uuid={self.farm.farm_uuid}"
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = ChatTaskStatusView.as_view()(request, task_id="farm-ai-chat-task-123")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["data"]["conversation_id"], str(conversation.uuid))
|
||||
self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid))
|
||||
|
||||
def test_chat_list_create_messages_and_delete_work_without_farm_uuid(self):
|
||||
landing_conversation = Conversation.objects.create(
|
||||
owner=self.user,
|
||||
farm=None,
|
||||
title="Landing chat",
|
||||
farm_context={"source": "landing"},
|
||||
)
|
||||
Message.objects.create(
|
||||
conversation=landing_conversation,
|
||||
farm=None,
|
||||
role=Message.ROLE_USER,
|
||||
content="Hello from landing",
|
||||
raw_response={"farm_uuid": None},
|
||||
)
|
||||
farm_conversation = Conversation.objects.create(
|
||||
owner=self.user,
|
||||
farm=self.farm,
|
||||
title="Farm chat",
|
||||
farm_context={},
|
||||
)
|
||||
Message.objects.create(
|
||||
conversation=farm_conversation,
|
||||
farm=self.farm,
|
||||
role=Message.ROLE_USER,
|
||||
content="Hello from farm",
|
||||
raw_response={"farm_uuid": str(self.farm.farm_uuid)},
|
||||
)
|
||||
|
||||
list_request = self.factory.get("/api/farm-ai-assistant/chats/")
|
||||
force_authenticate(list_request, user=self.user)
|
||||
list_response = ChatListCreateView.as_view()(list_request)
|
||||
|
||||
self.assertEqual(list_response.status_code, 200)
|
||||
self.assertEqual(len(list_response.data["data"]), 1)
|
||||
self.assertEqual(list_response.data["data"][0]["id"], str(landing_conversation.uuid))
|
||||
self.assertIsNone(list_response.data["data"][0]["farm_uuid"])
|
||||
|
||||
create_request = self.factory.post(
|
||||
"/api/farm-ai-assistant/chats/",
|
||||
{"title": "New landing conversation"},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(create_request, user=self.user)
|
||||
create_response = ChatListCreateView.as_view()(create_request)
|
||||
|
||||
self.assertEqual(create_response.status_code, 201)
|
||||
self.assertIsNone(create_response.data["data"]["farm_uuid"])
|
||||
|
||||
created_conversation = Conversation.objects.get(uuid=create_response.data["data"]["id"])
|
||||
self.assertIsNone(created_conversation.farm)
|
||||
|
||||
messages_request = self.factory.get(
|
||||
f"/api/farm-ai-assistant/chats/{landing_conversation.uuid}/messages/"
|
||||
)
|
||||
force_authenticate(messages_request, user=self.user)
|
||||
messages_response = ChatMessagesView.as_view()(
|
||||
messages_request,
|
||||
conversation_id=landing_conversation.uuid,
|
||||
)
|
||||
|
||||
self.assertEqual(messages_response.status_code, 200)
|
||||
self.assertEqual(messages_response.data["data"]["conversation_id"], str(landing_conversation.uuid))
|
||||
self.assertIsNone(messages_response.data["data"]["farm_uuid"])
|
||||
self.assertEqual(len(messages_response.data["data"]["messages"]), 1)
|
||||
self.assertIsNone(messages_response.data["data"]["messages"][0]["farm_uuid"])
|
||||
|
||||
delete_request = self.factory.delete(f"/api/farm-ai-assistant/chats/{landing_conversation.uuid}/")
|
||||
force_authenticate(delete_request, user=self.user)
|
||||
delete_response = ChatDetailView.as_view()(
|
||||
delete_request,
|
||||
conversation_id=landing_conversation.uuid,
|
||||
)
|
||||
|
||||
self.assertEqual(delete_response.status_code, 200)
|
||||
self.assertEqual(delete_response.data["data"]["conversation_id"], str(landing_conversation.uuid))
|
||||
self.assertIsNone(delete_response.data["data"]["farm_uuid"])
|
||||
self.assertFalse(Conversation.objects.filter(uuid=landing_conversation.uuid).exists())
|
||||
|
||||
|
||||
@override_settings(USE_EXTERNAL_API_MOCK=True)
|
||||
class FarmAiAssistantChatViewTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="chat-user",
|
||||
password="secret123",
|
||||
email="chat-user@example.com",
|
||||
phone_number="09120000001",
|
||||
)
|
||||
self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی")
|
||||
self.farm = FarmHub.objects.create(
|
||||
owner=self.user,
|
||||
farm_type=self.farm_type,
|
||||
name="Farm Chat",
|
||||
)
|
||||
|
||||
@patch("farm_ai_assistant.views.external_api_request")
|
||||
def test_chat_reads_content_and_sections_from_nested_result_payload(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data={
|
||||
"content": "برای خاک شما گندم و کلزا مناسب هستند.",
|
||||
"sections": [
|
||||
{
|
||||
"type": "chatTitle",
|
||||
"title": "تناسب خاک برای محصولات مختلف",
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"title": "محصولات مناسب",
|
||||
"items": ["گندم", "کلزا"],
|
||||
}
|
||||
],
|
||||
"extra_field": {"confidence": 0.92},
|
||||
},
|
||||
)
|
||||
|
||||
request = self.factory.post(
|
||||
"/api/farm-ai-assistant/chat/",
|
||||
{
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"query": "خاک من واسه چه محصولاتی مناسبه",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = ChatView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["status"], "success")
|
||||
self.assertEqual(response.data["data"]["content"], "برای خاک شما گندم و کلزا مناسب هستند.")
|
||||
self.assertEqual(response.data["data"]["extra_field"], {"confidence": 0.92})
|
||||
self.assertEqual(response.data["conversation_title"], "تناسب خاک برای محصولات مختلف")
|
||||
self.assertEqual(response.data["data"]["sections"][1]["title"], "محصولات مناسب")
|
||||
|
||||
assistant_message = Message.objects.filter(role=Message.ROLE_ASSISTANT).latest("created_at")
|
||||
self.assertEqual(assistant_message.content, "برای خاک شما گندم و کلزا مناسب هستند.")
|
||||
self.assertEqual(assistant_message.raw_response["sections"][1]["items"], ["گندم", "کلزا"])
|
||||
assistant_message.refresh_from_db()
|
||||
self.assertEqual(assistant_message.conversation.title, "تناسب خاک برای محصولات مختلف")
|
||||
|
||||
@patch("farm_ai_assistant.views.external_api_request")
|
||||
def test_chat_returns_error_when_ai_payload_is_empty(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data={},
|
||||
)
|
||||
|
||||
request = self.factory.post(
|
||||
"/api/farm-ai-assistant/chat/",
|
||||
{
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"query": "خاک من واسه چه محصولاتی مناسبه",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = ChatView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertEqual(response.data["status"], "error")
|
||||
self.assertIn("empty or invalid", response.data["data"]["message"])
|
||||
self.assertEqual(Message.objects.filter(role=Message.ROLE_ASSISTANT).count(), 0)
|
||||
|
||||
@patch("farm_ai_assistant.views.external_api_request")
|
||||
def test_chat_reads_sections_from_fenced_json_text_response(self, mock_external_api_request):
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data="""```json
|
||||
{
|
||||
"answer": "بله، خاک شما برای کاشت گل رز مناسب است.",
|
||||
"sections": [
|
||||
{
|
||||
"type": "recommendation",
|
||||
"title": "جمعبندی اصلی",
|
||||
"content": "بله، خاک شما برای کاشت گل رز مناسب است."
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"title": "نکات اجرایی",
|
||||
"items": ["زهکشی خاک را بررسی کنید."]
|
||||
}
|
||||
]
|
||||
}
|
||||
```""",
|
||||
)
|
||||
|
||||
request = self.factory.post(
|
||||
"/api/farm-ai-assistant/chat/",
|
||||
{
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"query": "خاک من برای گل رز مناسبه؟",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = ChatView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["status"], "success")
|
||||
self.assertEqual(response.data["data"]["answer"], "بله، خاک شما برای کاشت گل رز مناسب است.")
|
||||
self.assertEqual(response.data["conversation_title"], "خاک")
|
||||
self.assertEqual(len(response.data["data"]["sections"]), 2)
|
||||
self.assertEqual(response.data["data"]["sections"][0]["title"], "جمعبندی اصلی")
|
||||
self.assertEqual(response.data["data"]["sections"][1]["items"], ["زهکشی خاک را بررسی کنید."])
|
||||
|
||||
@patch("farm_ai_assistant.views.external_api_request")
|
||||
def test_chat_does_not_change_existing_conversation_title_on_later_turns(self, mock_external_api_request):
|
||||
conversation = Conversation.objects.create(
|
||||
owner=self.user,
|
||||
farm=self.farm,
|
||||
title="عنوان اولیه",
|
||||
farm_context={},
|
||||
)
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
farm=self.farm,
|
||||
role=Message.ROLE_USER,
|
||||
content="پیام اول",
|
||||
raw_response={},
|
||||
)
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
farm=self.farm,
|
||||
role=Message.ROLE_ASSISTANT,
|
||||
content="پاسخ اول",
|
||||
raw_response={"sections": [{"type": "chatTitle", "title": "عنوان اولیه"}]},
|
||||
)
|
||||
|
||||
mock_external_api_request.return_value = AdapterResponse(
|
||||
status_code=200,
|
||||
data={
|
||||
"sections": [
|
||||
{
|
||||
"type": "chatTitle",
|
||||
"title": "عنوان جدید که نباید ذخیره شود",
|
||||
},
|
||||
{
|
||||
"type": "recommendation",
|
||||
"title": "پاسخ جدید",
|
||||
"content": "این فقط پاسخ جدید است.",
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
request = self.factory.post(
|
||||
"/api/farm-ai-assistant/chat/",
|
||||
{
|
||||
"farm_uuid": str(self.farm.farm_uuid),
|
||||
"conversation_id": str(conversation.uuid),
|
||||
"query": "سوال دوم",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
force_authenticate(request, user=self.user)
|
||||
|
||||
response = ChatView.as_view()(request)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["conversation_title"], "عنوان اولیه")
|
||||
conversation.refresh_from_db()
|
||||
self.assertEqual(conversation.title, "عنوان اولیه")
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.urls import path
|
||||
|
||||
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/", 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"),
|
||||
]
|
||||
@@ -0,0 +1,615 @@
|
||||
"""Farm AI Assistant API views."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
|
||||
from django.db.models import Count
|
||||
from django.http import Http404
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.exceptions import ParseError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
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 external_api_adapter.exceptions import ExternalAPIRequestError
|
||||
from farm_hub.models import FarmHub
|
||||
from .defaults import CONTEXT_RESPONSE_TEMPLATE
|
||||
from .models import Conversation, Message
|
||||
from .serializers import (
|
||||
ChatPostSerializer,
|
||||
ChatResponseDataSerializer,
|
||||
ConversationCreateSerializer,
|
||||
ConversationDeleteSerializer,
|
||||
ConversationMessagesSerializer,
|
||||
ConversationSummarySerializer,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FarmAccessMixin:
|
||||
@staticmethod
|
||||
def _get_farm(request, farm_uuid):
|
||||
if not farm_uuid:
|
||||
raise serializers.ValidationError({"farm_uuid": ["This field is required."]})
|
||||
return FarmAccessMixin._get_optional_farm(request, farm_uuid)
|
||||
|
||||
@staticmethod
|
||||
def _get_optional_farm(request, farm_uuid):
|
||||
if not farm_uuid:
|
||||
return None
|
||||
try:
|
||||
return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user)
|
||||
except FarmHub.DoesNotExist as exc:
|
||||
raise Http404("Farm not found") from exc
|
||||
|
||||
@staticmethod
|
||||
def _farm_uuid_or_none(farm):
|
||||
return str(farm.farm_uuid) if farm else None
|
||||
|
||||
|
||||
class ContextView(FarmAccessMixin, APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Farm AI Assistant"],
|
||||
parameters=[
|
||||
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"),
|
||||
],
|
||||
responses={200: status_response("FarmAiAssistantContextResponse", data=serializers.JSONField())},
|
||||
)
|
||||
def get(self, request):
|
||||
farm = self._get_optional_farm(request, request.query_params.get("farm_uuid"))
|
||||
data = deepcopy(CONTEXT_RESPONSE_TEMPLATE)
|
||||
data["farm_uuid"] = self._farm_uuid_or_none(farm)
|
||||
return Response(
|
||||
{"status": "success", "data": data},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class ConversationAccessMixin(FarmAccessMixin):
|
||||
@staticmethod
|
||||
def _is_non_empty_payload(payload):
|
||||
if isinstance(payload, dict):
|
||||
return bool(payload)
|
||||
if isinstance(payload, list):
|
||||
return bool(payload)
|
||||
if isinstance(payload, str):
|
||||
return bool(payload.strip())
|
||||
return payload is not None
|
||||
|
||||
@staticmethod
|
||||
def _parse_adapter_text_payload(adapter_data):
|
||||
if not isinstance(adapter_data, str):
|
||||
return adapter_data
|
||||
|
||||
text = adapter_data.strip()
|
||||
if not text:
|
||||
return adapter_data
|
||||
|
||||
if text.startswith("```"):
|
||||
lines = text.splitlines()
|
||||
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].strip() == "```":
|
||||
text = "\n".join(lines[1:-1]).strip()
|
||||
|
||||
try:
|
||||
return json.loads(text)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Farm AI assistant text response could not be parsed as JSON: preview=%s",
|
||||
text[:200],
|
||||
)
|
||||
return adapter_data
|
||||
|
||||
@staticmethod
|
||||
def _generate_conversation_title(query):
|
||||
normalized_query = (query or "").strip()
|
||||
if not normalized_query:
|
||||
return "Image"
|
||||
first_word = normalized_query.split()[0].strip()
|
||||
return (first_word or normalized_query or "New chat")[:255]
|
||||
|
||||
@staticmethod
|
||||
def _get_conversation(request, conversation_id, farm_uuid=None):
|
||||
filters = {"uuid": conversation_id, "owner": request.user}
|
||||
if farm_uuid:
|
||||
filters["farm__farm_uuid"] = farm_uuid
|
||||
else:
|
||||
filters["farm__isnull"] = True
|
||||
try:
|
||||
return Conversation.objects.select_related("farm").get(**filters)
|
||||
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",
|
||||
"primaryAction",
|
||||
"frequency",
|
||||
"amount",
|
||||
"timing",
|
||||
"validityPeriod",
|
||||
"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 _get_or_create_conversation(self, request, validated):
|
||||
conversation_id = validated.get("conversation_id")
|
||||
farm = self._get_optional_farm(request, validated.get("farm_uuid"))
|
||||
|
||||
if conversation_id:
|
||||
conversation = self._get_conversation(
|
||||
request,
|
||||
conversation_id,
|
||||
farm.farm_uuid if farm else None,
|
||||
)
|
||||
return conversation
|
||||
|
||||
return Conversation.objects.create(
|
||||
owner=request.user,
|
||||
farm=farm,
|
||||
title=self._generate_conversation_title(validated.get("query", "")),
|
||||
farm_context={},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_history_messages(history):
|
||||
normalized_history = []
|
||||
for item in history or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
role = str(item.get("role") or "").strip()
|
||||
content = str(item.get("content") or item.get("message") or "").strip()
|
||||
if not role and not content:
|
||||
continue
|
||||
normalized_item = {}
|
||||
if role:
|
||||
normalized_item["role"] = role
|
||||
if content:
|
||||
normalized_item["content"] = content
|
||||
if item.get("sections") is not None:
|
||||
normalized_item["sections"] = item.get("sections")
|
||||
normalized_history.append(normalized_item)
|
||||
return normalized_history
|
||||
|
||||
@staticmethod
|
||||
def _build_adapter_payload(request, validated, conversation):
|
||||
payload = {
|
||||
"farm_uuid": str(conversation.farm.farm_uuid) if conversation.farm else "",
|
||||
"query": validated.get("query", ""),
|
||||
"history": ConversationAccessMixin._serialize_history_messages(validated.get("history", [])),
|
||||
"image_urls": validated.get("image_urls", []),
|
||||
"images": validated.get("images", []),
|
||||
"conversation_id": str(conversation.uuid),
|
||||
"user_id": request.user.id,
|
||||
}
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _attach_uploaded_files(payload, uploaded_images):
|
||||
if not uploaded_images:
|
||||
return payload
|
||||
|
||||
files = []
|
||||
for uploaded_image in uploaded_images:
|
||||
files.append(
|
||||
(
|
||||
"images",
|
||||
(
|
||||
uploaded_image.name,
|
||||
uploaded_image,
|
||||
getattr(uploaded_image, "content_type", "application/octet-stream"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
multipart_payload = dict(payload)
|
||||
multipart_payload["history"] = json.dumps(payload.get("history", []), ensure_ascii=False)
|
||||
multipart_payload["image_urls"] = json.dumps(payload.get("image_urls", []), ensure_ascii=False)
|
||||
multipart_payload["__files__"] = files
|
||||
return multipart_payload
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_array(value):
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, list) else None
|
||||
|
||||
def _collect_uploaded_images(self, request):
|
||||
uploaded_images = []
|
||||
single_image = request.FILES.get("image")
|
||||
if single_image is not None:
|
||||
uploaded_images.append(single_image)
|
||||
uploaded_images.extend(request.FILES.getlist("images"))
|
||||
return uploaded_images
|
||||
|
||||
def _merge_history(self, validated, conversation):
|
||||
provided_history = validated.get("history", [])
|
||||
if provided_history:
|
||||
return self._serialize_history_messages(provided_history)
|
||||
|
||||
existing_messages = conversation.messages.order_by("created_at")
|
||||
return [
|
||||
{
|
||||
"role": message.role,
|
||||
"content": message.content,
|
||||
**(
|
||||
{"sections": message.raw_response.get("sections", [])}
|
||||
if message.role == Message.ROLE_ASSISTANT and isinstance(message.raw_response, dict)
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for message in existing_messages
|
||||
if message.content
|
||||
or (
|
||||
message.role == Message.ROLE_ASSISTANT
|
||||
and isinstance(message.raw_response, dict)
|
||||
and message.raw_response.get("sections")
|
||||
)
|
||||
]
|
||||
|
||||
def _prepare_chat_input(self, request):
|
||||
mutable_data = request.data.copy()
|
||||
|
||||
for field_name in ("message", "content", "title", "farm_context"):
|
||||
if field_name in mutable_data:
|
||||
mutable_data.pop(field_name)
|
||||
|
||||
if "history" in mutable_data:
|
||||
parsed_history = self._parse_json_array(mutable_data.get("history"))
|
||||
if parsed_history is not None:
|
||||
mutable_data["history"] = parsed_history
|
||||
|
||||
if "image_urls" in mutable_data and isinstance(mutable_data.get("image_urls"), str):
|
||||
parsed_urls = self._parse_json_array(mutable_data.get("image_urls"))
|
||||
if parsed_urls is not None:
|
||||
mutable_data.setlist("image_urls", parsed_urls) if hasattr(mutable_data, "setlist") else mutable_data.__setitem__("image_urls", parsed_urls)
|
||||
|
||||
if "images" in mutable_data and isinstance(mutable_data.get("images"), str):
|
||||
parsed_images = self._parse_json_array(mutable_data.get("images"))
|
||||
if parsed_images is not None:
|
||||
mutable_data.setlist("images", parsed_images) if hasattr(mutable_data, "setlist") else mutable_data.__setitem__("images", parsed_images)
|
||||
|
||||
return mutable_data
|
||||
|
||||
@staticmethod
|
||||
def _extract_message_content(payload):
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
|
||||
for key in ("content", "body", "message", "answer", "text"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
|
||||
sections = payload.get("sections")
|
||||
if isinstance(sections, list):
|
||||
for section in sections:
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
value = section.get("content")
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_chat_title(payload):
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
|
||||
sections = payload.get("sections")
|
||||
if not isinstance(sections, list):
|
||||
return ""
|
||||
|
||||
for section in sections:
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
if section.get("type") != "chatTitle":
|
||||
continue
|
||||
title = section.get("title")
|
||||
if isinstance(title, str) and title.strip():
|
||||
return title.strip()[:255]
|
||||
return ""
|
||||
|
||||
def _extract_assistant_payload(self, adapter_data, conversation):
|
||||
adapter_data = self._parse_adapter_text_payload(adapter_data)
|
||||
|
||||
logger.warning(
|
||||
"Farm AI assistant parsing response: conversation_id=%s adapter_type=%s adapter_keys=%s",
|
||||
str(conversation.uuid),
|
||||
type(adapter_data).__name__,
|
||||
sorted(adapter_data.keys()) if isinstance(adapter_data, dict) else None,
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"Farm AI assistant final parsed payload: conversation_id=%s payload_type=%s is_non_empty=%s",
|
||||
str(conversation.uuid),
|
||||
type(adapter_data).__name__,
|
||||
self._is_non_empty_payload(adapter_data),
|
||||
)
|
||||
return adapter_data
|
||||
|
||||
@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),
|
||||
"farm_uuid": ConversationAccessMixin._farm_uuid_or_none(message.farm),
|
||||
"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"],
|
||||
parameters=[
|
||||
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"),
|
||||
],
|
||||
responses={200: status_response("FarmAiAssistantConversationListResponse", data=ConversationSummarySerializer(many=True))},
|
||||
)
|
||||
def get(self, request):
|
||||
farm = self._get_optional_farm(request, request.query_params.get("farm_uuid"))
|
||||
conversations = (
|
||||
Conversation.objects.filter(owner=request.user, farm=farm)
|
||||
.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
|
||||
farm = self._get_optional_farm(request, validated.get("farm_uuid"))
|
||||
conversation = Conversation.objects.create(
|
||||
owner=request.user,
|
||||
farm=farm,
|
||||
title=validated.get("title", "").strip() or "New chat",
|
||||
farm_context=validated.get("farm_context") or {},
|
||||
)
|
||||
|
||||
response_serializer = ConversationSummarySerializer(
|
||||
{
|
||||
"uuid": conversation.uuid,
|
||||
"farm": farm,
|
||||
"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),
|
||||
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"),
|
||||
],
|
||||
responses={200: status_response("FarmAiAssistantMessageListResponse", data=ConversationMessagesSerializer())},
|
||||
)
|
||||
def get(self, request, conversation_id):
|
||||
farm = self._get_optional_farm(request, request.query_params.get("farm_uuid"))
|
||||
conversation = self._get_conversation(request, conversation_id, farm.farm_uuid if farm else None)
|
||||
messages = conversation.messages.select_related("farm").all()
|
||||
serialized_messages = [self._serialize_chat_message(message) for message in messages]
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.uuid),
|
||||
"farm_uuid": self._farm_uuid_or_none(farm),
|
||||
"messages": serialized_messages,
|
||||
},
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class ChatDetailView(ConversationAccessMixin, APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["Farm AI Assistant"],
|
||||
parameters=[
|
||||
OpenApiParameter(name="conversation_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH),
|
||||
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"),
|
||||
],
|
||||
responses={200: status_response("FarmAiAssistantConversationDeleteResponse", data=ConversationDeleteSerializer())},
|
||||
)
|
||||
def delete(self, request, conversation_id):
|
||||
farm = self._get_optional_farm(request, request.query_params.get("farm_uuid"))
|
||||
conversation = self._get_conversation(request, conversation_id, farm.farm_uuid if farm else None)
|
||||
deleted_conversation_id = str(conversation.uuid)
|
||||
deleted_farm_uuid = self._farm_uuid_or_none(conversation.farm)
|
||||
conversation.delete()
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"conversation_id": deleted_conversation_id,
|
||||
"farm_uuid": deleted_farm_uuid,
|
||||
},
|
||||
},
|
||||
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=ChatResponseDataSerializer())},
|
||||
)
|
||||
def post(self, request):
|
||||
try:
|
||||
chat_input = self._prepare_chat_input(request)
|
||||
except ParseError:
|
||||
return Response(
|
||||
{
|
||||
"status": "error",
|
||||
"data": {
|
||||
"message": "Invalid JSON body. Use valid JSON and remove extra trailing characters.",
|
||||
},
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = ChatPostSerializer(data=chat_input)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
validated = serializer.validated_data
|
||||
conversation = self._get_or_create_conversation(request, validated)
|
||||
is_first_chat_turn = not conversation.messages.exists()
|
||||
history = self._merge_history(validated, conversation)
|
||||
uploaded_images = self._collect_uploaded_images(request)
|
||||
|
||||
user_message = Message.objects.create(
|
||||
conversation=conversation,
|
||||
farm=conversation.farm,
|
||||
role=Message.ROLE_USER,
|
||||
content=validated.get("query", ""),
|
||||
images=validated.get("image_urls", []) + validated.get("images", []),
|
||||
raw_response={
|
||||
"farm_uuid": self._farm_uuid_or_none(conversation.farm),
|
||||
"history": history,
|
||||
},
|
||||
)
|
||||
|
||||
adapter_payload = self._build_adapter_payload(request, validated, conversation)
|
||||
adapter_payload["history"] = history
|
||||
adapter_payload = self._attach_uploaded_files(adapter_payload, uploaded_images)
|
||||
|
||||
try:
|
||||
adapter_response = external_api_request(
|
||||
"ai",
|
||||
"/api/rag/chat/",
|
||||
method="POST",
|
||||
payload=adapter_payload,
|
||||
)
|
||||
logger.warning(
|
||||
"Farm AI assistant adapter response received: conversation_id=%s status_code=%s response_type=%s response_keys=%s",
|
||||
str(conversation.uuid),
|
||||
adapter_response.status_code,
|
||||
type(adapter_response.data).__name__,
|
||||
adapter_response
|
||||
)
|
||||
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)
|
||||
if not self._is_non_empty_payload(assistant_payload):
|
||||
logger.error(
|
||||
"Farm AI assistant returned an empty payload: conversation_id=%s response_type=%s response_keys=%s",
|
||||
str(conversation.uuid),
|
||||
type(adapter_response.data).__name__,
|
||||
sorted(adapter_response.data.keys()) if isinstance(adapter_response.data, dict) else None,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"status": "error",
|
||||
"data": {
|
||||
"message": "AI service returned an empty or invalid response.",
|
||||
},
|
||||
},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
response_status_code = adapter_response.status_code
|
||||
except ExternalAPIRequestError as exc:
|
||||
return Response(
|
||||
{
|
||||
"status": "error",
|
||||
"data": {
|
||||
"message": str(exc) or "External AI service is unavailable.",
|
||||
},
|
||||
},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
assistant_message = Message.objects.create(
|
||||
conversation=conversation,
|
||||
farm=conversation.farm,
|
||||
role=Message.ROLE_ASSISTANT,
|
||||
content=self._extract_message_content(assistant_payload),
|
||||
raw_response=assistant_payload if isinstance(assistant_payload, (dict, list)) else {},
|
||||
)
|
||||
|
||||
chat_title = self._extract_chat_title(assistant_payload)
|
||||
if is_first_chat_turn and chat_title:
|
||||
conversation.title = chat_title
|
||||
conversation.save(update_fields=["title", "updated_at"])
|
||||
elif not conversation.title:
|
||||
conversation.title = self._generate_conversation_title(validated.get("query", ""))
|
||||
conversation.save(update_fields=["title", "updated_at"])
|
||||
else:
|
||||
conversation.save(update_fields=["updated_at"])
|
||||
|
||||
return Response(
|
||||
{
|
||||
"status": "success",
|
||||
"conversation_id": str(conversation.uuid),
|
||||
"farm_uuid": self._farm_uuid_or_none(conversation.farm),
|
||||
"data": assistant_payload,
|
||||
"conversation_title": conversation.title,
|
||||
},
|
||||
status=response_status_code,
|
||||
)
|
||||
Reference in New Issue
Block a user