Skip to content

Commit 1261b40

Browse files
committed
feat: base model
1 parent 70959a6 commit 1261b40

File tree

4 files changed

+206
-2
lines changed

4 files changed

+206
-2
lines changed

backend/openedx_ai_extensions/admin.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,77 @@
99
from django.utils.html import format_html
1010
from django.utils.safestring import mark_safe
1111

12-
from openedx_ai_extensions.workflows.models import AIWorkflowProfile, AIWorkflowScope, AIWorkflowSession
12+
from openedx_ai_extensions.workflows.models import (
13+
AIWorkflowProfile,
14+
AIWorkflowScope,
15+
AIWorkflowSession,
16+
PromptTemplate,
17+
)
1318
from openedx_ai_extensions.workflows.template_utils import discover_templates, parse_json5_string
1419

1520

21+
@admin.register(PromptTemplate)
22+
class PromptTemplateAdmin(admin.ModelAdmin):
23+
"""
24+
Admin interface for Prompt Templates - one big textbox for easy editing.
25+
"""
26+
27+
list_display = ('slug', 'body_preview', 'updated_at')
28+
list_filter = ('created_at', 'updated_at')
29+
search_fields = ('slug', 'body')
30+
readonly_fields = ('id', 'created_at', 'updated_at')
31+
32+
def get_fieldsets(self, request, obj=None):
33+
"""Return dynamic fieldsets with UUID example if editing existing object."""
34+
if obj and obj.pk:
35+
# Editing existing - show UUID example
36+
identification_description = (
37+
f'Slug is human-readable, ID is the stable UUID reference. <br/>'
38+
f'Use in profile: <code>"prompt_template": "{obj.pk}"</code> or '
39+
f'<code>"prompt_template": "{obj.slug}"</code>'
40+
)
41+
else:
42+
# Creating new
43+
identification_description = (
44+
'Slug is human-readable, ID will be generated automatically.'
45+
)
46+
47+
return (
48+
('Identification', {
49+
'fields': ('slug', 'id'),
50+
'description': format_html(identification_description)
51+
}),
52+
('Prompt Content', {
53+
'fields': ('body',),
54+
'description': 'The prompt template text - edit in the big textbox below.'
55+
}),
56+
('Timestamps', {
57+
'fields': ('created_at', 'updated_at'),
58+
'classes': ('collapse',)
59+
}),
60+
)
61+
62+
def get_form(self, request, obj=None, change=False, **kwargs):
63+
"""Customize the form to use a large textarea for body."""
64+
form = super().get_form(request, obj, change=change, **kwargs)
65+
if 'body' in form.base_fields:
66+
form.base_fields['body'].widget = forms.Textarea(attrs={
67+
'rows': 25,
68+
'cols': 120,
69+
'class': 'vLargeTextField',
70+
'style': 'font-family: monospace; font-size: 14px;'
71+
})
72+
return form
73+
74+
def body_preview(self, obj):
75+
"""Show truncated body text."""
76+
if obj.body:
77+
preview = obj.body[:80].replace('\n', ' ')
78+
return preview + ('...' if len(obj.body) > 80 else '')
79+
return '-'
80+
body_preview.short_description = 'Prompt Preview'
81+
82+
1683
class AIWorkflowProfileAdminForm(forms.ModelForm):
1784
"""Custom form for AIWorkflowProfile with template selection."""
1885

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 4.2.20 on 2025-12-24 03:04
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import uuid
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('openedx_ai_extensions', '0004_aiworkflowsession_profile_aiworkflowscope_and_more'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='PromptTemplate',
17+
fields=[
18+
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Stable UUID for referencing this template', primary_key=True, serialize=False)),
19+
('slug', models.SlugField(help_text="Human-readable identifier (e.g., 'eli5', 'summarize_unit')", max_length=100, unique=True)),
20+
('body', models.TextField(help_text='The prompt template text')),
21+
('created_at', models.DateTimeField(auto_now_add=True)),
22+
('updated_at', models.DateTimeField(auto_now=True)),
23+
],
24+
options={
25+
'ordering': ['slug'],
26+
},
27+
)
28+
]

backend/openedx_ai_extensions/processors/litellm_base_processor.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def __init__(self, config=None, user_session=None):
4646
)
4747

4848
self.provider = model.split("/")[0]
49-
self.custom_prompt = self.config.get("prompt", None)
49+
self.custom_prompt = self._load_prompt()
5050
self.stream = self.config.get("stream", False)
5151

5252
enabled_tools = self.config.get("enabled_tools", [])
@@ -80,6 +80,30 @@ def __init__(self, config=None, user_session=None):
8080
for key, value in self.mcp_configs.items()
8181
]
8282

83+
def _load_prompt(self):
84+
"""
85+
Load prompt from PromptTemplate model or inline config.
86+
87+
Priority:
88+
1. prompt_template: Load by slug or UUID (unified key)
89+
2. prompt: Use inline prompt (backwards compatibility)
90+
3. None: No custom prompt
91+
92+
Returns:
93+
str or None: The prompt text
94+
"""
95+
# pylint: disable=import-error,import-outside-toplevel
96+
from openedx_ai_extensions.workflows.models import PromptTemplate
97+
98+
# Try loading from PromptTemplate (handles both slug and UUID)
99+
template_id = self.config.get("prompt_template")
100+
if template_id:
101+
prompt = PromptTemplate.load_prompt(template_id)
102+
if prompt:
103+
return prompt
104+
# Fall back to inline prompt (backwards compatibility)
105+
return self.config.get("prompt")
106+
83107
def process(self, *args, **kwargs):
84108
"""Process based on configured function - must be implemented by subclasses"""
85109
raise NotImplementedError("Subclasses must implement process method")

backend/openedx_ai_extensions/workflows/models.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,91 @@
2626
logger = logging.getLogger(__name__)
2727

2828

29+
class PromptTemplate(models.Model):
30+
"""
31+
Reusable prompt templates for AI workflows.
32+
33+
This is the source for reusable prompt text. Profiles can reference
34+
templates by slug (human-readable) or UUID (stable).
35+
36+
Examples:
37+
- slug: "eli5", "summarize_unit", "explain_concept"
38+
- body: "You are a helpful AI that explains things simply..."
39+
40+
.. no_pii:
41+
"""
42+
id = models.UUIDField(
43+
primary_key=True,
44+
default=uuid4,
45+
editable=False,
46+
help_text="Stable UUID for referencing this template"
47+
)
48+
slug = models.SlugField(
49+
max_length=100,
50+
unique=True,
51+
help_text="Human-readable identifier (e.g., 'eli5', 'summarize_unit')"
52+
)
53+
body = models.TextField(
54+
help_text="The prompt template text"
55+
)
56+
created_at = models.DateTimeField(auto_now_add=True)
57+
updated_at = models.DateTimeField(auto_now=True)
58+
59+
class Meta:
60+
ordering = ['slug']
61+
62+
def __str__(self):
63+
return f"{self.slug}"
64+
65+
def __repr__(self):
66+
return f"<PromptTemplate: {self.slug}>"
67+
68+
@classmethod
69+
def load_prompt(cls, template_identifier):
70+
"""
71+
Load prompt text by slug or UUID.
72+
73+
Uses regex to detect UUID format and query accordingly for efficiency.
74+
75+
Args:
76+
template_identifier: Either a slug (str) or UUID string
77+
78+
Returns:
79+
str or None: The prompt body, or None if not found
80+
"""
81+
if not template_identifier:
82+
return None
83+
84+
# UUID pattern: 32 hex digits with or without dashes
85+
uuid_pattern = re.compile(
86+
r'^[a-f\d]{8}-?([a-f\d]{4}-?){3}[a-f\d]{12}$',
87+
re.IGNORECASE
88+
)
89+
if uuid_pattern.match(str(template_identifier)):
90+
try:
91+
template = cls.objects.get(id=template_identifier)
92+
logger.info(f"Loaded prompt template by UUID: {template_identifier}")
93+
return template.body
94+
except cls.DoesNotExist:
95+
logger.warning(f"PromptTemplate with UUID '{template_identifier}' not found")
96+
return None
97+
except Exception as e: # pylint: disable=broad-exception-caught
98+
logger.warning(f"Error loading PromptTemplate by UUID '{template_identifier}': {e}")
99+
return None
100+
101+
# Otherwise, try as slug
102+
try:
103+
template = cls.objects.get(slug=template_identifier)
104+
logger.info(f"Loaded prompt template by slug: {template_identifier}")
105+
return template.body
106+
except cls.DoesNotExist:
107+
logger.warning(f"PromptTemplate with slug '{template_identifier}' not found")
108+
return None
109+
except Exception as e: # pylint: disable=broad-exception-caught
110+
logger.warning(f"Error loading PromptTemplate by slug '{template_identifier}': {e}")
111+
return None
112+
113+
29114
class AIWorkflowProfile(models.Model):
30115
"""
31116
Workflow profile combining a disk-based template with database overrides.

0 commit comments

Comments
 (0)