refactor: Apply code review improvements to AI suggestions API

- Remove unused imports (Document from serializers, override_settings from tests)
- Add explanatory comments to all empty except clauses
- Create SUGGESTION_TYPE_CHOICES constant and SuggestionSerializerMixin for DRY
- Implement type-specific validation (value_id for ID types, value_text for text types)
- Remove redundant Document.DoesNotExist handler (already handled by DRF)
- Optimize ai_suggestion_stats query (21 queries → 3 queries using aggregation)
- Update documentation to clarify unsupported custom_field/workflow in apply endpoint

Addresses all 14 code review comments from Copilot PR reviewer.

Co-authored-by: dawnsystem <42047891+dawnsystem@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-13 08:22:57 +00:00
parent 075f58734a
commit e9d64e6aac
4 changed files with 77 additions and 57 deletions

View file

@ -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",

View file

@ -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):

View file

@ -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

View file

@ -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,