diff --git a/docs/API_AI_SUGGESTIONS.md b/docs/API_AI_SUGGESTIONS.md index 6dd755c39..d2756ac41 100644 --- a/docs/API_AI_SUGGESTIONS.md +++ b/docs/API_AI_SUGGESTIONS.md @@ -108,16 +108,16 @@ Apply an AI suggestion to a document and record user feedback. } ``` -**Suggestion Types:** +**Supported Suggestion Types:** - `tag` - Tag assignment - `correspondent` - Correspondent assignment - `document_type` - Document type classification - `storage_path` - Storage path assignment -- `custom_field` - Custom field value -- `workflow` - Workflow assignment - `title` - Document title -**For ID-based suggestions (tag, correspondent, document_type, storage_path, workflow):** +**Note:** Custom field and workflow suggestions are supported in the API response but not yet implemented in the apply endpoint. + +**For ID-based suggestions (tag, correspondent, document_type, storage_path):** ```json { "suggestion_type": "correspondent", @@ -126,7 +126,7 @@ Apply an AI suggestion to a document and record user feedback. } ``` -**For text-based suggestions (title, custom_field):** +**For text-based suggestions (title):** ```json { "suggestion_type": "title", diff --git a/src/documents/serializers/ai_suggestions.py b/src/documents/serializers/ai_suggestions.py index ffc6eb5a0..f793482de 100644 --- a/src/documents/serializers/ai_suggestions.py +++ b/src/documents/serializers/ai_suggestions.py @@ -15,7 +15,6 @@ from documents.models import ( AISuggestionFeedback, Correspondent, CustomField, - Document, DocumentType, StoragePath, Tag, @@ -23,6 +22,24 @@ from documents.models import ( ) +# Suggestion type choices - used across multiple serializers +SUGGESTION_TYPE_CHOICES = [ + 'tag', + 'correspondent', + 'document_type', + 'storage_path', + 'custom_field', + 'workflow', + 'title', +] + +# Types that require value_id +ID_REQUIRED_TYPES = ['tag', 'correspondent', 'document_type', 'storage_path', 'workflow'] +# Types that require value_text +TEXT_REQUIRED_TYPES = ['title'] +# Types that can use either (custom_field can be ID or text) + + class TagSuggestionSerializer(serializers.Serializer): """Serializer for tag suggestions.""" @@ -122,6 +139,7 @@ class AISuggestionsSerializer(serializers.Serializer): 'confidence': confidence, }) except Tag.DoesNotExist: + # Tag no longer exists in database; skip this suggestion pass data['tags'] = tag_suggestions @@ -136,6 +154,7 @@ class AISuggestionsSerializer(serializers.Serializer): 'confidence': confidence, } except Correspondent.DoesNotExist: + # Correspondent no longer exists in database; omit from suggestions pass # Document Type @@ -149,6 +168,7 @@ class AISuggestionsSerializer(serializers.Serializer): 'confidence': confidence, } except DocumentType.DoesNotExist: + # Document type no longer exists in database; omit from suggestions pass # Storage Path @@ -163,6 +183,7 @@ class AISuggestionsSerializer(serializers.Serializer): 'confidence': confidence, } except StoragePath.DoesNotExist: + # Storage path no longer exists in database; omit from suggestions pass # Custom Fields @@ -178,6 +199,7 @@ class AISuggestionsSerializer(serializers.Serializer): 'confidence': confidence, }) except CustomField.DoesNotExist: + # Custom field no longer exists in database; skip this suggestion pass data['custom_fields'] = field_suggestions @@ -193,6 +215,7 @@ class AISuggestionsSerializer(serializers.Serializer): 'confidence': confidence, }) except Workflow.DoesNotExist: + # Workflow no longer exists in database; skip this suggestion pass data['workflows'] = workflow_suggestions @@ -205,66 +228,65 @@ class AISuggestionsSerializer(serializers.Serializer): return data -class ApplySuggestionSerializer(serializers.Serializer): +class SuggestionSerializerMixin: + """ + Mixin to provide validation logic for suggestion serializers. + """ + def validate(self, attrs): + """Validate that the correct value field is provided for the suggestion type.""" + suggestion_type = attrs.get('suggestion_type') + value_id = attrs.get('value_id') + value_text = attrs.get('value_text') + + # Types that require value_id + if suggestion_type in ID_REQUIRED_TYPES and not value_id: + raise serializers.ValidationError( + f"value_id is required for suggestion_type '{suggestion_type}'" + ) + + # Types that require value_text + if suggestion_type in TEXT_REQUIRED_TYPES and not value_text: + raise serializers.ValidationError( + f"value_text is required for suggestion_type '{suggestion_type}'" + ) + + # For custom_field, either is acceptable + if suggestion_type == 'custom_field' and not value_id and not value_text: + raise serializers.ValidationError( + "Either value_id or value_text must be provided for custom_field" + ) + + return attrs + + +class ApplySuggestionSerializer(SuggestionSerializerMixin, serializers.Serializer): """ Serializer for applying AI suggestions. """ suggestion_type = serializers.ChoiceField( - choices=[ - 'tag', - 'correspondent', - 'document_type', - 'storage_path', - 'custom_field', - 'workflow', - 'title', - ], + choices=SUGGESTION_TYPE_CHOICES, required=True, ) value_id = serializers.IntegerField(required=False, allow_null=True) value_text = serializers.CharField(required=False, allow_blank=True) confidence = serializers.FloatField(required=True) - - def validate(self, attrs): - """Validate that at least one value field is provided.""" - if not attrs.get('value_id') and not attrs.get('value_text'): - raise serializers.ValidationError( - "Either value_id or value_text must be provided" - ) - return attrs -class RejectSuggestionSerializer(serializers.Serializer): +class RejectSuggestionSerializer(SuggestionSerializerMixin, serializers.Serializer): """ Serializer for rejecting AI suggestions. """ suggestion_type = serializers.ChoiceField( - choices=[ - 'tag', - 'correspondent', - 'document_type', - 'storage_path', - 'custom_field', - 'workflow', - 'title', - ], + choices=SUGGESTION_TYPE_CHOICES, required=True, ) value_id = serializers.IntegerField(required=False, allow_null=True) value_text = serializers.CharField(required=False, allow_blank=True) confidence = serializers.FloatField(required=True) - - def validate(self, attrs): - """Validate that at least one value field is provided.""" - if not attrs.get('value_id') and not attrs.get('value_text'): - raise serializers.ValidationError( - "Either value_id or value_text must be provided" - ) - return attrs class AISuggestionFeedbackSerializer(serializers.ModelSerializer): diff --git a/src/documents/tests/test_api_ai_suggestions.py b/src/documents/tests/test_api_ai_suggestions.py index fe2475425..74705690f 100644 --- a/src/documents/tests/test_api_ai_suggestions.py +++ b/src/documents/tests/test_api_ai_suggestions.py @@ -5,7 +5,6 @@ Tests for AI Suggestions API endpoints. from unittest import mock from django.contrib.auth.models import User -from django.test import override_settings from rest_framework import status from rest_framework.test import APITestCase diff --git a/src/documents/views.py b/src/documents/views.py index d6f277cec..ecfac2c13 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -1386,8 +1386,6 @@ class UnifiedSearchViewSet(DocumentViewSet): return Response(serializer.validated_data) - except Document.DoesNotExist: - return Response({"detail": "Document not found"}, status=404) except Exception as e: logger.error(f"Error getting AI suggestions for document {pk}: {e}", exc_info=True) return Response( @@ -1559,19 +1557,20 @@ class UnifiedSearchViewSet(DocumentViewSet): # Calculate accuracy rate accuracy_rate = (total_applied / total_feedbacks * 100) if total_feedbacks > 0 else 0 - # Get statistics by suggestion type + # Get statistics by suggestion type using a single aggregated query + stats_by_type = AISuggestionFeedback.objects.values('suggestion_type').annotate( + total=Count('id'), + applied=Count('id', filter=Q(status=AISuggestionFeedback.STATUS_APPLIED)), + rejected=Count('id', filter=Q(status=AISuggestionFeedback.STATUS_REJECTED)) + ) + + # Build the by_type dictionary using the aggregated results by_type = {} - for suggestion_type, _ in AISuggestionFeedback.SUGGESTION_TYPES: - type_feedbacks = AISuggestionFeedback.objects.filter( - suggestion_type=suggestion_type - ) - type_applied = type_feedbacks.filter( - status=AISuggestionFeedback.STATUS_APPLIED - ).count() - type_rejected = type_feedbacks.filter( - status=AISuggestionFeedback.STATUS_REJECTED - ).count() - type_total = type_applied + type_rejected + for stat in stats_by_type: + suggestion_type = stat['suggestion_type'] + type_total = stat['total'] + type_applied = stat['applied'] + type_rejected = stat['rejected'] by_type[suggestion_type] = { 'total': type_total,