Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: generate article summaries with chat gpt #203

Merged
merged 8 commits into from
Dec 17, 2024
74 changes: 74 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
iankressin marked this conversation as resolved.
Show resolved Hide resolved
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
iankressin marked this conversation as resolved.
Show resolved Hide resolved
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
iankressin marked this conversation as resolved.
Show resolved Hide resolved
51 changes: 47 additions & 4 deletions server/apps/research/admin/article_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
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:
model = Article
Expand All @@ -15,16 +18,51 @@
super().__init__(*args, **kwargs)
self.fields['acknowledgement'].widget = TinyMCE(attrs={'cols': 80, 'rows': 30, 'id': "acknowledgement_richtext_field", 'placeholder': f"Enter Acknowledgement here"})
self.fields['content'].widget = TinyMCE(attrs={'cols': 80, 'rows': 30, 'id': "content_richtext_field", 'placeholder': f"Enter Article Content here"})
self.fields['gpt_summary'].widget = TinyMCE(attrs={'cols': 80, 'rows': 15, 'id': "gpt_summary_richtext_field", 'placeholder': f"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 <h3> 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)
Comment on lines +64 to +73
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Move system prompt to settings or constants

The system prompt should be moved to a settings file or constants module for better maintainability and reusability.

+# In apps/research/constants.py
+GPT_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:
+- Provide enough depth and detail so the user gets a complete understanding of the core ideas.
+- Be in HTML format, use <h3> tags for headings if needed. Avoid other heading levels.
+- Minimize the use of bullet points. If you need to list items, you can, but prefer concise paragraph formatting.
+"""

# In article_admin.py
+from ..constants import GPT_SYSTEM_PROMPT

     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 <h3> 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)
+        return await self.gpt_service.prompt(GPT_SYSTEM_PROMPT, content)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 <h3> 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)
async def _generate_summary(self, content: str) -> str:
return await self.gpt_service.prompt(GPT_SYSTEM_PROMPT, content)


def generate_summary_view(self, request):
if request.method == 'POST':
content = request.POST.get('content')
try:
# Run the async function in the sync view
gpt_summary = asyncio.run(self._generate_summary(content))
return JsonResponse({'summary': gpt_summary})
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
Fixed Show fixed Hide fixed
iankressin marked this conversation as resolved.
Show resolved Hide resolved
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'

fieldsets = [
('Article Details', {'fields': ['title', 'slug', 'authors', 'acknowledgement', 'categories', 'thumb', 'content', 'summary', 'status', 'scheduled_publish_time']}),
('Article Details', {'fields': ['title', 'slug', 'authors', 'acknowledgement', 'categories', 'thumb', 'content', 'summary', 'gpt_summary', 'status', 'scheduled_publish_time']}),
('Sponsorship Details', {'fields': ['is_sponsored', 'sponsor_color', 'sponsor_text_color']}),
('URL Management', {
'fields': ('current_slug_history',),
Expand All @@ -38,7 +76,12 @@
list_filter = ('authors', 'status', 'categories', 'created_at', 'is_sponsored')
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."""
Expand Down
Original file line number Diff line number Diff line change
@@ -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_alter_category_slug'),
]

operations = [
migrations.AddField(
model_name='article',
name='gpt_summary',
field=tinymce.models.HTMLField(blank=True, null=True),
),
]
1 change: 1 addition & 0 deletions server/apps/research/models/article.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class Article(BaseModel):
title = models.TextField()
content = HTMLField(blank=True, null=True)
summary = models.TextField(blank=True)
gpt_summary = models.TextField(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)
Expand Down
4 changes: 4 additions & 0 deletions server/apps/research/services/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""
Services package for the research app.
This package contains service classes that handle business logic and external API interactions.
"""
42 changes: 42 additions & 0 deletions server/apps/research/services/gpt_service.py
Original file line number Diff line number Diff line change
@@ -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)}")
5 changes: 4 additions & 1 deletion server/core/config/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,7 @@
SILENCED_SYSTEM_CHECKS = ["security.W019"]

# Tinymce API Config
TINYMCE_API_KEY = config('TINYMCE_API_KEY')
TINYMCE_API_KEY = config('TINYMCE_API_KEY')

# OpenAI API Config
OPENAI_API_KEY = config('OPENAI_API_KEY', default=None)
28 changes: 28 additions & 0 deletions server/static/css/article_admin.css
Original file line number Diff line number Diff line change
@@ -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;
}
101 changes: 101 additions & 0 deletions server/static/js/article_admin.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading