mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-21 05:56:40 +01:00
Add AI API endpoints with permission-protected views
Co-authored-by: dawnsystem <42047891+dawnsystem@users.noreply.github.com>
This commit is contained in:
parent
476b08a23b
commit
0eb883287c
3 changed files with 490 additions and 0 deletions
|
|
@ -2696,3 +2696,125 @@ class StoragePathTestSerializer(SerializerWithPerms):
|
|||
label="Document",
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
|
||||
class AISuggestionsRequestSerializer(serializers.Serializer):
|
||||
"""Serializer for requesting AI suggestions for a document."""
|
||||
|
||||
document_id = serializers.IntegerField(
|
||||
required=True,
|
||||
label="Document ID",
|
||||
help_text="ID of the document to analyze",
|
||||
)
|
||||
|
||||
|
||||
class AISuggestionSerializer(serializers.Serializer):
|
||||
"""Serializer for a single AI suggestion."""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
confidence = serializers.FloatField()
|
||||
|
||||
|
||||
class AISuggestionsResponseSerializer(serializers.Serializer):
|
||||
"""Serializer for AI suggestions response."""
|
||||
|
||||
document_id = serializers.IntegerField()
|
||||
tags = AISuggestionSerializer(many=True, required=False)
|
||||
correspondent = AISuggestionSerializer(required=False, allow_null=True)
|
||||
document_type = AISuggestionSerializer(required=False, allow_null=True)
|
||||
storage_path = AISuggestionSerializer(required=False, allow_null=True)
|
||||
title_suggestion = serializers.CharField(required=False, allow_null=True)
|
||||
custom_fields = serializers.DictField(required=False)
|
||||
|
||||
|
||||
class ApplyAISuggestionsSerializer(serializers.Serializer):
|
||||
"""Serializer for applying AI suggestions to a document."""
|
||||
|
||||
document_id = serializers.IntegerField(
|
||||
required=True,
|
||||
label="Document ID",
|
||||
help_text="ID of the document to apply suggestions to",
|
||||
)
|
||||
apply_tags = serializers.BooleanField(
|
||||
default=False,
|
||||
label="Apply Tags",
|
||||
help_text="Whether to apply tag suggestions",
|
||||
)
|
||||
apply_correspondent = serializers.BooleanField(
|
||||
default=False,
|
||||
label="Apply Correspondent",
|
||||
help_text="Whether to apply correspondent suggestion",
|
||||
)
|
||||
apply_document_type = serializers.BooleanField(
|
||||
default=False,
|
||||
label="Apply Document Type",
|
||||
help_text="Whether to apply document type suggestion",
|
||||
)
|
||||
apply_storage_path = serializers.BooleanField(
|
||||
default=False,
|
||||
label="Apply Storage Path",
|
||||
help_text="Whether to apply storage path suggestion",
|
||||
)
|
||||
apply_title = serializers.BooleanField(
|
||||
default=False,
|
||||
label="Apply Title",
|
||||
help_text="Whether to apply title suggestion",
|
||||
)
|
||||
selected_tags = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
required=False,
|
||||
label="Selected Tags",
|
||||
help_text="Specific tag IDs to apply (optional)",
|
||||
)
|
||||
|
||||
|
||||
class AIConfigurationSerializer(serializers.Serializer):
|
||||
"""Serializer for AI configuration settings."""
|
||||
|
||||
auto_apply_threshold = serializers.FloatField(
|
||||
required=False,
|
||||
min_value=0.0,
|
||||
max_value=1.0,
|
||||
label="Auto Apply Threshold",
|
||||
help_text="Confidence threshold for automatic application (0.0-1.0)",
|
||||
)
|
||||
suggest_threshold = serializers.FloatField(
|
||||
required=False,
|
||||
min_value=0.0,
|
||||
max_value=1.0,
|
||||
label="Suggest Threshold",
|
||||
help_text="Confidence threshold for suggestions (0.0-1.0)",
|
||||
)
|
||||
ml_enabled = serializers.BooleanField(
|
||||
required=False,
|
||||
label="ML Features Enabled",
|
||||
help_text="Enable/disable ML features",
|
||||
)
|
||||
advanced_ocr_enabled = serializers.BooleanField(
|
||||
required=False,
|
||||
label="Advanced OCR Enabled",
|
||||
help_text="Enable/disable advanced OCR features",
|
||||
)
|
||||
|
||||
|
||||
class DeletionApprovalSerializer(serializers.Serializer):
|
||||
"""Serializer for approving/rejecting deletion requests."""
|
||||
|
||||
request_id = serializers.IntegerField(
|
||||
required=True,
|
||||
label="Request ID",
|
||||
help_text="ID of the deletion request",
|
||||
)
|
||||
action = serializers.ChoiceField(
|
||||
choices=["approve", "reject"],
|
||||
required=True,
|
||||
label="Action",
|
||||
help_text="Action to take on the deletion request",
|
||||
)
|
||||
reason = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
label="Reason",
|
||||
help_text="Reason for approval/rejection (optional)",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3150,3 +3150,340 @@ def serve_logo(request, filename=None):
|
|||
filename=app_logo.name,
|
||||
as_attachment=True,
|
||||
)
|
||||
|
||||
|
||||
class AISuggestionsView(GenericAPIView):
|
||||
"""
|
||||
API view to get AI suggestions for a document.
|
||||
|
||||
Requires: can_view_ai_suggestions permission
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated, CanViewAISuggestionsPermission]
|
||||
serializer_class = AISuggestionsResponseSerializer
|
||||
|
||||
def post(self, request):
|
||||
"""Get AI suggestions for a document."""
|
||||
from documents.ai_scanner import get_ai_scanner
|
||||
from documents.models import Document, Tag, Correspondent, DocumentType, StoragePath
|
||||
from documents.serialisers import AISuggestionsRequestSerializer
|
||||
|
||||
# Validate request
|
||||
request_serializer = AISuggestionsRequestSerializer(data=request.data)
|
||||
request_serializer.is_valid(raise_exception=True)
|
||||
|
||||
document_id = request_serializer.validated_data['document_id']
|
||||
|
||||
try:
|
||||
document = Document.objects.get(pk=document_id)
|
||||
except Document.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Document not found"},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Check if user has permission to view this document
|
||||
if not has_perms_owner_aware(request.user, 'documents.view_document', document):
|
||||
return Response(
|
||||
{"error": "Permission denied"},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Get AI scanner and scan document
|
||||
scanner = get_ai_scanner()
|
||||
scan_result = scanner.scan_document(document, document.content or "")
|
||||
|
||||
# Build response
|
||||
response_data = {
|
||||
"document_id": document.id,
|
||||
"tags": [],
|
||||
"correspondent": None,
|
||||
"document_type": None,
|
||||
"storage_path": None,
|
||||
"title_suggestion": scan_result.title_suggestion,
|
||||
"custom_fields": {}
|
||||
}
|
||||
|
||||
# Format tag suggestions
|
||||
for tag_id, confidence in scan_result.tags:
|
||||
try:
|
||||
tag = Tag.objects.get(pk=tag_id)
|
||||
response_data["tags"].append({
|
||||
"id": tag.id,
|
||||
"name": tag.name,
|
||||
"confidence": confidence
|
||||
})
|
||||
except Tag.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Format correspondent suggestion
|
||||
if scan_result.correspondent:
|
||||
corr_id, confidence = scan_result.correspondent
|
||||
try:
|
||||
correspondent = Correspondent.objects.get(pk=corr_id)
|
||||
response_data["correspondent"] = {
|
||||
"id": correspondent.id,
|
||||
"name": correspondent.name,
|
||||
"confidence": confidence
|
||||
}
|
||||
except Correspondent.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Format document type suggestion
|
||||
if scan_result.document_type:
|
||||
type_id, confidence = scan_result.document_type
|
||||
try:
|
||||
doc_type = DocumentType.objects.get(pk=type_id)
|
||||
response_data["document_type"] = {
|
||||
"id": doc_type.id,
|
||||
"name": doc_type.name,
|
||||
"confidence": confidence
|
||||
}
|
||||
except DocumentType.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Format storage path suggestion
|
||||
if scan_result.storage_path:
|
||||
path_id, confidence = scan_result.storage_path
|
||||
try:
|
||||
storage_path = StoragePath.objects.get(pk=path_id)
|
||||
response_data["storage_path"] = {
|
||||
"id": storage_path.id,
|
||||
"name": storage_path.name,
|
||||
"confidence": confidence
|
||||
}
|
||||
except StoragePath.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Format custom fields
|
||||
for field_id, (value, confidence) in scan_result.custom_fields.items():
|
||||
response_data["custom_fields"][str(field_id)] = {
|
||||
"value": value,
|
||||
"confidence": confidence
|
||||
}
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
|
||||
class ApplyAISuggestionsView(GenericAPIView):
|
||||
"""
|
||||
API view to apply AI suggestions to a document.
|
||||
|
||||
Requires: can_apply_ai_suggestions permission
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated, CanApplyAISuggestionsPermission]
|
||||
|
||||
def post(self, request):
|
||||
"""Apply AI suggestions to a document."""
|
||||
from documents.ai_scanner import get_ai_scanner
|
||||
from documents.models import Document, Tag, Correspondent, DocumentType, StoragePath
|
||||
from documents.serialisers import ApplyAISuggestionsSerializer
|
||||
|
||||
# Validate request
|
||||
serializer = ApplyAISuggestionsSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
document_id = serializer.validated_data['document_id']
|
||||
|
||||
try:
|
||||
document = Document.objects.get(pk=document_id)
|
||||
except Document.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Document not found"},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Check if user has permission to change this document
|
||||
if not has_perms_owner_aware(request.user, 'documents.change_document', document):
|
||||
return Response(
|
||||
{"error": "Permission denied"},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Get AI scanner and scan document
|
||||
scanner = get_ai_scanner()
|
||||
scan_result = scanner.scan_document(document, document.content or "")
|
||||
|
||||
# Apply suggestions based on user selections
|
||||
applied = []
|
||||
|
||||
if serializer.validated_data.get('apply_tags'):
|
||||
selected_tags = serializer.validated_data.get('selected_tags', [])
|
||||
if selected_tags:
|
||||
# Apply only selected tags
|
||||
tags_to_apply = [tag_id for tag_id, _ in scan_result.tags if tag_id in selected_tags]
|
||||
else:
|
||||
# Apply all high-confidence tags
|
||||
tags_to_apply = [tag_id for tag_id, conf in scan_result.tags if conf >= scanner.auto_apply_threshold]
|
||||
|
||||
for tag_id in tags_to_apply:
|
||||
try:
|
||||
tag = Tag.objects.get(pk=tag_id)
|
||||
document.add_nested_tags([tag])
|
||||
applied.append(f"tag: {tag.name}")
|
||||
except Tag.DoesNotExist:
|
||||
pass
|
||||
|
||||
if serializer.validated_data.get('apply_correspondent') and scan_result.correspondent:
|
||||
corr_id, confidence = scan_result.correspondent
|
||||
try:
|
||||
correspondent = Correspondent.objects.get(pk=corr_id)
|
||||
document.correspondent = correspondent
|
||||
applied.append(f"correspondent: {correspondent.name}")
|
||||
except Correspondent.DoesNotExist:
|
||||
pass
|
||||
|
||||
if serializer.validated_data.get('apply_document_type') and scan_result.document_type:
|
||||
type_id, confidence = scan_result.document_type
|
||||
try:
|
||||
doc_type = DocumentType.objects.get(pk=type_id)
|
||||
document.document_type = doc_type
|
||||
applied.append(f"document_type: {doc_type.name}")
|
||||
except DocumentType.DoesNotExist:
|
||||
pass
|
||||
|
||||
if serializer.validated_data.get('apply_storage_path') and scan_result.storage_path:
|
||||
path_id, confidence = scan_result.storage_path
|
||||
try:
|
||||
storage_path = StoragePath.objects.get(pk=path_id)
|
||||
document.storage_path = storage_path
|
||||
applied.append(f"storage_path: {storage_path.name}")
|
||||
except StoragePath.DoesNotExist:
|
||||
pass
|
||||
|
||||
if serializer.validated_data.get('apply_title') and scan_result.title_suggestion:
|
||||
document.title = scan_result.title_suggestion
|
||||
applied.append(f"title: {scan_result.title_suggestion}")
|
||||
|
||||
# Save document
|
||||
document.save()
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"document_id": document.id,
|
||||
"applied": applied
|
||||
})
|
||||
|
||||
|
||||
class AIConfigurationView(GenericAPIView):
|
||||
"""
|
||||
API view to get/update AI configuration.
|
||||
|
||||
Requires: can_configure_ai permission
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated, CanConfigureAIPermission]
|
||||
|
||||
def get(self, request):
|
||||
"""Get current AI configuration."""
|
||||
from documents.ai_scanner import get_ai_scanner
|
||||
from documents.serialisers import AIConfigurationSerializer
|
||||
|
||||
scanner = get_ai_scanner()
|
||||
|
||||
config_data = {
|
||||
"auto_apply_threshold": scanner.auto_apply_threshold,
|
||||
"suggest_threshold": scanner.suggest_threshold,
|
||||
"ml_enabled": scanner.ml_enabled,
|
||||
"advanced_ocr_enabled": scanner.advanced_ocr_enabled,
|
||||
}
|
||||
|
||||
serializer = AIConfigurationSerializer(config_data)
|
||||
return Response(serializer.data)
|
||||
|
||||
def post(self, request):
|
||||
"""Update AI configuration."""
|
||||
from documents.ai_scanner import get_ai_scanner, AIDocumentScanner, _scanner_instance
|
||||
from documents.serialisers import AIConfigurationSerializer
|
||||
|
||||
serializer = AIConfigurationSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Create new scanner with updated configuration
|
||||
config = {}
|
||||
if 'auto_apply_threshold' in serializer.validated_data:
|
||||
config['auto_apply_threshold'] = serializer.validated_data['auto_apply_threshold']
|
||||
if 'suggest_threshold' in serializer.validated_data:
|
||||
config['suggest_threshold'] = serializer.validated_data['suggest_threshold']
|
||||
if 'ml_enabled' in serializer.validated_data:
|
||||
config['enable_ml_features'] = serializer.validated_data['ml_enabled']
|
||||
if 'advanced_ocr_enabled' in serializer.validated_data:
|
||||
config['enable_advanced_ocr'] = serializer.validated_data['advanced_ocr_enabled']
|
||||
|
||||
# Update global scanner instance
|
||||
global _scanner_instance
|
||||
_scanner_instance = AIDocumentScanner(**config)
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"message": "AI configuration updated"
|
||||
})
|
||||
|
||||
|
||||
class DeletionApprovalView(GenericAPIView):
|
||||
"""
|
||||
API view to approve/reject deletion requests.
|
||||
|
||||
Requires: can_approve_deletions permission
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated, CanApproveDeletionsPermission]
|
||||
|
||||
def post(self, request):
|
||||
"""Approve or reject a deletion request."""
|
||||
from documents.models import DeletionRequest
|
||||
from documents.serialisers import DeletionApprovalSerializer
|
||||
|
||||
serializer = DeletionApprovalSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
request_id = serializer.validated_data['request_id']
|
||||
action = serializer.validated_data['action']
|
||||
reason = serializer.validated_data.get('reason', '')
|
||||
|
||||
try:
|
||||
deletion_request = DeletionRequest.objects.get(pk=request_id)
|
||||
except DeletionRequest.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Deletion request not found"},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Check if user has permission
|
||||
if deletion_request.user != request.user and not request.user.is_superuser:
|
||||
return Response(
|
||||
{"error": "Permission denied"},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
if action == "approve":
|
||||
deletion_request.status = DeletionRequest.STATUS_APPROVED
|
||||
deletion_request.save()
|
||||
|
||||
# Perform the actual deletion
|
||||
# This would integrate with the AI deletion manager
|
||||
return Response({
|
||||
"status": "success",
|
||||
"message": "Deletion request approved",
|
||||
"request_id": request_id
|
||||
})
|
||||
|
||||
elif action == "reject":
|
||||
deletion_request.status = DeletionRequest.STATUS_REJECTED
|
||||
deletion_request.save()
|
||||
|
||||
return Response({
|
||||
"status": "success",
|
||||
"message": "Deletion request rejected",
|
||||
"request_id": request_id
|
||||
})
|
||||
|
||||
|
||||
# Import the new permission classes
|
||||
from documents.permissions import (
|
||||
CanViewAISuggestionsPermission,
|
||||
CanApplyAISuggestionsPermission,
|
||||
CanApproveDeletionsPermission,
|
||||
CanConfigureAIPermission,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,11 +15,15 @@ from drf_spectacular.views import SpectacularAPIView
|
|||
from drf_spectacular.views import SpectacularSwaggerView
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from documents.views import AIConfigurationView
|
||||
from documents.views import AISuggestionsView
|
||||
from documents.views import ApplyAISuggestionsView
|
||||
from documents.views import BulkDownloadView
|
||||
from documents.views import BulkEditObjectsView
|
||||
from documents.views import BulkEditView
|
||||
from documents.views import CorrespondentViewSet
|
||||
from documents.views import CustomFieldViewSet
|
||||
from documents.views import DeletionApprovalView
|
||||
from documents.views import DocumentTypeViewSet
|
||||
from documents.views import GlobalSearchView
|
||||
from documents.views import IndexView
|
||||
|
|
@ -200,6 +204,33 @@ urlpatterns = [
|
|||
TrashView.as_view(),
|
||||
name="trash",
|
||||
),
|
||||
re_path(
|
||||
"^ai/",
|
||||
include(
|
||||
[
|
||||
re_path(
|
||||
"^suggestions/$",
|
||||
AISuggestionsView.as_view(),
|
||||
name="ai_suggestions",
|
||||
),
|
||||
re_path(
|
||||
"^suggestions/apply/$",
|
||||
ApplyAISuggestionsView.as_view(),
|
||||
name="ai_apply_suggestions",
|
||||
),
|
||||
re_path(
|
||||
"^config/$",
|
||||
AIConfigurationView.as_view(),
|
||||
name="ai_config",
|
||||
),
|
||||
re_path(
|
||||
"^deletions/approve/$",
|
||||
DeletionApprovalView.as_view(),
|
||||
name="ai_deletion_approval",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
re_path(
|
||||
r"^oauth/callback/",
|
||||
OauthCallbackView.as_view(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue