diff --git a/docs/API_AI_SUGGESTIONS.md b/docs/API_AI_SUGGESTIONS.md new file mode 100644 index 000000000..d2756ac41 --- /dev/null +++ b/docs/API_AI_SUGGESTIONS.md @@ -0,0 +1,441 @@ +# AI Suggestions API Documentation + +This document describes the AI Suggestions API endpoints for the IntelliDocs-ngx project. + +## Overview + +The AI Suggestions API allows frontend applications to: +1. Retrieve AI-generated suggestions for document metadata +2. Apply suggestions to documents +3. Reject suggestions (for user feedback) +4. View accuracy statistics for AI model improvement + +## Authentication + +All endpoints require authentication. Include the authentication token in the request headers: + +```http +Authorization: Token +``` + +## Endpoints + +### 1. Get AI Suggestions + +Retrieve AI-generated suggestions for a specific document. + +**Endpoint:** `GET /api/documents/{id}/ai-suggestions/` + +**Parameters:** +- `id` (path parameter): Document ID + +**Response:** +```json +{ + "tags": [ + { + "id": 1, + "name": "Invoice", + "color": "#FF5733", + "confidence": 0.85 + }, + { + "id": 2, + "name": "Important", + "color": "#33FF57", + "confidence": 0.75 + } + ], + "correspondent": { + "id": 5, + "name": "Acme Corporation", + "confidence": 0.90 + }, + "document_type": { + "id": 3, + "name": "Invoice", + "confidence": 0.88 + }, + "storage_path": { + "id": 2, + "name": "Financial Documents", + "path": "/documents/financial/", + "confidence": 0.80 + }, + "custom_fields": [ + { + "field_id": 1, + "field_name": "Invoice Number", + "value": "INV-2024-001", + "confidence": 0.92 + } + ], + "workflows": [ + { + "id": 4, + "name": "Invoice Processing", + "confidence": 0.78 + } + ], + "title_suggestion": { + "title": "Invoice - Acme Corporation - 2024-01-15" + } +} +``` + +**Error Responses:** +- `400 Bad Request`: Document has no content to analyze +- `404 Not Found`: Document not found +- `500 Internal Server Error`: Error generating suggestions + +--- + +### 2. Apply Suggestion + +Apply an AI suggestion to a document and record user feedback. + +**Endpoint:** `POST /api/documents/{id}/apply-suggestion/` + +**Parameters:** +- `id` (path parameter): Document ID + +**Request Body:** +```json +{ + "suggestion_type": "tag", + "value_id": 1, + "confidence": 0.85 +} +``` + +**Supported Suggestion Types:** +- `tag` - Tag assignment +- `correspondent` - Correspondent assignment +- `document_type` - Document type classification +- `storage_path` - Storage path assignment +- `title` - Document title + +**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", + "value_id": 5, + "confidence": 0.90 +} +``` + +**For text-based suggestions (title):** +```json +{ + "suggestion_type": "title", + "value_text": "New Document Title", + "confidence": 0.80 +} +``` + +**Response:** +```json +{ + "status": "success", + "message": "Tag 'Invoice' applied" +} +``` + +**Error Responses:** +- `400 Bad Request`: Invalid suggestion type or missing value +- `404 Not Found`: Referenced object not found +- `500 Internal Server Error`: Error applying suggestion + +--- + +### 3. Reject Suggestion + +Reject an AI suggestion and record user feedback for model improvement. + +**Endpoint:** `POST /api/documents/{id}/reject-suggestion/` + +**Parameters:** +- `id` (path parameter): Document ID + +**Request Body:** +```json +{ + "suggestion_type": "tag", + "value_id": 2, + "confidence": 0.65 +} +``` + +Same format as apply-suggestion endpoint. + +**Response:** +```json +{ + "status": "success", + "message": "Suggestion rejected and feedback recorded" +} +``` + +**Error Responses:** +- `400 Bad Request`: Invalid request data +- `500 Internal Server Error`: Error recording feedback + +--- + +### 4. AI Suggestion Statistics + +Get accuracy statistics and metrics for AI suggestions. + +**Endpoint:** `GET /api/documents/ai-suggestion-stats/` + +**Response:** +```json +{ + "total_suggestions": 150, + "total_applied": 120, + "total_rejected": 30, + "accuracy_rate": 80.0, + "by_type": { + "tag": { + "total": 50, + "applied": 45, + "rejected": 5, + "accuracy_rate": 90.0 + }, + "correspondent": { + "total": 40, + "applied": 35, + "rejected": 5, + "accuracy_rate": 87.5 + }, + "document_type": { + "total": 30, + "applied": 20, + "rejected": 10, + "accuracy_rate": 66.67 + }, + "storage_path": { + "total": 20, + "applied": 15, + "rejected": 5, + "accuracy_rate": 75.0 + }, + "title": { + "total": 10, + "applied": 5, + "rejected": 5, + "accuracy_rate": 50.0 + } + }, + "average_confidence_applied": 0.82, + "average_confidence_rejected": 0.58, + "recent_suggestions": [ + { + "id": 150, + "document": 42, + "suggestion_type": "tag", + "suggested_value_id": 5, + "suggested_value_text": "", + "confidence": 0.85, + "status": "applied", + "user": 1, + "created_at": "2024-01-15T10:30:00Z", + "applied_at": "2024-01-15T10:30:05Z", + "metadata": {} + } + ] +} +``` + +**Error Responses:** +- `500 Internal Server Error`: Error calculating statistics + +--- + +## Frontend Integration Example + +### React/TypeScript Example + +```typescript +import axios from 'axios'; + +const API_BASE = '/api/documents'; + +interface AISuggestions { + tags?: Array<{id: number; name: string; confidence: number}>; + correspondent?: {id: number; name: string; confidence: number}; + document_type?: {id: number; name: string; confidence: number}; + // ... other fields +} + +// Get AI suggestions +async function getAISuggestions(documentId: number): Promise { + const response = await axios.get(`${API_BASE}/${documentId}/ai-suggestions/`); + return response.data; +} + +// Apply a suggestion +async function applySuggestion( + documentId: number, + type: string, + valueId: number, + confidence: number +): Promise { + await axios.post(`${API_BASE}/${documentId}/apply-suggestion/`, { + suggestion_type: type, + value_id: valueId, + confidence: confidence + }); +} + +// Reject a suggestion +async function rejectSuggestion( + documentId: number, + type: string, + valueId: number, + confidence: number +): Promise { + await axios.post(`${API_BASE}/${documentId}/reject-suggestion/`, { + suggestion_type: type, + value_id: valueId, + confidence: confidence + }); +} + +// Get statistics +async function getStatistics() { + const response = await axios.get(`${API_BASE}/ai-suggestion-stats/`); + return response.data; +} + +// Usage example +async function handleDocument(documentId: number) { + try { + // Get suggestions + const suggestions = await getAISuggestions(documentId); + + // Show suggestions to user + if (suggestions.tags) { + suggestions.tags.forEach(tag => { + console.log(`Suggested tag: ${tag.name} (${tag.confidence * 100}%)`); + }); + } + + // User accepts a tag suggestion + if (suggestions.tags && suggestions.tags.length > 0) { + const tag = suggestions.tags[0]; + await applySuggestion(documentId, 'tag', tag.id, tag.confidence); + console.log('Tag applied successfully'); + } + + } catch (error) { + console.error('Error handling AI suggestions:', error); + } +} +``` + +--- + +## Database Schema + +### AISuggestionFeedback Model + +Stores user feedback on AI suggestions for accuracy tracking and model improvement. + +**Fields:** +- `id` (BigAutoField): Primary key +- `document` (ForeignKey): Reference to Document +- `suggestion_type` (CharField): Type of suggestion (tag, correspondent, etc.) +- `suggested_value_id` (IntegerField, nullable): ID of suggested object +- `suggested_value_text` (TextField): Text representation of suggestion +- `confidence` (FloatField): AI confidence score (0.0 to 1.0) +- `status` (CharField): 'applied' or 'rejected' +- `user` (ForeignKey, nullable): User who provided feedback +- `created_at` (DateTimeField): When suggestion was created +- `applied_at` (DateTimeField): When feedback was recorded +- `metadata` (JSONField): Additional metadata + +**Indexes:** +- `(document, suggestion_type)` +- `(status, created_at)` +- `(suggestion_type, status)` + +--- + +## Best Practices + +1. **Confidence Thresholds:** + - High confidence (≥ 0.80): Can be auto-applied + - Medium confidence (0.60-0.79): Show to user for review + - Low confidence (< 0.60): Log but don't suggest + +2. **Error Handling:** + - Always handle 400, 404, and 500 errors gracefully + - Show user-friendly error messages + - Log errors for debugging + +3. **Performance:** + - Cache suggestions when possible + - Use pagination for statistics endpoint if needed + - Batch apply/reject operations when possible + +4. **User Experience:** + - Show confidence scores to users + - Allow users to modify suggestions before applying + - Provide feedback on applied/rejected actions + - Show statistics to demonstrate AI improvement over time + +5. **Privacy:** + - Only authenticated users can access suggestions + - Users can only see suggestions for documents they have access to + - Feedback is tied to user accounts for accountability + +--- + +## Troubleshooting + +### No suggestions returned +- Verify document has content (document.content is not empty) +- Check if AI scanner is enabled in settings +- Verify ML models are loaded correctly + +### Suggestions not being applied +- Check user permissions on the document +- Verify the suggested object (tag, correspondent, etc.) still exists +- Check application logs for detailed error messages + +### Statistics showing 0 accuracy +- Ensure users are applying or rejecting suggestions +- Check database for AISuggestionFeedback entries +- Verify feedback is being recorded with correct status + +--- + +## Future Enhancements + +Potential improvements for future versions: + +1. Bulk operations (apply/reject multiple suggestions at once) +2. Suggestion confidence threshold configuration per user +3. A/B testing different AI models +4. Machine learning model retraining based on feedback +5. Suggestion explanations (why AI made this suggestion) +6. Custom suggestion rules per user or organization +7. Integration with external AI services +8. Real-time suggestions via WebSocket + +--- + +## Support + +For issues or questions: +- GitHub Issues: https://github.com/dawnsystem/IntelliDocs-ngx/issues +- Documentation: https://docs.paperless-ngx.com +- Community: Matrix chat or forum + +--- + +*Last updated: 2024-11-13* +*API Version: 1.0* diff --git a/src/documents/migrations/1076_aisuggestionfeedback.py b/src/documents/migrations/1076_aisuggestionfeedback.py new file mode 100644 index 000000000..f669e21df --- /dev/null +++ b/src/documents/migrations/1076_aisuggestionfeedback.py @@ -0,0 +1,164 @@ +# Generated manually for AI Suggestions API + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.core.validators + + +class Migration(migrations.Migration): + """ + Add AISuggestionFeedback model for tracking user feedback on AI suggestions. + + This model enables: + - Tracking of applied vs rejected AI suggestions + - Accuracy statistics and improvement of AI models + - User feedback analysis + """ + + dependencies = [ + ("documents", "1075_add_performance_indexes"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AISuggestionFeedback", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "suggestion_type", + models.CharField( + choices=[ + ("tag", "Tag"), + ("correspondent", "Correspondent"), + ("document_type", "Document Type"), + ("storage_path", "Storage Path"), + ("custom_field", "Custom Field"), + ("workflow", "Workflow"), + ("title", "Title"), + ], + max_length=50, + verbose_name="suggestion type", + ), + ), + ( + "suggested_value_id", + models.IntegerField( + blank=True, + help_text="ID of the suggested object (tag, correspondent, etc.)", + null=True, + verbose_name="suggested value ID", + ), + ), + ( + "suggested_value_text", + models.TextField( + blank=True, + help_text="Text representation of the suggested value", + verbose_name="suggested value text", + ), + ), + ( + "confidence", + models.FloatField( + help_text="AI confidence score (0.0 to 1.0)", + validators=[ + django.core.validators.MinValueValidator(0.0), + django.core.validators.MaxValueValidator(1.0), + ], + verbose_name="confidence", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("applied", "Applied"), + ("rejected", "Rejected"), + ], + max_length=20, + verbose_name="status", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + verbose_name="created at", + ), + ), + ( + "applied_at", + models.DateTimeField( + auto_now=True, + verbose_name="applied/rejected at", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + default=dict, + help_text="Additional metadata about the suggestion", + verbose_name="metadata", + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ai_suggestion_feedbacks", + to="documents.document", + verbose_name="document", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + help_text="User who applied or rejected the suggestion", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="ai_suggestion_feedbacks", + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ], + options={ + "verbose_name": "AI suggestion feedback", + "verbose_name_plural": "AI suggestion feedbacks", + "ordering": ["-created_at"], + }, + ), + migrations.AddIndex( + model_name="aisuggestionfeedback", + index=models.Index( + fields=["document", "suggestion_type"], + name="documents_a_documen_idx", + ), + ), + migrations.AddIndex( + model_name="aisuggestionfeedback", + index=models.Index( + fields=["status", "created_at"], + name="documents_a_status_idx", + ), + ), + migrations.AddIndex( + model_name="aisuggestionfeedback", + index=models.Index( + fields=["suggestion_type", "status"], + name="documents_a_suggest_idx", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 78feb30bb..f0f91ef4f 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1734,3 +1734,116 @@ class DeletionRequest(models.Model): self.save() return True + + +class AISuggestionFeedback(models.Model): + """ + Model to track user feedback on AI suggestions (applied/rejected). + Used for improving AI accuracy and providing statistics. + """ + + # Suggestion types + TYPE_TAG = 'tag' + TYPE_CORRESPONDENT = 'correspondent' + TYPE_DOCUMENT_TYPE = 'document_type' + TYPE_STORAGE_PATH = 'storage_path' + TYPE_CUSTOM_FIELD = 'custom_field' + TYPE_WORKFLOW = 'workflow' + TYPE_TITLE = 'title' + + SUGGESTION_TYPES = ( + (TYPE_TAG, _('Tag')), + (TYPE_CORRESPONDENT, _('Correspondent')), + (TYPE_DOCUMENT_TYPE, _('Document Type')), + (TYPE_STORAGE_PATH, _('Storage Path')), + (TYPE_CUSTOM_FIELD, _('Custom Field')), + (TYPE_WORKFLOW, _('Workflow')), + (TYPE_TITLE, _('Title')), + ) + + # Feedback status + STATUS_APPLIED = 'applied' + STATUS_REJECTED = 'rejected' + + FEEDBACK_STATUS = ( + (STATUS_APPLIED, _('Applied')), + (STATUS_REJECTED, _('Rejected')), + ) + + document = models.ForeignKey( + Document, + on_delete=models.CASCADE, + related_name='ai_suggestion_feedbacks', + verbose_name=_('document'), + ) + + suggestion_type = models.CharField( + _('suggestion type'), + max_length=50, + choices=SUGGESTION_TYPES, + ) + + suggested_value_id = models.IntegerField( + _('suggested value ID'), + null=True, + blank=True, + help_text=_('ID of the suggested object (tag, correspondent, etc.)'), + ) + + suggested_value_text = models.TextField( + _('suggested value text'), + blank=True, + help_text=_('Text representation of the suggested value'), + ) + + confidence = models.FloatField( + _('confidence'), + help_text=_('AI confidence score (0.0 to 1.0)'), + validators=[MinValueValidator(0.0), MaxValueValidator(1.0)], + ) + + status = models.CharField( + _('status'), + max_length=20, + choices=FEEDBACK_STATUS, + ) + + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='ai_suggestion_feedbacks', + verbose_name=_('user'), + help_text=_('User who applied or rejected the suggestion'), + ) + + created_at = models.DateTimeField( + _('created at'), + auto_now_add=True, + ) + + applied_at = models.DateTimeField( + _('applied/rejected at'), + auto_now=True, + ) + + metadata = models.JSONField( + _('metadata'), + default=dict, + blank=True, + help_text=_('Additional metadata about the suggestion'), + ) + + class Meta: + verbose_name = _('AI suggestion feedback') + verbose_name_plural = _('AI suggestion feedbacks') + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['document', 'suggestion_type']), + models.Index(fields=['status', 'created_at']), + models.Index(fields=['suggestion_type', 'status']), + ] + + def __str__(self): + return f"{self.suggestion_type} suggestion for document {self.document_id} - {self.status}" diff --git a/src/documents/serializers/__init__.py b/src/documents/serializers/__init__.py new file mode 100644 index 000000000..3c6543214 --- /dev/null +++ b/src/documents/serializers/__init__.py @@ -0,0 +1,17 @@ +"""Serializers package for documents app.""" + +from .ai_suggestions import ( + AISuggestionFeedbackSerializer, + AISuggestionsSerializer, + AISuggestionStatsSerializer, + ApplySuggestionSerializer, + RejectSuggestionSerializer, +) + +__all__ = [ + 'AISuggestionFeedbackSerializer', + 'AISuggestionsSerializer', + 'AISuggestionStatsSerializer', + 'ApplySuggestionSerializer', + 'RejectSuggestionSerializer', +] diff --git a/src/documents/serializers/ai_suggestions.py b/src/documents/serializers/ai_suggestions.py new file mode 100644 index 000000000..f793482de --- /dev/null +++ b/src/documents/serializers/ai_suggestions.py @@ -0,0 +1,331 @@ +""" +Serializers for AI Suggestions API. + +This module provides serializers for exposing AI scanner results +and handling user feedback on AI suggestions. +""" + +from __future__ import annotations + +from typing import Any, Dict + +from rest_framework import serializers + +from documents.models import ( + AISuggestionFeedback, + Correspondent, + CustomField, + DocumentType, + StoragePath, + Tag, + Workflow, +) + + +# 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.""" + + id = serializers.IntegerField() + name = serializers.CharField() + color = serializers.CharField() + confidence = serializers.FloatField() + + +class CorrespondentSuggestionSerializer(serializers.Serializer): + """Serializer for correspondent suggestions.""" + + id = serializers.IntegerField() + name = serializers.CharField() + confidence = serializers.FloatField() + + +class DocumentTypeSuggestionSerializer(serializers.Serializer): + """Serializer for document type suggestions.""" + + id = serializers.IntegerField() + name = serializers.CharField() + confidence = serializers.FloatField() + + +class StoragePathSuggestionSerializer(serializers.Serializer): + """Serializer for storage path suggestions.""" + + id = serializers.IntegerField() + name = serializers.CharField() + path = serializers.CharField() + confidence = serializers.FloatField() + + +class CustomFieldSuggestionSerializer(serializers.Serializer): + """Serializer for custom field suggestions.""" + + field_id = serializers.IntegerField() + field_name = serializers.CharField() + value = serializers.CharField() + confidence = serializers.FloatField() + + +class WorkflowSuggestionSerializer(serializers.Serializer): + """Serializer for workflow suggestions.""" + + id = serializers.IntegerField() + name = serializers.CharField() + confidence = serializers.FloatField() + + +class TitleSuggestionSerializer(serializers.Serializer): + """Serializer for title suggestions.""" + + title = serializers.CharField() + + +class AISuggestionsSerializer(serializers.Serializer): + """ + Main serializer for AI scan results. + + Converts AIScanResult objects to JSON format for API responses. + """ + + tags = TagSuggestionSerializer(many=True, required=False) + correspondent = CorrespondentSuggestionSerializer(required=False, allow_null=True) + document_type = DocumentTypeSuggestionSerializer(required=False, allow_null=True) + storage_path = StoragePathSuggestionSerializer(required=False, allow_null=True) + custom_fields = CustomFieldSuggestionSerializer(many=True, required=False) + workflows = WorkflowSuggestionSerializer(many=True, required=False) + title_suggestion = TitleSuggestionSerializer(required=False, allow_null=True) + + @staticmethod + def from_scan_result(scan_result, document_id: int) -> Dict[str, Any]: + """ + Convert an AIScanResult object to serializer data. + + Args: + scan_result: AIScanResult instance from ai_scanner + document_id: Document ID for reference + + Returns: + Dictionary ready for serialization + """ + data = {} + + # Tags + if scan_result.tags: + tag_suggestions = [] + for tag_id, confidence in scan_result.tags: + try: + tag = Tag.objects.get(pk=tag_id) + tag_suggestions.append({ + 'id': tag.id, + 'name': tag.name, + 'color': getattr(tag, 'color', '#000000'), + 'confidence': confidence, + }) + except Tag.DoesNotExist: + # Tag no longer exists in database; skip this suggestion + pass + data['tags'] = tag_suggestions + + # Correspondent + if scan_result.correspondent: + corr_id, confidence = scan_result.correspondent + try: + correspondent = Correspondent.objects.get(pk=corr_id) + data['correspondent'] = { + 'id': correspondent.id, + 'name': correspondent.name, + 'confidence': confidence, + } + except Correspondent.DoesNotExist: + # Correspondent no longer exists in database; omit from suggestions + pass + + # Document Type + if scan_result.document_type: + type_id, confidence = scan_result.document_type + try: + doc_type = DocumentType.objects.get(pk=type_id) + data['document_type'] = { + 'id': doc_type.id, + 'name': doc_type.name, + 'confidence': confidence, + } + except DocumentType.DoesNotExist: + # Document type no longer exists in database; omit from suggestions + pass + + # Storage Path + if scan_result.storage_path: + path_id, confidence = scan_result.storage_path + try: + storage_path = StoragePath.objects.get(pk=path_id) + data['storage_path'] = { + 'id': storage_path.id, + 'name': storage_path.name, + 'path': storage_path.path, + 'confidence': confidence, + } + except StoragePath.DoesNotExist: + # Storage path no longer exists in database; omit from suggestions + pass + + # Custom Fields + if scan_result.custom_fields: + field_suggestions = [] + for field_id, (value, confidence) in scan_result.custom_fields.items(): + try: + field = CustomField.objects.get(pk=field_id) + field_suggestions.append({ + 'field_id': field.id, + 'field_name': field.name, + 'value': str(value), + 'confidence': confidence, + }) + except CustomField.DoesNotExist: + # Custom field no longer exists in database; skip this suggestion + pass + data['custom_fields'] = field_suggestions + + # Workflows + if scan_result.workflows: + workflow_suggestions = [] + for workflow_id, confidence in scan_result.workflows: + try: + workflow = Workflow.objects.get(pk=workflow_id) + workflow_suggestions.append({ + 'id': workflow.id, + 'name': workflow.name, + 'confidence': confidence, + }) + except Workflow.DoesNotExist: + # Workflow no longer exists in database; skip this suggestion + pass + data['workflows'] = workflow_suggestions + + # Title suggestion + if scan_result.title_suggestion: + data['title_suggestion'] = { + 'title': scan_result.title_suggestion, + } + + return data + + +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=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) + + +class RejectSuggestionSerializer(SuggestionSerializerMixin, serializers.Serializer): + """ + Serializer for rejecting AI suggestions. + """ + + suggestion_type = serializers.ChoiceField( + 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) + + +class AISuggestionFeedbackSerializer(serializers.ModelSerializer): + """Serializer for AI suggestion feedback model.""" + + class Meta: + model = AISuggestionFeedback + fields = [ + 'id', + 'document', + 'suggestion_type', + 'suggested_value_id', + 'suggested_value_text', + 'confidence', + 'status', + 'user', + 'created_at', + 'applied_at', + 'metadata', + ] + read_only_fields = ['id', 'created_at', 'applied_at'] + + +class AISuggestionStatsSerializer(serializers.Serializer): + """ + Serializer for AI suggestion accuracy statistics. + """ + + total_suggestions = serializers.IntegerField() + total_applied = serializers.IntegerField() + total_rejected = serializers.IntegerField() + accuracy_rate = serializers.FloatField() + + by_type = serializers.DictField( + child=serializers.DictField(), + help_text="Statistics broken down by suggestion type", + ) + + average_confidence_applied = serializers.FloatField() + average_confidence_rejected = serializers.FloatField() + + recent_suggestions = AISuggestionFeedbackSerializer(many=True, required=False) diff --git a/src/documents/tests/test_api_ai_suggestions.py b/src/documents/tests/test_api_ai_suggestions.py new file mode 100644 index 000000000..74705690f --- /dev/null +++ b/src/documents/tests/test_api_ai_suggestions.py @@ -0,0 +1,462 @@ +""" +Tests for AI Suggestions API endpoints. +""" + +from unittest import mock + +from django.contrib.auth.models import User +from rest_framework import status +from rest_framework.test import APITestCase + +from documents.ai_scanner import AIScanResult +from documents.models import ( + AISuggestionFeedback, + Correspondent, + Document, + DocumentType, + StoragePath, + Tag, +) +from documents.tests.utils import DirectoriesMixin + + +class TestAISuggestionsAPI(DirectoriesMixin, APITestCase): + """Test cases for AI suggestions API endpoints.""" + + def setUp(self): + super().setUp() + + # Create test user + self.user = User.objects.create_superuser(username="test_admin") + self.client.force_authenticate(user=self.user) + + # Create test data + self.correspondent = Correspondent.objects.create( + name="Test Corp", + pk=1, + ) + self.doc_type = DocumentType.objects.create( + name="Invoice", + pk=1, + ) + self.tag1 = Tag.objects.create( + name="Important", + pk=1, + ) + self.tag2 = Tag.objects.create( + name="Urgent", + pk=2, + ) + self.storage_path = StoragePath.objects.create( + name="Archive", + path="/archive/", + pk=1, + ) + + # Create test document + self.document = Document.objects.create( + title="Test Document", + content="This is a test document with some content for AI analysis.", + checksum="abc123", + mime_type="application/pdf", + ) + + def test_ai_suggestions_endpoint_exists(self): + """Test that the ai-suggestions endpoint is accessible.""" + response = self.client.get( + f"/api/documents/{self.document.pk}/ai-suggestions/" + ) + # Should not be 404 + self.assertNotEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + @mock.patch('documents.ai_scanner.get_ai_scanner') + def test_get_ai_suggestions_success(self, mock_get_scanner): + """Test successfully getting AI suggestions for a document.""" + # Create mock scan result + scan_result = AIScanResult() + scan_result.tags = [(self.tag1.id, 0.85), (self.tag2.id, 0.75)] + scan_result.correspondent = (self.correspondent.id, 0.90) + scan_result.document_type = (self.doc_type.id, 0.88) + scan_result.storage_path = (self.storage_path.id, 0.80) + scan_result.title_suggestion = "Suggested Title" + + # Mock scanner + mock_scanner = mock.Mock() + mock_scanner.scan_document.return_value = scan_result + mock_get_scanner.return_value = mock_scanner + + # Make request + response = self.client.get( + f"/api/documents/{self.document.pk}/ai-suggestions/" + ) + + # Verify response + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + + # Check tags + self.assertIn('tags', data) + self.assertEqual(len(data['tags']), 2) + self.assertEqual(data['tags'][0]['id'], self.tag1.id) + self.assertEqual(data['tags'][0]['confidence'], 0.85) + + # Check correspondent + self.assertIn('correspondent', data) + self.assertEqual(data['correspondent']['id'], self.correspondent.id) + self.assertEqual(data['correspondent']['confidence'], 0.90) + + # Check document type + self.assertIn('document_type', data) + self.assertEqual(data['document_type']['id'], self.doc_type.id) + + # Check title suggestion + self.assertIn('title_suggestion', data) + self.assertEqual(data['title_suggestion']['title'], "Suggested Title") + + def test_get_ai_suggestions_no_content(self): + """Test getting AI suggestions for document without content.""" + # Create document without content + doc = Document.objects.create( + title="Empty Document", + content="", + checksum="empty123", + mime_type="application/pdf", + ) + + response = self.client.get(f"/api/documents/{doc.pk}/ai-suggestions/") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("no content", response.json()['detail'].lower()) + + def test_get_ai_suggestions_document_not_found(self): + """Test getting AI suggestions for non-existent document.""" + response = self.client.get("/api/documents/99999/ai-suggestions/") + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_apply_suggestion_tag(self): + """Test applying a tag suggestion.""" + request_data = { + 'suggestion_type': 'tag', + 'value_id': self.tag1.id, + 'confidence': 0.85, + } + + response = self.client.post( + f"/api/documents/{self.document.pk}/apply-suggestion/", + data=request_data, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()['status'], 'success') + + # Verify tag was applied + self.document.refresh_from_db() + self.assertIn(self.tag1, self.document.tags.all()) + + # Verify feedback was recorded + feedback = AISuggestionFeedback.objects.filter( + document=self.document, + suggestion_type='tag', + ).first() + self.assertIsNotNone(feedback) + self.assertEqual(feedback.status, AISuggestionFeedback.STATUS_APPLIED) + self.assertEqual(feedback.suggested_value_id, self.tag1.id) + self.assertEqual(feedback.confidence, 0.85) + self.assertEqual(feedback.user, self.user) + + def test_apply_suggestion_correspondent(self): + """Test applying a correspondent suggestion.""" + request_data = { + 'suggestion_type': 'correspondent', + 'value_id': self.correspondent.id, + 'confidence': 0.90, + } + + response = self.client.post( + f"/api/documents/{self.document.pk}/apply-suggestion/", + data=request_data, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify correspondent was applied + self.document.refresh_from_db() + self.assertEqual(self.document.correspondent, self.correspondent) + + # Verify feedback was recorded + feedback = AISuggestionFeedback.objects.filter( + document=self.document, + suggestion_type='correspondent', + ).first() + self.assertIsNotNone(feedback) + self.assertEqual(feedback.status, AISuggestionFeedback.STATUS_APPLIED) + + def test_apply_suggestion_document_type(self): + """Test applying a document type suggestion.""" + request_data = { + 'suggestion_type': 'document_type', + 'value_id': self.doc_type.id, + 'confidence': 0.88, + } + + response = self.client.post( + f"/api/documents/{self.document.pk}/apply-suggestion/", + data=request_data, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify document type was applied + self.document.refresh_from_db() + self.assertEqual(self.document.document_type, self.doc_type) + + def test_apply_suggestion_title(self): + """Test applying a title suggestion.""" + request_data = { + 'suggestion_type': 'title', + 'value_text': 'New Suggested Title', + 'confidence': 0.80, + } + + response = self.client.post( + f"/api/documents/{self.document.pk}/apply-suggestion/", + data=request_data, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify title was applied + self.document.refresh_from_db() + self.assertEqual(self.document.title, 'New Suggested Title') + + def test_apply_suggestion_invalid_type(self): + """Test applying suggestion with invalid type.""" + request_data = { + 'suggestion_type': 'invalid_type', + 'value_id': 1, + 'confidence': 0.85, + } + + response = self.client.post( + f"/api/documents/{self.document.pk}/apply-suggestion/", + data=request_data, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_apply_suggestion_missing_value(self): + """Test applying suggestion without value_id or value_text.""" + request_data = { + 'suggestion_type': 'tag', + 'confidence': 0.85, + } + + response = self.client.post( + f"/api/documents/{self.document.pk}/apply-suggestion/", + data=request_data, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_apply_suggestion_nonexistent_object(self): + """Test applying suggestion with non-existent object ID.""" + request_data = { + 'suggestion_type': 'tag', + 'value_id': 99999, + 'confidence': 0.85, + } + + response = self.client.post( + f"/api/documents/{self.document.pk}/apply-suggestion/", + data=request_data, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_reject_suggestion(self): + """Test rejecting an AI suggestion.""" + request_data = { + 'suggestion_type': 'tag', + 'value_id': self.tag1.id, + 'confidence': 0.65, + } + + response = self.client.post( + f"/api/documents/{self.document.pk}/reject-suggestion/", + data=request_data, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()['status'], 'success') + + # Verify feedback was recorded + feedback = AISuggestionFeedback.objects.filter( + document=self.document, + suggestion_type='tag', + ).first() + self.assertIsNotNone(feedback) + self.assertEqual(feedback.status, AISuggestionFeedback.STATUS_REJECTED) + self.assertEqual(feedback.suggested_value_id, self.tag1.id) + self.assertEqual(feedback.confidence, 0.65) + self.assertEqual(feedback.user, self.user) + + def test_reject_suggestion_with_text(self): + """Test rejecting a suggestion with text value.""" + request_data = { + 'suggestion_type': 'title', + 'value_text': 'Bad Title Suggestion', + 'confidence': 0.50, + } + + response = self.client.post( + f"/api/documents/{self.document.pk}/reject-suggestion/", + data=request_data, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify feedback was recorded + feedback = AISuggestionFeedback.objects.filter( + document=self.document, + suggestion_type='title', + ).first() + self.assertIsNotNone(feedback) + self.assertEqual(feedback.status, AISuggestionFeedback.STATUS_REJECTED) + self.assertEqual(feedback.suggested_value_text, 'Bad Title Suggestion') + + def test_ai_suggestion_stats_empty(self): + """Test getting statistics when no feedback exists.""" + response = self.client.get("/api/documents/ai-suggestion-stats/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + + self.assertEqual(data['total_suggestions'], 0) + self.assertEqual(data['total_applied'], 0) + self.assertEqual(data['total_rejected'], 0) + self.assertEqual(data['accuracy_rate'], 0) + + def test_ai_suggestion_stats_with_data(self): + """Test getting statistics with feedback data.""" + # Create some feedback entries + AISuggestionFeedback.objects.create( + document=self.document, + suggestion_type='tag', + suggested_value_id=self.tag1.id, + confidence=0.85, + status=AISuggestionFeedback.STATUS_APPLIED, + user=self.user, + ) + AISuggestionFeedback.objects.create( + document=self.document, + suggestion_type='tag', + suggested_value_id=self.tag2.id, + confidence=0.70, + status=AISuggestionFeedback.STATUS_APPLIED, + user=self.user, + ) + AISuggestionFeedback.objects.create( + document=self.document, + suggestion_type='correspondent', + suggested_value_id=self.correspondent.id, + confidence=0.60, + status=AISuggestionFeedback.STATUS_REJECTED, + user=self.user, + ) + + response = self.client.get("/api/documents/ai-suggestion-stats/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + + # Check overall stats + self.assertEqual(data['total_suggestions'], 3) + self.assertEqual(data['total_applied'], 2) + self.assertEqual(data['total_rejected'], 1) + self.assertAlmostEqual(data['accuracy_rate'], 66.67, places=1) + + # Check by_type stats + self.assertIn('by_type', data) + self.assertIn('tag', data['by_type']) + self.assertEqual(data['by_type']['tag']['total'], 2) + self.assertEqual(data['by_type']['tag']['applied'], 2) + self.assertEqual(data['by_type']['tag']['rejected'], 0) + + # Check confidence averages + self.assertGreater(data['average_confidence_applied'], 0) + self.assertGreater(data['average_confidence_rejected'], 0) + + # Check recent suggestions + self.assertIn('recent_suggestions', data) + self.assertEqual(len(data['recent_suggestions']), 3) + + def test_ai_suggestion_stats_accuracy_calculation(self): + """Test that accuracy rate is calculated correctly.""" + # Create 7 applied and 3 rejected = 70% accuracy + for i in range(7): + AISuggestionFeedback.objects.create( + document=self.document, + suggestion_type='tag', + suggested_value_id=self.tag1.id, + confidence=0.80, + status=AISuggestionFeedback.STATUS_APPLIED, + user=self.user, + ) + + for i in range(3): + AISuggestionFeedback.objects.create( + document=self.document, + suggestion_type='tag', + suggested_value_id=self.tag2.id, + confidence=0.60, + status=AISuggestionFeedback.STATUS_REJECTED, + user=self.user, + ) + + response = self.client.get("/api/documents/ai-suggestion-stats/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + + self.assertEqual(data['total_suggestions'], 10) + self.assertEqual(data['total_applied'], 7) + self.assertEqual(data['total_rejected'], 3) + self.assertEqual(data['accuracy_rate'], 70.0) + + def test_authentication_required(self): + """Test that authentication is required for all endpoints.""" + self.client.force_authenticate(user=None) + + # Test ai-suggestions endpoint + response = self.client.get( + f"/api/documents/{self.document.pk}/ai-suggestions/" + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # Test apply-suggestion endpoint + response = self.client.post( + f"/api/documents/{self.document.pk}/apply-suggestion/", + data={}, + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # Test reject-suggestion endpoint + response = self.client.post( + f"/api/documents/{self.document.pk}/reject-suggestion/", + data={}, + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # Test stats endpoint + response = self.client.get("/api/documents/ai-suggestion-stats/") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/src/documents/views.py b/src/documents/views.py index 898b75dba..74345bf64 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -1358,6 +1358,279 @@ class UnifiedSearchViewSet(DocumentViewSet): ) return Response(max_asn + 1) + @action(detail=True, methods=["GET"], name="Get AI Suggestions") + def ai_suggestions(self, request, pk=None): + """ + Get AI suggestions for a document. + + Returns AI-generated suggestions for tags, correspondent, document type, + storage path, custom fields, workflows, and title. + """ + from documents.ai_scanner import get_ai_scanner + from documents.serializers.ai_suggestions import AISuggestionsSerializer + + try: + document = self.get_object() + + # Check if document has content to scan + if not document.content: + return Response( + {"detail": "Document has no content to analyze"}, + status=400, + ) + + # Get AI scanner instance + scanner = get_ai_scanner() + + # Perform AI scan + scan_result = scanner.scan_document( + document=document, + document_text=document.content, + original_file_path=document.source_path if hasattr(document, 'source_path') else None, + ) + + # Convert scan result to serializable format + data = AISuggestionsSerializer.from_scan_result(scan_result, document.id) + + # Serialize and return + serializer = AISuggestionsSerializer(data=data) + serializer.is_valid(raise_exception=True) + + return Response(serializer.validated_data) + + except Exception as e: + logger.error(f"Error getting AI suggestions for document {pk}: {e}", exc_info=True) + return Response( + {"detail": "Error generating AI suggestions. Please check the logs for details."}, + status=500, + ) + + @action(detail=True, methods=["POST"], name="Apply AI Suggestion") + def apply_suggestion(self, request, pk=None): + """ + Apply an AI suggestion to a document. + + Records user feedback and applies the suggested change. + """ + from documents.models import AISuggestionFeedback + from documents.serializers.ai_suggestions import ApplySuggestionSerializer + + try: + document = self.get_object() + + # Validate input + serializer = ApplySuggestionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + suggestion_type = serializer.validated_data['suggestion_type'] + value_id = serializer.validated_data.get('value_id') + value_text = serializer.validated_data.get('value_text') + confidence = serializer.validated_data['confidence'] + + # Apply the suggestion based on type + applied = False + result_message = "" + + if suggestion_type == 'tag' and value_id: + tag = Tag.objects.get(pk=value_id) + document.tags.add(tag) + applied = True + result_message = f"Tag '{tag.name}' applied" + + elif suggestion_type == 'correspondent' and value_id: + correspondent = Correspondent.objects.get(pk=value_id) + document.correspondent = correspondent + document.save() + applied = True + result_message = f"Correspondent '{correspondent.name}' applied" + + elif suggestion_type == 'document_type' and value_id: + doc_type = DocumentType.objects.get(pk=value_id) + document.document_type = doc_type + document.save() + applied = True + result_message = f"Document type '{doc_type.name}' applied" + + elif suggestion_type == 'storage_path' and value_id: + storage_path = StoragePath.objects.get(pk=value_id) + document.storage_path = storage_path + document.save() + applied = True + result_message = f"Storage path '{storage_path.name}' applied" + + elif suggestion_type == 'title' and value_text: + document.title = value_text + document.save() + applied = True + result_message = f"Title updated to '{value_text}'" + + if applied: + # Record feedback + AISuggestionFeedback.objects.create( + document=document, + suggestion_type=suggestion_type, + suggested_value_id=value_id, + suggested_value_text=value_text or "", + confidence=confidence, + status=AISuggestionFeedback.STATUS_APPLIED, + user=request.user, + ) + + return Response({ + "status": "success", + "message": result_message, + }) + else: + return Response( + {"detail": "Invalid suggestion type or missing value"}, + status=400, + ) + + except (Tag.DoesNotExist, Correspondent.DoesNotExist, + DocumentType.DoesNotExist, StoragePath.DoesNotExist): + return Response( + {"detail": "Referenced object not found"}, + status=404, + ) + except Exception as e: + logger.error(f"Error applying suggestion for document {pk}: {e}", exc_info=True) + return Response( + {"detail": "Error applying suggestion. Please check the logs for details."}, + status=500, + ) + + @action(detail=True, methods=["POST"], name="Reject AI Suggestion") + def reject_suggestion(self, request, pk=None): + """ + Reject an AI suggestion for a document. + + Records user feedback for improving AI accuracy. + """ + from documents.models import AISuggestionFeedback + from documents.serializers.ai_suggestions import RejectSuggestionSerializer + + try: + document = self.get_object() + + # Validate input + serializer = RejectSuggestionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + suggestion_type = serializer.validated_data['suggestion_type'] + value_id = serializer.validated_data.get('value_id') + value_text = serializer.validated_data.get('value_text') + confidence = serializer.validated_data['confidence'] + + # Record feedback + AISuggestionFeedback.objects.create( + document=document, + suggestion_type=suggestion_type, + suggested_value_id=value_id, + suggested_value_text=value_text or "", + confidence=confidence, + status=AISuggestionFeedback.STATUS_REJECTED, + user=request.user, + ) + + return Response({ + "status": "success", + "message": "Suggestion rejected and feedback recorded", + }) + + except Exception as e: + logger.error(f"Error rejecting suggestion for document {pk}: {e}", exc_info=True) + return Response( + {"detail": "Error rejecting suggestion. Please check the logs for details."}, + status=500, + ) + + @action(detail=False, methods=["GET"], name="AI Suggestion Statistics") + def ai_suggestion_stats(self, request): + """ + Get statistics about AI suggestion accuracy. + + Returns aggregated data about applied vs rejected suggestions, + accuracy rates, and confidence scores. + """ + from django.db.models import Avg, Count, Q + from documents.models import AISuggestionFeedback + from documents.serializers.ai_suggestions import AISuggestionStatsSerializer + + try: + # Get overall counts + total_feedbacks = AISuggestionFeedback.objects.count() + total_applied = AISuggestionFeedback.objects.filter( + status=AISuggestionFeedback.STATUS_APPLIED + ).count() + total_rejected = AISuggestionFeedback.objects.filter( + status=AISuggestionFeedback.STATUS_REJECTED + ).count() + + # Calculate accuracy rate + accuracy_rate = (total_applied / total_feedbacks * 100) if total_feedbacks > 0 else 0 + + # 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 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, + 'applied': type_applied, + 'rejected': type_rejected, + 'accuracy_rate': (type_applied / type_total * 100) if type_total > 0 else 0, + } + + # Get average confidence scores + avg_confidence_applied = AISuggestionFeedback.objects.filter( + status=AISuggestionFeedback.STATUS_APPLIED + ).aggregate(Avg('confidence'))['confidence__avg'] or 0.0 + + avg_confidence_rejected = AISuggestionFeedback.objects.filter( + status=AISuggestionFeedback.STATUS_REJECTED + ).aggregate(Avg('confidence'))['confidence__avg'] or 0.0 + + # Get recent suggestions (last 10) + recent_suggestions = AISuggestionFeedback.objects.order_by('-created_at')[:10] + + # Build response data + from documents.serializers.ai_suggestions import AISuggestionFeedbackSerializer + data = { + 'total_suggestions': total_feedbacks, + 'total_applied': total_applied, + 'total_rejected': total_rejected, + 'accuracy_rate': accuracy_rate, + 'by_type': by_type, + 'average_confidence_applied': avg_confidence_applied, + 'average_confidence_rejected': avg_confidence_rejected, + 'recent_suggestions': AISuggestionFeedbackSerializer( + recent_suggestions, many=True + ).data, + } + + # Serialize and return + serializer = AISuggestionStatsSerializer(data=data) + serializer.is_valid(raise_exception=True) + + return Response(serializer.validated_data) + + except Exception as e: + logger.error(f"Error getting AI suggestion statistics: {e}", exc_info=True) + return Response( + {"detail": "Error getting statistics. Please check the logs for details."}, + status=500, + ) + @extend_schema_view( list=extend_schema(