diff --git a/backend/openedx_ai_extensions/admin.py b/backend/openedx_ai_extensions/admin.py index d01488f..f199a96 100644 --- a/backend/openedx_ai_extensions/admin.py +++ b/backend/openedx_ai_extensions/admin.py @@ -9,10 +9,73 @@ from django.utils.html import format_html from django.utils.safestring import mark_safe +from openedx_ai_extensions.models import PromptTemplate from openedx_ai_extensions.workflows.models import AIWorkflowProfile, AIWorkflowScope, AIWorkflowSession from openedx_ai_extensions.workflows.template_utils import discover_templates, parse_json5_string +@admin.register(PromptTemplate) +class PromptTemplateAdmin(admin.ModelAdmin): + """ + Admin interface for Prompt Templates - one big textbox for easy editing. + """ + + list_display = ('slug', 'body_preview', 'updated_at') + list_filter = ('created_at', 'updated_at') + search_fields = ('slug', 'body') + readonly_fields = ('id', 'created_at', 'updated_at') + + def get_fieldsets(self, request, obj=None): + """Return dynamic fieldsets with UUID example if editing existing object.""" + if obj and obj.pk: + # Editing existing - show UUID example + identification_description = ( + f'Slug is human-readable, ID is the stable UUID reference.
' + f'Use in profile: "prompt_template": "{obj.pk}" or ' + f'"prompt_template": "{obj.slug}"' + ) + else: + # Creating new + identification_description = ( + 'Slug is human-readable, ID will be generated automatically.' + ) + + return ( + ('Identification', { + 'fields': ('slug', 'id'), + 'description': format_html(identification_description) + }), + ('Prompt Content', { + 'fields': ('body',), + 'description': 'The prompt template text - edit in the big textbox below.' + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def get_form(self, request, obj=None, change=False, **kwargs): + """Customize the form to use a large textarea for body.""" + form = super().get_form(request, obj, change=change, **kwargs) + if 'body' in form.base_fields: + form.base_fields['body'].widget = forms.Textarea(attrs={ + 'rows': 25, + 'cols': 120, + 'class': 'vLargeTextField', + 'style': 'font-family: monospace; font-size: 14px;' + }) + return form + + def body_preview(self, obj): + """Show truncated body text.""" + if obj.body: + preview = obj.body[:80].replace('\n', ' ') + return preview + ('...' if len(obj.body) > 80 else '') + return '-' + body_preview.short_description = 'Prompt Preview' + + class AIWorkflowProfileAdminForm(forms.ModelForm): """Custom form for AIWorkflowProfile with template selection.""" diff --git a/backend/openedx_ai_extensions/migrations/0005_prompttemplate.py b/backend/openedx_ai_extensions/migrations/0005_prompttemplate.py new file mode 100644 index 0000000..abcbb6d --- /dev/null +++ b/backend/openedx_ai_extensions/migrations/0005_prompttemplate.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.20 on 2025-12-24 03:04 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('openedx_ai_extensions', '0004_aiworkflowsession_profile_aiworkflowscope_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PromptTemplate', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable UUID for referencing this template', primary_key=True, serialize=False)), + ('slug', models.SlugField(help_text="Human-readable identifier (e.g., 'eli5', 'summarize_unit')", max_length=100, unique=True)), + ('body', models.TextField(help_text='The prompt template text')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['slug'], + }, + ) + ] diff --git a/backend/openedx_ai_extensions/models.py b/backend/openedx_ai_extensions/models.py index 57dc6ad..f88d48f 100644 --- a/backend/openedx_ai_extensions/models.py +++ b/backend/openedx_ai_extensions/models.py @@ -1,6 +1,100 @@ """ Database models for openedx_ai_extensions. """ +import logging +import re +from uuid import uuid4 +from django.db import models -# TODO: your models here +logger = logging.getLogger(__name__) + + +class PromptTemplate(models.Model): + """ + Reusable prompt templates for AI workflows. + + This is the source for reusable prompt text. Profiles can reference + templates by slug (human-readable) or UUID (stable). + + Examples: + - slug: "eli5", "summarize_unit", "explain_concept" + - body: "You are a helpful AI that explains things simply..." + + .. no_pii: + """ + + id = models.UUIDField( + primary_key=True, + default=uuid4, + editable=False, + help_text="Stable UUID for referencing this template" + ) + slug = models.SlugField( + max_length=100, + unique=True, + help_text="Human-readable identifier (e.g., 'eli5', 'summarize_unit')" + ) + body = models.TextField( + help_text="The prompt template text" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + """Model metadata.""" + + ordering = ['slug'] + + def __str__(self): + """Return string representation.""" + return f"{self.slug}" + + def __repr__(self): + """Return detailed string representation.""" + return f"" + + @classmethod + def load_prompt(cls, template_identifier): + """ + Load prompt text by slug or UUID. + + Uses regex to detect UUID format and query accordingly for efficiency. + + Args: + template_identifier: Either a slug (str) or UUID string + + Returns: + str or None: The prompt body, or None if not found + """ + if not template_identifier: + return None + + # UUID pattern: 32 hex digits with or without dashes + uuid_pattern = re.compile( + r'^[a-f\d]{8}-?([a-f\d]{4}-?){3}[a-f\d]{12}$', + re.IGNORECASE + ) + if uuid_pattern.match(str(template_identifier)): + try: + template = cls.objects.get(id=template_identifier) + logger.info(f"Loaded prompt template by UUID: {template_identifier}") + return template.body + except cls.DoesNotExist: + logger.warning(f"PromptTemplate with UUID '{template_identifier}' not found") + return None + except Exception as e: # pylint: disable=broad-exception-caught + logger.warning(f"Error loading PromptTemplate by UUID '{template_identifier}': {e}") + return None + + # Otherwise, try as slug + try: + template = cls.objects.get(slug=template_identifier) + logger.info(f"Loaded prompt template by slug: {template_identifier}") + return template.body + except cls.DoesNotExist: + logger.warning(f"PromptTemplate with slug '{template_identifier}' not found") + return None + except Exception as e: # pylint: disable=broad-exception-caught + logger.warning(f"Error loading PromptTemplate by slug '{template_identifier}': {e}") + return None diff --git a/backend/openedx_ai_extensions/processors/litellm_base_processor.py b/backend/openedx_ai_extensions/processors/litellm_base_processor.py index f43f6a9..9018ae4 100644 --- a/backend/openedx_ai_extensions/processors/litellm_base_processor.py +++ b/backend/openedx_ai_extensions/processors/litellm_base_processor.py @@ -46,7 +46,7 @@ def __init__(self, config=None, user_session=None): ) self.provider = model.split("/")[0] - self.custom_prompt = self.config.get("prompt", None) + self.custom_prompt = self._load_prompt() self.stream = self.config.get("stream", False) enabled_tools = self.config.get("enabled_tools", []) @@ -80,6 +80,29 @@ def __init__(self, config=None, user_session=None): for key, value in self.mcp_configs.items() ] + def _load_prompt(self): + """ + Load prompt from PromptTemplate model or inline config. + + Priority: + 1. prompt_template: Load by slug or UUID (unified key) + 2. prompt: Use inline prompt (backwards compatibility) + 3. None: No custom prompt + + Returns: + str or None: The prompt text + """ + from openedx_ai_extensions.models import PromptTemplate # pylint: disable=import-outside-toplevel + + # Try loading from PromptTemplate (handles both slug and UUID) + template_id = self.config.get("prompt_template") + if template_id: + prompt = PromptTemplate.load_prompt(template_id) + if prompt: + return prompt + # Fall back to inline prompt (backwards compatibility) + return self.config.get("prompt") + def process(self, *args, **kwargs): """Process based on configured function - must be implemented by subclasses""" raise NotImplementedError("Subclasses must implement process method") diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index d69d021..e2bd6ff 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -3,10 +3,15 @@ Tests for the `openedx-ai-extensions` models module. """ +import time +from unittest.mock import Mock + import pytest from django.contrib.auth import get_user_model from opaque_keys.edx.keys import CourseKey +from openedx_ai_extensions.models import PromptTemplate + User = get_user_model() @@ -39,3 +44,148 @@ def course_key(): Create and return a test course key. """ return CourseKey.from_string("course-v1:edX+DemoX+Demo_Course") + + +@pytest.fixture +def prompt_template(): + """ + Create and return a test prompt template. + """ + return PromptTemplate.objects.create( + slug="test-prompt", + body="You are a helpful AI assistant. Please help with: {context}" + ) + + +@pytest.mark.django_db +class TestPromptTemplate: + """Tests for PromptTemplate model.""" + + # pylint: disable=redefined-outer-name + # Note: pytest fixtures intentionally "redefine" names from outer scope + + def test_create_prompt_template(self): + """Test creating a PromptTemplate instance.""" + template = PromptTemplate.objects.create( + slug="eli5", + body="Explain this like I'm five years old: {content}" + ) + assert template.slug == "eli5" + assert template.body == "Explain this like I'm five years old: {content}" + assert template.id is not None + assert template.created_at is not None + assert template.updated_at is not None + + def test_prompt_template_str(self, prompt_template): + """Test __str__ method returns slug.""" + assert str(prompt_template) == "test-prompt" + + def test_prompt_template_repr(self, prompt_template): + """Test __repr__ method.""" + assert repr(prompt_template) == "" + + def test_load_prompt_by_slug(self, prompt_template): + """Test loading prompt by slug.""" + result = PromptTemplate.load_prompt(prompt_template.slug) + assert result == prompt_template.body + + def test_load_prompt_by_uuid(self, prompt_template): + """Test loading prompt by UUID.""" + result = PromptTemplate.load_prompt(str(prompt_template.id)) + assert result == "You are a helpful AI assistant. Please help with: {context}" + + def test_load_prompt_by_uuid_without_dashes(self, prompt_template): + """Test loading prompt by UUID without dashes.""" + uuid_str = str(prompt_template.id).replace('-', '') + result = PromptTemplate.load_prompt(uuid_str) + assert result == "You are a helpful AI assistant. Please help with: {context}" + + def test_load_prompt_nonexistent_slug(self): + """Test loading prompt with nonexistent slug returns None.""" + result = PromptTemplate.load_prompt("nonexistent-slug") + assert result is None + + def test_load_prompt_nonexistent_uuid(self): + """Test loading prompt with nonexistent UUID returns None.""" + result = PromptTemplate.load_prompt("12345678-1234-1234-1234-123456789abc") + assert result is None + + def test_load_prompt_empty_identifier(self): + """Test loading prompt with empty identifier returns None.""" + assert PromptTemplate.load_prompt("") is None + assert PromptTemplate.load_prompt(None) is None + + def test_load_prompt_invalid_identifier(self): + """Test loading prompt with invalid identifier returns None.""" + result = PromptTemplate.load_prompt("not-a-real-slug-or-uuid-12345") + assert result is None + + def test_prompt_template_ordering(self): + """Test that prompt templates are ordered by slug.""" + PromptTemplate.objects.create(slug="zebra", body="Z prompt") + PromptTemplate.objects.create(slug="alpha", body="A prompt") + PromptTemplate.objects.create(slug="beta", body="B prompt") + + templates = list(PromptTemplate.objects.all()) + slugs = [t.slug for t in templates] + assert slugs == sorted(slugs) + + def test_prompt_template_unique_slug(self, prompt_template): + """Test that slug must be unique.""" + # prompt_template fixture creates a template with slug "test-prompt" + # Try to create another with the same slug - should fail + with pytest.raises(Exception): # IntegrityError + PromptTemplate.objects.create( + slug=prompt_template.slug, + body="Different body" + ) + + def test_load_prompt_uuid_database_error(self, prompt_template, monkeypatch): + """Test loading prompt by UUID handles database errors gracefully.""" + + # Mock objects.get to raise a database error + mock_objects = Mock() + mock_objects.get.side_effect = ValueError("Database connection error") + monkeypatch.setattr(PromptTemplate, 'objects', mock_objects) + + # Should return None on error + result = PromptTemplate.load_prompt(str(prompt_template.id)) + assert result is None + + def test_load_prompt_slug_database_error(self, monkeypatch): + """Test loading prompt by slug handles database errors gracefully.""" + + # Mock objects.get to raise a database error + mock_objects = Mock() + mock_objects.get.side_effect = RuntimeError("Database error") + monkeypatch.setattr(PromptTemplate, 'objects', mock_objects) + + # Should return None on error + result = PromptTemplate.load_prompt("some-slug") + assert result is None + + def test_prompt_template_updated_at(self, prompt_template): + """Test that updated_at changes when model is saved.""" + + original_updated = prompt_template.updated_at + + # Wait a tiny bit and update + time.sleep(0.01) + prompt_template.body = "Updated body content" + prompt_template.save() + + # Refresh from database + prompt_template.refresh_from_db() + assert prompt_template.updated_at > original_updated + + def test_prompt_template_case_sensitive_uuid(self, prompt_template): + """Test that UUID matching is case-insensitive.""" + # Test with uppercase UUID + uuid_upper = str(prompt_template.id).upper() + result = PromptTemplate.load_prompt(uuid_upper) + assert result == prompt_template.body + + # Test with mixed case + uuid_mixed = str(prompt_template.id).replace('a', 'A').replace('b', 'B') + result = PromptTemplate.load_prompt(uuid_mixed) + assert result == prompt_template.body