diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c4ee82c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,74 @@ +amqp==5.2.0 +annotated-types==0.7.0 +anyio==4.4.0 +asgiref==3.8.1 +attrs==24.1.0 +beautifulsoup4==4.12.3 +billiard==4.2.0 +bs4==0.0.2 +celery==5.4.0 +certifi==2024.7.4 +cffi==1.17.0 +charset-normalizer==3.3.2 +click==8.1.7 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +cron-descriptor==1.4.3 +cryptography==43.0.0 +distro==1.9.0 +Django==5.0.8 +django-admin-interface==0.28.8 +django-celery-beat==2.6.0 +django-ckeditor==6.7.1 +django-ckeditor-5==0.2.13 +django-colorfield==0.11.0 +django-cors-headers==4.4.0 +django-filter==24.2 +django-jazzmin==3.0.0 +django-js-asset==2.2.0 +django-shortcuts==1.6 +django-sortedm2m==4.0.0 +django-timezone-field==7.0 +django-tinymce==4.1.0 +djangorestframework==3.15.2 +drf-spectacular==0.27.2 +h11==0.14.0 +httpcore==1.0.5 +httpx==0.27.0 +idna==3.7 +inflection==0.5.1 +jiter==0.8.2 +jsonschema==4.23.0 +jsonschema-specifications==2023.12.1 +kombu==5.4.0 +openai==1.57.2 +pillow==10.4.0 +prompt_toolkit==3.0.47 +pycparser==2.22 +pydantic==2.10.3 +pydantic_core==2.27.1 +pyOpenSSL==24.2.1 +python-crontab==3.2.0 +python-dateutil==2.9.0.post0 +python-decouple==3.8 +python-dotenv==1.0.1 +python-slugify==8.0.4 +PyYAML==6.0.1 +redis==5.0.8 +referencing==0.35.1 +requests==2.32.3 +rpds-py==0.19.1 +six==1.16.0 +sniffio==1.3.1 +soupsieve==2.6 +sqlparse==0.5.1 +text-unidecode==1.3 +tqdm==4.67.1 +typing_extensions==4.12.2 +tzdata==2024.1 +uritemplate==4.1.1 +urllib3==2.2.2 +vine==5.1.0 +wcwidth==0.2.13 +whitenoise==6.7.0 diff --git a/server/apps/research/admin/article_admin.py b/server/apps/research/admin/article_admin.py index 7dc624c..1a29c07 100644 --- a/server/apps/research/admin/article_admin.py +++ b/server/apps/research/admin/article_admin.py @@ -4,6 +4,11 @@ from apps.research.models import Article, ArticleSlugHistory from tinymce.widgets import TinyMCE from .slug_history import current_slug_history +from django.conf import settings +from django.http import JsonResponse +from django.urls import path +from ..services.gpt_service import GPTService +import asyncio class ArticleForm(forms.ModelForm): class Meta: @@ -21,13 +26,64 @@ def __init__(self, *args, **kwargs): Q(pk=self.instance.pk) | Q(status='draft') ).order_by('-scheduled_publish_time') - self.fields['acknowledgement'].widget = TinyMCE(attrs={'cols': 80, 'rows': 30, 'id': "acknowledgement_richtext_field", 'placeholder': "Enter Acknowledgement here"}) - self.fields['content'].widget = TinyMCE(attrs={'cols': 80, 'rows': 30, 'id': "content_richtext_field", 'placeholder': "Enter Article Content here"}) + # Configure TinyMCE widgets + self.fields['acknowledgement'].widget = TinyMCE(attrs={ + 'cols': 80, + 'rows': 30, + 'id': "acknowledgement_richtext_field", + 'placeholder': "Enter Acknowledgement here" + }) + self.fields['content'].widget = TinyMCE(attrs={ + 'cols': 80, + 'rows': 30, + 'id': "content_richtext_field", + 'placeholder': "Enter Article Content here" + }) + self.fields['gpt_summary'].widget = TinyMCE(attrs={ + 'cols': 80, + 'rows': 15, + 'id': "gpt_summary_richtext_field", + 'placeholder': "GPT-generated summary will appear here" + }) class ArticleAdmin(admin.ModelAdmin): """Admin interface for the Article model.""" form = ArticleForm + def __init__(self, model, admin_site): + super().__init__(model, admin_site) + self.gpt_service = GPTService() + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('generate-summary/', self.generate_summary_view, name='generate-summary'), + ] + return custom_urls + urls + + async def _generate_summary(self, content: str) -> str: + system_prompt = ( + "You are a professional summarizer at 2077 Research. Below is an article on Ethereum technical aspects. " + "Your goal is to produce a summary that is shorter than the original content, yet detailed enough for readers " + "to fully understand the piece without needing to read the original. Your summary should:\n" + "- Provide enough depth and detail so the user gets a complete understanding of the core ideas.\n" + "- Be in HTML format, use

tags for headings if needed. Avoid other heading levels.\n" + "- Minimize the use of bullet points. If you need to list items, you can, but prefer concise paragraph formatting.\n\n" + ) + return await self.gpt_service.prompt(system_prompt, content) + + def generate_summary_view(self, request): + if request.method == 'POST': + content = request.POST.get('content') + try: + gpt_summary = asyncio.run(self._generate_summary(content)) + return JsonResponse({'summary': gpt_summary}) + except Exception as e: + import logging + logging.error("An error occurred while generating the summary", exc_info=True) + return JsonResponse({'error': 'An internal error has occurred!'}, status=500) + return JsonResponse({'error': 'Invalid request method'}, status=400) + def current_slug_history(self, obj): return current_slug_history(obj) current_slug_history.short_description = 'Slug Change History' @@ -36,7 +92,7 @@ def current_slug_history(self, obj): ('Article Details', { 'fields': [ 'title', 'slug', 'authors', 'acknowledgement', 'categories', - 'thumb', 'content', 'summary', 'status', 'scheduled_publish_time' + 'thumb', 'content', 'summary', 'gpt_summary', 'status', 'scheduled_publish_time' ] }), ('Related Content', { @@ -60,6 +116,12 @@ def current_slug_history(self, obj): readonly_fields = ('views', 'current_slug_history',) list_editable = ('status',) + class Media: + css = { + 'all': ('css/article_admin.css',) + } + js = ('js/article_admin.js',) + def display_authors(self, obj): """Return a comma-separated list of authors for the article.""" return ", ".join(author.user.username for author in obj.authors.all()) diff --git a/server/apps/research/migrations/0017_article_gpt_summary_alter_article_summary.py b/server/apps/research/migrations/0017_article_gpt_summary_alter_article_summary.py new file mode 100644 index 0000000..939a188 --- /dev/null +++ b/server/apps/research/migrations/0017_article_gpt_summary_alter_article_summary.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2024-12-10 18:18 + +import tinymce.models +from django.db import migrations + +class Migration(migrations.Migration): + + dependencies = [ + ('research', '0016_article_related_articles'), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='gpt_summary', + field=tinymce.models.HTMLField(blank=True, null=True), + ), + ] diff --git a/server/apps/research/models/article.py b/server/apps/research/models/article.py index 4b607e6..6b1c3b3 100644 --- a/server/apps/research/models/article.py +++ b/server/apps/research/models/article.py @@ -28,6 +28,7 @@ class Article(BaseModel): title = models.TextField() content = HTMLField(blank=True, null=True) summary = models.TextField(blank=True) + gpt_summary = HTMLField(blank=True, null=True) acknowledgement = HTMLField(blank=True, null=True) authors = models.ManyToManyField(Author, blank=True, related_name='articles') slug = models.SlugField(max_length=255, blank=True, db_index=True) diff --git a/server/apps/research/serializers/article_serializer.py b/server/apps/research/serializers/article_serializer.py index 0832cfc..f824563 100644 --- a/server/apps/research/serializers/article_serializer.py +++ b/server/apps/research/serializers/article_serializer.py @@ -55,6 +55,7 @@ class Meta: "created_at", "updated_at", "table_of_contents", + "gpt_summary", ] @@ -90,6 +91,7 @@ class Meta: "min_read", "status", "views", + "gpt_summary", "created_at", "updated_at", "scheduled_publish_time", @@ -123,6 +125,7 @@ class Meta: "thumb", "content", "summary", + "gpt_summary", "acknowledgement", "status", "authors", @@ -185,4 +188,4 @@ def update(self, instance: Article, validated_data: dict) -> Article: return instance except Exception as e: - raise serializers.ValidationError(f"Error updating article: {str(e)}") + raise serializers.ValidationError(f"Error updating article: {str(e)}") \ No newline at end of file diff --git a/server/apps/research/services/__init__.py b/server/apps/research/services/__init__.py new file mode 100644 index 0000000..5aaea07 --- /dev/null +++ b/server/apps/research/services/__init__.py @@ -0,0 +1,4 @@ +""" +Services package for the research app. +This package contains service classes that handle business logic and external API interactions. +""" \ No newline at end of file diff --git a/server/apps/research/services/gpt_service.py b/server/apps/research/services/gpt_service.py new file mode 100644 index 0000000..4ea1010 --- /dev/null +++ b/server/apps/research/services/gpt_service.py @@ -0,0 +1,42 @@ +from django.conf import settings +from openai import AsyncOpenAI + +class GPTService: + """Service for handling OpenAI GPT API interactions.""" + + def __init__(self): + self.client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) + self.model = "gpt-3.5-turbo" + self.max_tokens = 500 + + async def prompt(self, system: str, user: str) -> str: + """ + Send a prompt to GPT and get the response. + + Args: + system (str): The system message that sets the behavior of the assistant + user (str): The user's input/question + + Returns: + str: The generated response from GPT + + Raises: + Exception: If there's an error in the API call or if the API key is not set + """ + if not settings.OPENAI_API_KEY: + raise Exception("OpenAI API key is not configured") + + try: + completion = await self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user} + ], + max_tokens=self.max_tokens + ) + # Access the response content directly from the completion object + return completion.choices[0].message.content + except Exception as e: + print(e) + raise Exception(f"Error calling OpenAI API: {str(e)}") \ No newline at end of file diff --git a/server/core/config/base.py b/server/core/config/base.py index 1d6d46e..d043e3b 100644 --- a/server/core/config/base.py +++ b/server/core/config/base.py @@ -223,4 +223,7 @@ SILENCED_SYSTEM_CHECKS = ["security.W019"] # Tinymce API Config -TINYMCE_API_KEY = config('TINYMCE_API_KEY') \ No newline at end of file +TINYMCE_API_KEY = config('TINYMCE_API_KEY') + +# OpenAI API Config +OPENAI_API_KEY = config('OPENAI_API_KEY', default=None) \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt index a9ee425..3b32972 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,4 +1,5 @@ amqp==5.2.0 +annotated-types==0.7.0 anyio==4.4.0 asgiref==3.8.1 attrs==24.1.0 @@ -15,6 +16,7 @@ click-plugins==1.1.1 click-repl==0.3.0 cron-descriptor==1.4.3 cryptography==43.0.0 +distro==1.9.0 Django==5.0.8 django-admin-interface==0.28.8 django-celery-beat==2.6.0 @@ -33,12 +35,16 @@ httpcore==1.0.5 httpx==0.27.0 idna==3.7 inflection==0.5.1 +jiter==0.8.2 jsonschema==4.23.0 jsonschema-specifications==2023.12.1 kombu==5.4.0 +openai==1.57.4 pillow==10.4.0 prompt_toolkit==3.0.47 pycparser==2.22 +pydantic==2.10.3 +pydantic_core==2.27.1 pyOpenSSL==24.2.1 python-crontab==3.2.0 python-dateutil==2.9.0.post0 @@ -55,6 +61,8 @@ sniffio==1.3.1 soupsieve==2.6 sqlparse==0.5.1 text-unidecode==1.3 +tqdm==4.67.1 +typing_extensions==4.12.2 tzdata==2024.1 uritemplate==4.1.1 urllib3==2.2.2 diff --git a/server/static/css/article_admin.css b/server/static/css/article_admin.css new file mode 100644 index 0000000..fef5266 --- /dev/null +++ b/server/static/css/article_admin.css @@ -0,0 +1,28 @@ +.generate-summary-btn { + background-color: #0C4B33; + color: white; + padding: 10px 15px; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + margin-left: 10px; + align-self: baseline; +} + +.generate-summary-btn:disabled { + background-color: #cccccc; + cursor: not-allowed; +} + +.summary-status { + margin-left: 10px; + font-style: italic; + color: #666; +} + +.generate-summary-container-btn { + display: flex; + flex-direction: column; + gap: 10px; +} \ No newline at end of file diff --git a/server/static/js/article_admin.js b/server/static/js/article_admin.js new file mode 100644 index 0000000..4e28844 --- /dev/null +++ b/server/static/js/article_admin.js @@ -0,0 +1,101 @@ +document.addEventListener('DOMContentLoaded', function() { + // Wait for TinyMCE to initialize + if (typeof tinymce !== 'undefined') { + const gptSummaryContainer = document.getElementById('gpt_summary_richtext_field'); + if (gptSummaryContainer) { + const buttonContainer = document.createElement('div'); + buttonContainer.id = 'generate-summary-container-btn'; + + const button = document.createElement('button'); + button.type = 'button'; + button.id = 'generate-summary-btn'; + button.className = 'generate-summary-btn'; + button.textContent = 'Generate Summary'; + + const statusSpan = document.createElement('p'); + statusSpan.className = 'summary-status'; + statusSpan.id = 'summary-status'; + + buttonContainer.appendChild(button); + buttonContainer.appendChild(statusSpan); + + gptSummaryContainer.parentNode.insertBefore(buttonContainer, gptSummaryContainer.nextSibling); + button.parentNode.insertBefore(statusSpan, button.nextSibling); + + button.addEventListener('click', function() { + const contentEditor = tinymce.get('content_richtext_field'); + const gptSummaryContainer = document.getElementById('gpt_summary_richtext_field'); + statusSpan.textContent = ' Generating summary...'; + + if (contentEditor && gptSummaryContainer) { + const content = contentEditor.getContent(); + if (!content.trim()) { + alert('Please enter some content before generating a summary.'); + return; + } + + // Call the GPT API + generateSummary(content) + .then(() => { + statusSpan.textContent = ' Summary generated successfully!'; + }) + .catch(error => { + statusSpan.textContent = ' Error generating summary. Please try again.'; + }) + .finally(() => { + // Re-enable editors and button + contentEditor.setMode('design'); + gptSummaryEditor.setMode('design'); + button.disabled = false; + }); + } + }); + } + } +}); + +async function generateSummary(content) { + try { + const response = await fetch('/admin/research/article/generate-summary/', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: 'content=' + encodeURIComponent(content) + }); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const data = await response.json(); + + // Update the summary field + if (typeof tinymce !== 'undefined') { + const summaryEditor = tinymce.get('gpt_summary_richtext_field'); + if (summaryEditor) { + summaryEditor.setContent(data.summary); + } + } + } catch (error) { + console.error('Error:', error); + throw error; + } +} + +// Helper function to get CSRF token +function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} \ No newline at end of file