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