diff --git a/.env.example b/.env.example index 16dd2cf..cea2d1e 100644 --- a/.env.example +++ b/.env.example @@ -2,12 +2,12 @@ SECRET_KEY=your-secret-key-change-in-production DEBUG=1 DOCKER_VERSION=develop -ALLOWED_HOSTS=node.crop-logic.ir,crop-logic.ir,localhost,127.0.0.1,0.0.0.0 +ALLOWED_HOSTS=node.crop-logic.ir,crop-logic.ir,localhost,127.0.0.1,0.0.0.0,web,backend-web # Database (MySQL) DB_ENGINE=django.db.backends.mysql -DB_NAME=backend -DB_USER=backend +DB_NAME=croplogic +DB_USER=croplogic DB_PASSWORD=changeme DB_HOST=db DB_PORT=3306 @@ -27,7 +27,8 @@ ACCESS_CONTROL_AUTHZ_BATCH_PATH=/v1/data/croplogic/authz/batch_decision ACCESS_CONTROL_AUTHZ_TIMEOUT=30 ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT=300 -AI_SERVICE_BASE_URL=https://ai.example.com +AI_SERVICE_BASE_URL=http://ai-web:8000 +AI_SERVICE_HOST_HEADER=localhost AI_SERVICE_API_KEY= FARM_HUB_SERVICE_BASE_URL=https://farm-hub.example.com diff --git a/config/settings.py b/config/settings.py index 91d23ed..c5755ed 100644 --- a/config/settings.py +++ b/config/settings.py @@ -8,9 +8,18 @@ load_dotenv() BASE_DIR = Path(__file__).resolve().parent.parent + +def _get_csv_env(name, default=""): + return [item.strip() for item in os.environ.get(name, default).split(",") if item.strip()] + SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only") DEBUG = os.environ.get("DEBUG", "0") == "1" -ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") +ALLOWED_HOSTS = list( + dict.fromkeys( + _get_csv_env("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0") + + ["web", "backend-web", os.environ.get("HOSTNAME", "")] + ) +) AUTH_USER_MODEL = "account.User" @@ -178,12 +187,14 @@ ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT = int(os.getenv("ACCESS_CONTROL_AUTHZ_CACHE_T EXTERNAL_SERVICES = { "ai": { - "base_url": os.getenv("AI_SERVICE_BASE_URL", ""), + "base_url": os.getenv("AI_SERVICE_BASE_URL", "http://ai-web:8000"), "api_key": os.getenv("AI_SERVICE_API_KEY", ""), + "host_header": os.getenv("AI_SERVICE_HOST_HEADER", "localhost"), }, "farm_hub": { "base_url": os.getenv("FARM_HUB_SERVICE_BASE_URL", ""), "api_key": os.getenv("FARM_HUB_SERVICE_API_KEY", ""), + "host_header": os.getenv("FARM_HUB_SERVICE_HOST_HEADER", ""), }, } diff --git a/dashboard/migrations/0002_alter_farmdashboardconfig_row_order.py b/dashboard/migrations/0002_alter_farmdashboardconfig_row_order.py new file mode 100644 index 0000000..5db19b8 --- /dev/null +++ b/dashboard/migrations/0002_alter_farmdashboardconfig_row_order.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.15 on 2026-04-25 21:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='farmdashboardconfig', + name='row_order', + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/docker-compose-prod.yaml b/docker-compose-prod.yaml index 80f31c0..e14ef45 100644 --- a/docker-compose-prod.yaml +++ b/docker-compose-prod.yaml @@ -1,7 +1,7 @@ services: db: image: docker.iranserver.com/mysql:8 - container_name: backend-db + container_name: croplogic-db environment: MYSQL_DATABASE: ${DB_NAME} MYSQL_USER: ${DB_USER} @@ -9,6 +9,8 @@ services: MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} volumes: - backend_mysql_data:/var/lib/mysql + ports: + - "3306:3306" restart: unless-stopped healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"] @@ -51,9 +53,12 @@ services: - .env environment: DOCKER_VERSION: ${DOCKER_VERSION:-production} - DB_HOST: db - CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0} - CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web} + AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000} + AI_SERVICE_HOST_HEADER: ${AI_SERVICE_HOST_HEADER:-localhost} + DB_HOST: croplogic-db + CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://backend-redis:6379/0} + CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://backend-redis:6379/0} QDRANT_HOST: ${QDRANT_HOST:-qdrant} QDRANT_PORT: ${QDRANT_PORT:-6333} SKIP_MIGRATE: "0" @@ -77,9 +82,12 @@ services: - .env environment: DOCKER_VERSION: ${DOCKER_VERSION:-production} - DB_HOST: db - CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0} - CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web} + AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000} + AI_SERVICE_HOST_HEADER: ${AI_SERVICE_HOST_HEADER:-localhost} + DB_HOST: croplogic-db + CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://backend-redis:6379/0} + CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://backend-redis:6379/0} CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true" SKIP_MIGRATE: "1" ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181 diff --git a/docker-compose.yaml b/docker-compose.yaml index 2cbc988..b6ac482 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,16 +1,17 @@ services: db: image: docker.iranserver.com/mysql:8 - container_name: backend-db + container_name: croplogic-db environment: - MYSQL_DATABASE: ${DB_NAME:-backend} - MYSQL_USER: ${DB_USER:-backend} + MYSQL_DATABASE: ${DB_NAME:-croplogic} + MYSQL_USER: ${DB_USER:-croplogic} MYSQL_PASSWORD: ${DB_PASSWORD:-changeme} - MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-changeme} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root} volumes: - backend_mysql_data:/var/lib/mysql + healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-changeme}"] + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-root}"] interval: 5s timeout: 5s retries: 5 @@ -21,7 +22,7 @@ services: image: docker-mirror.liara.ir/phpmyadmin:latest container_name: backend-phpmyadmin environment: - PMA_HOST: db + PMA_HOST: croplogic-db PMA_PORT: 3306 UPLOAD_LIMIT: 64M ports: @@ -68,9 +69,11 @@ services: - .env environment: DOCKER_VERSION: ${DOCKER_VERSION:-develop} - DB_HOST: db - CELERY_BROKER_URL: redis://redis:6379/0 - CELERY_RESULT_BACKEND: redis://redis:6379/0 + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web} + AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000} + DB_HOST: croplogic-db + CELERY_BROKER_URL: redis://backend-redis:6379/0 + CELERY_RESULT_BACKEND: redis://backend-redis:6379/0 QDRANT_HOST: qdrant QDRANT_PORT: 6333 SKIP_MIGRATE: "0" @@ -101,9 +104,11 @@ services: - .env environment: DOCKER_VERSION: ${DOCKER_VERSION:-develop} - DB_HOST: db - CELERY_BROKER_URL: redis://redis:6379/0 - CELERY_RESULT_BACKEND: redis://redis:6379/0 + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web} + AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000} + DB_HOST: croplogic-db + CELERY_BROKER_URL: redis://backend-redis:6379/0 + CELERY_RESULT_BACKEND: redis://backend-redis:6379/0 CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true" SKIP_MIGRATE: "1" ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181 diff --git a/external_api_adapter/adapter.py b/external_api_adapter/adapter.py index 31aac0c..343b675 100644 --- a/external_api_adapter/adapter.py +++ b/external_api_adapter/adapter.py @@ -4,6 +4,7 @@ import requests from django.conf import settings from .exceptions import ExternalAPIRequestError +from .exceptions import MockDirectoryNotFound, MockFileNotFound from .mock_loader import MockLoader from .services import ServiceRegistry @@ -25,15 +26,19 @@ class ExternalAPIAdapter: request_method = method.upper() self._validate_method(request_method) service = self.service_registry.get(service_name) - - if getattr(settings, "USE_EXTERNAL_API_MOCK", False): - mock_response = self.mock_loader.load(service_name=service_name, path=path, method=request_method) - return AdapterResponse( - status_code=mock_response.status_code, - data=mock_response.data, - headers={"X-Mock-File": mock_response.file_path}, - is_mock=True, - ) + + use_mock = getattr(settings, "USE_EXTERNAL_API_MOCK", False) and service_name != "ai" + if use_mock: + try: + mock_response = self.mock_loader.load(service_name=service_name, path=path, method=request_method) + return AdapterResponse( + status_code=mock_response.status_code, + data=mock_response.data, + headers={"X-Mock-File": mock_response.file_path}, + is_mock=True, + ) + except (MockDirectoryNotFound, MockFileNotFound): + pass return self._call_real_api( service=service, @@ -47,25 +52,47 @@ class ExternalAPIAdapter: def _call_real_api(self, service, path, method, payload=None, query=None, headers=None): base_url = service.get("base_url", "").rstrip("/") api_key = service.get("api_key", "") + host_header = service.get("host_header", "").strip() if not base_url: raise ExternalAPIRequestError("External service base_url is not configured.") url = f"{base_url}/{str(path).lstrip('/')}" + files = None + request_payload = payload request_headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } + if host_header: + request_headers["Host"] = host_header if headers: request_headers.update(headers) + if isinstance(payload, dict) and payload.get("__files__"): + files = payload["__files__"] + request_payload = { + key: value + for key, value in payload.items() + if key != "__files__" + } + request_headers.pop("Content-Type", None) + try: + request_kwargs = { + "method": method, + "url": url, + "params": query, + "headers": request_headers, + "timeout": getattr(settings, "EXTERNAL_API_TIMEOUT", 30), + } + if files: + request_kwargs["data"] = request_payload + request_kwargs["files"] = files + else: + request_kwargs["json"] = request_payload + response = requests.request( - method=method, - url=url, - json=payload, - params=query, - headers=request_headers, - timeout=getattr(settings, "EXTERNAL_API_TIMEOUT", 30), + **request_kwargs, ) except requests.RequestException as exc: raise ExternalAPIRequestError(f"External API request failed for '{url}': {exc}") from exc diff --git a/farm_ai_assistant/serializers.py b/farm_ai_assistant/serializers.py index 25ca088..6ae3d1b 100644 --- a/farm_ai_assistant/serializers.py +++ b/farm_ai_assistant/serializers.py @@ -12,6 +12,8 @@ class ChatSectionSerializer(serializers.Serializer): 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) @@ -57,40 +59,42 @@ class ConversationDeleteSerializer(serializers.Serializer): farm_uuid = serializers.UUIDField(read_only=True, allow_null=True) -class ChatTaskSubmitDataSerializer(serializers.Serializer): - task_id = serializers.CharField(required=False, allow_blank=True) - status = serializers.CharField(required=False, allow_blank=True) - status_url = serializers.CharField(required=False, allow_blank=True) - conversation_id = serializers.UUIDField(read_only=True) - message_id = serializers.UUIDField(read_only=True) - farm_uuid = serializers.UUIDField(read_only=True, allow_null=True) - - -class ChatTaskStatusDataSerializer(serializers.Serializer): - task_id = serializers.CharField(required=False, allow_blank=True) - status = serializers.CharField(required=False, allow_blank=True) - conversation_id = serializers.UUIDField(read_only=True) - farm_uuid = serializers.UUIDField(read_only=True, allow_null=True) - progress = serializers.JSONField(required=False) - result = serializers.JSONField(required=False) - error = serializers.CharField(required=False, allow_blank=True) - - class ChatPostSerializer(serializers.Serializer): - farm_uuid = serializers.UUIDField(required=False, allow_null=True) - content = serializers.CharField(required=False, allow_blank=True, default="") + 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) - 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() + query = (attrs.get("query") or "").strip() + image_urls = attrs.get("image_urls") or [] images = attrs.get("images") or [] - if not content and not images: - raise serializers.ValidationError("Either content or images is required.") + 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 diff --git a/farm_ai_assistant/urls.py b/farm_ai_assistant/urls.py index 06610de..dd3581c 100644 --- a/farm_ai_assistant/urls.py +++ b/farm_ai_assistant/urls.py @@ -4,17 +4,13 @@ from .views import ( ChatDetailView, ChatListCreateView, ChatMessagesView, - ChatTaskCreateView, - ChatTaskStatusView, ChatView, ContextView, ) urlpatterns = [ path("context/", ContextView.as_view(), name="farm-ai-assistant-context"), - # path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"), - path("chat/task/", ChatTaskCreateView.as_view(), name="farm-ai-assistant-chat-task-create"), - path("chat/task//status/", ChatTaskStatusView.as_view(), name="farm-ai-assistant-chat-task-status"), + path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"), 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 4456fd0..f904a7f 100644 --- a/farm_ai_assistant/views.py +++ b/farm_ai_assistant/views.py @@ -1,10 +1,12 @@ """Farm AI Assistant API views.""" +import json 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 @@ -15,13 +17,11 @@ 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 .mock_data import CHAT_RESPONSE_DATA, CONTEXT_RESPONSE_DATA +from .mock_data import CONTEXT_RESPONSE_DATA from .models import Conversation, Message from .serializers import ( ChatPostSerializer, ChatResponseDataSerializer, - ChatTaskStatusDataSerializer, - ChatTaskSubmitDataSerializer, ConversationCreateSerializer, ConversationDeleteSerializer, ConversationMessagesSerializer, @@ -71,6 +71,14 @@ class ContextView(FarmAccessMixin, APIView): class ConversationAccessMixin(FarmAccessMixin): + @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} @@ -94,9 +102,11 @@ class ConversationAccessMixin(FarmAccessMixin): "content", "items", "icon", + "primaryAction", "frequency", "amount", "timing", + "validityPeriod", "expandableExplanation", } normalized_sections = [] @@ -119,16 +129,8 @@ class ConversationAccessMixin(FarmAccessMixin): normalized_sections.append(normalized_section) return normalized_sections - def _build_mock_assistant_payload(self, conversation): - payload = deepcopy(CHAT_RESPONSE_DATA) - payload["conversation_id"] = str(conversation.uuid) - payload["farm_uuid"] = self._farm_uuid_or_none(conversation.farm) - return payload - def _get_or_create_conversation(self, request, validated): conversation_id = validated.get("conversation_id") - farm_context = validated.get("farm_context") - title = validated.get("title", "").strip() farm = self._get_optional_farm(request, validated.get("farm_uuid")) if conversation_id: @@ -137,42 +139,130 @@ class ConversationAccessMixin(FarmAccessMixin): conversation_id, farm.farm_uuid if farm else None, ) - updated_fields = [] - if farm_context is not None: - conversation.farm_context = farm_context - updated_fields.append("farm_context") - if title: - conversation.title = title - updated_fields.append("title") - if updated_fields: - updated_fields.append("updated_at") - conversation.save(update_fields=updated_fields) return conversation return Conversation.objects.create( owner=request.user, farm=farm, - title=title or (validated.get("content", "")[:255]) or "New chat", - farm_context=farm_context or {}, + 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 = { - "content": validated.get("content", ""), - "query": validated.get("content", ""), + "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, } - if conversation.farm: - payload["farm_uuid"] = str(conversation.farm.farm_uuid) - if "farm_context" in validated: - payload["farm_context"] = validated.get("farm_context") or {} - if "title" in validated: - payload["title"] = validated.get("title", "") 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 else {}), + } + for message in existing_messages + if message.content or (message.role == Message.ROLE_ASSISTANT 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 + def _extract_assistant_payload(self, adapter_data, conversation): payload_source = adapter_data if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict): @@ -199,80 +289,6 @@ class ConversationAccessMixin(FarmAccessMixin): "sections": sections, } - @staticmethod - def _extract_task_submit_payload(adapter_data, conversation, message_id): - payload_source = adapter_data - if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict): - payload_source = adapter_data["data"] - - if not isinstance(payload_source, dict): - payload_source = {} - - return { - "task_id": str(payload_source.get("task_id") or ""), - "status": str(payload_source.get("status") or ""), - "status_url": str(payload_source.get("status_url") or ""), - "conversation_id": str(conversation.uuid), - "message_id": str(message_id), - "farm_uuid": ConversationAccessMixin._farm_uuid_or_none(conversation.farm), - } - - def _extract_task_status_payload(self, adapter_data, task_id, conversation=None, farm_uuid=None): - payload_source = adapter_data - if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict): - payload_source = adapter_data["data"] - - if not isinstance(payload_source, dict): - payload_source = {} - - task_status_payload = { - "task_id": str(payload_source.get("task_id") or task_id), - "status": str(payload_source.get("status") or ""), - } - if conversation: - task_status_payload["conversation_id"] = str(conversation.uuid) - task_status_payload["farm_uuid"] = self._farm_uuid_or_none(conversation.farm) - elif farm_uuid is not None: - task_status_payload["farm_uuid"] = str(farm_uuid) - - progress = payload_source.get("progress") - if progress is not None: - task_status_payload["progress"] = progress - elif payload_source.get("message") and task_status_payload["status"] != "SUCCESS": - task_status_payload["progress"] = {"message": payload_source.get("message")} - - if payload_source.get("error"): - task_status_payload["error"] = str(payload_source["error"]) - - result = payload_source.get("result") - if result is not None: - task_status_payload["result"] = result - - return task_status_payload - - def _extract_structured_task_result(self, adapter_data): - payload_source = adapter_data - if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict): - payload_source = adapter_data["data"] - - if not isinstance(payload_source, dict): - return None - - result = payload_source.get("result") - if isinstance(result, dict): - return result - - if payload_source.get("status") == "SUCCESS": - content = payload_source.get("content") - sections = payload_source.get("sections") - if content or sections: - return { - "content": content or "", - "sections": sections or [], - } - - return None - @staticmethod def _serialize_chat_message(message): raw_response = message.raw_response if isinstance(message.raw_response, dict) else {} @@ -288,62 +304,6 @@ class ConversationAccessMixin(FarmAccessMixin): "created_at": message.created_at, } - @staticmethod - def _find_user_message_for_task(request, task_id, farm_uuid): - filters = { - "conversation__owner": request.user, - "role": Message.ROLE_USER, - "raw_response__task_id": task_id, - } - if farm_uuid: - filters["farm__farm_uuid"] = farm_uuid - else: - filters["farm__isnull"] = True - return ( - Message.objects.select_related("conversation", "farm") - .filter(**filters) - .order_by("-created_at") - .first() - ) - - def _persist_task_result(self, user_message, task_id, result): - assistant_payload = self._extract_assistant_payload(result, user_message.conversation) - assistant_message = ( - user_message.conversation.messages.filter( - role=Message.ROLE_ASSISTANT, - raw_response__task_id=task_id, - ) - .order_by("-created_at") - .first() - ) - - if assistant_message is None: - assistant_message = Message.objects.create( - conversation=user_message.conversation, - farm=user_message.farm, - role=Message.ROLE_ASSISTANT, - content=assistant_payload.get("content", ""), - raw_response={}, - ) - - assistant_payload["message_id"] = str(assistant_message.uuid) - assistant_payload["task_id"] = task_id - assistant_message.content = assistant_payload.get("content", "") - assistant_message.raw_response = assistant_payload - assistant_message.save(update_fields=["content", "raw_response"]) - - conversation = user_message.conversation - if not conversation.title: - conversation.title = ( - user_message.content or assistant_payload.get("content", "") or "New chat" - )[:255] - conversation.save(update_fields=["title", "updated_at"]) - else: - conversation.save(update_fields=["updated_at"]) - - return assistant_payload - - class ChatListCreateView(ConversationAccessMixin, APIView): permission_classes = [IsAuthenticated] @@ -459,27 +419,47 @@ class ChatView(ConversationAccessMixin, APIView): responses={200: status_response("FarmAiAssistantChatResponse", data=ChatResponseDataSerializer())}, ) def post(self, request): - serializer = ChatPostSerializer(data=request.data) + 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) + 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("content", ""), - images=validated.get("images", []), - raw_response={"farm_uuid": self._farm_uuid_or_none(conversation.farm)}, + 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", - "/rag/chat", + "/api/rag/chat/", method="POST", payload=adapter_payload, ) @@ -493,9 +473,16 @@ class ChatView(ConversationAccessMixin, APIView): ) assistant_payload = self._extract_assistant_payload(adapter_response.data, conversation) response_status_code = adapter_response.status_code - except ExternalAPIRequestError: - assistant_payload = self._build_mock_assistant_payload(conversation) - response_status_code = status.HTTP_200_OK + 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, @@ -509,7 +496,7 @@ class ChatView(ConversationAccessMixin, APIView): assistant_message.save(update_fields=["raw_response"]) if not conversation.title: - conversation.title = (validated.get("content", "") or assistant_payload.get("content", "") or "New chat")[:255] + conversation.title = self._generate_conversation_title(validated.get("query", "")) conversation.save(update_fields=["title", "updated_at"]) else: conversation.save(update_fields=["updated_at"]) @@ -521,142 +508,3 @@ class ChatView(ConversationAccessMixin, APIView): }, status=response_status_code, ) - - -class ChatTaskCreateView(ConversationAccessMixin, APIView): - permission_classes = [IsAuthenticated] - - @extend_schema( - tags=["Farm AI Assistant"], - request=ChatPostSerializer, - responses={202: status_response("FarmAiAssistantChatTaskCreateResponse", data=ChatTaskSubmitDataSerializer())}, - ) - def post(self, request): - serializer = ChatPostSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - validated = serializer.validated_data - conversation = self._get_or_create_conversation(request, validated) - user_message = Message.objects.create( - conversation=conversation, - farm=conversation.farm, - role=Message.ROLE_USER, - content=validated.get("content", ""), - images=validated.get("images", []), - raw_response={"farm_uuid": self._farm_uuid_or_none(conversation.farm)}, - ) - - adapter_payload = self._build_adapter_payload(request, validated, conversation) - try: - adapter_response = external_api_request( - "ai", - "/rag/chat/generate", - method="POST", - payload=adapter_payload, - ) - except ExternalAPIRequestError: - return Response( - { - "status": "error", - "data": { - "message": "External AI service is unavailable.", - }, - }, - status=status.HTTP_503_SERVICE_UNAVAILABLE, - ) - - if adapter_response.status_code >= 400: - return Response( - { - "status": "error", - "data": adapter_response.data, - }, - status=adapter_response.status_code, - ) - - task_payload = self._extract_task_submit_payload( - adapter_response.data, - conversation, - user_message.uuid, - ) - user_message.raw_response = task_payload - user_message.save(update_fields=["raw_response"]) - conversation.save(update_fields=["updated_at"]) - - return Response( - { - "status": "success", - "data": task_payload, - }, - status=adapter_response.status_code, - ) - - -class ChatTaskStatusView(ConversationAccessMixin, APIView): - permission_classes = [IsAuthenticated] - - @extend_schema( - tags=["Farm AI Assistant"], - parameters=[ - OpenApiParameter(name="task_id", type=OpenApiTypes.STR, 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("FarmAiAssistantChatTaskStatusResponse", data=ChatTaskStatusDataSerializer())}, - ) - def get(self, request, task_id): - farm = self._get_optional_farm(request, request.query_params.get("farm_uuid")) - try: - query = {} - if farm: - query["farm_uuid"] = str(farm.farm_uuid) - adapter_response = external_api_request( - "ai", - f"/tasks/{task_id}/status", - method="GET", - query=query, - ) - except ExternalAPIRequestError: - return Response( - { - "status": "error", - "data": { - "message": "External AI service is unavailable.", - }, - }, - status=status.HTTP_503_SERVICE_UNAVAILABLE, - ) - - if adapter_response.status_code >= 400: - return Response( - { - "status": "error", - "data": adapter_response.data, - }, - status=adapter_response.status_code, - ) - - farm_uuid = farm.farm_uuid if farm else None - user_message = self._find_user_message_for_task(request, task_id, farm_uuid) - conversation = user_message.conversation if user_message else None - task_status_payload = self._extract_task_status_payload( - adapter_response.data, - task_id, - conversation=conversation, - farm_uuid=farm_uuid, - ) - - result = self._extract_structured_task_result(adapter_response.data) - if result is not None: - task_status_payload["result"] = result - - if user_message and task_status_payload.get("status") == "SUCCESS" and isinstance(result, dict): - assistant_payload = self._persist_task_result(user_message, task_id, result) - task_status_payload["result"] = assistant_payload - - return Response( - { - "status": "success", - "data": task_status_payload, - }, - status=adapter_response.status_code, - ) diff --git a/farm_alerts/migrations/0002_alter_anomalydetection_severity_and_more.py b/farm_alerts/migrations/0002_alter_anomalydetection_severity_and_more.py new file mode 100644 index 0000000..2aec3c8 --- /dev/null +++ b/farm_alerts/migrations/0002_alter_anomalydetection_severity_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.15 on 2026-04-25 21:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('farm_alerts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='anomalydetection', + name='severity', + field=models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('success', 'Success')], default='warning', max_length=32), + ), + migrations.AlterField( + model_name='farmalert', + name='color', + field=models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('success', 'Success')], default='info', max_length=32), + ), + ] diff --git a/sensor_catalog/migrations/0004_alter_sensorcatalog_options.py b/sensor_catalog/migrations/0004_alter_sensorcatalog_options.py new file mode 100644 index 0000000..3b9df2f --- /dev/null +++ b/sensor_catalog/migrations/0004_alter_sensorcatalog_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.15 on 2026-04-25 21:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('sensor_catalog', '0003_sensorcatalog_code'), + ] + + operations = [ + migrations.AlterModelOptions( + name='sensorcatalog', + options={'ordering': ['code']}, + ), + ]