F: Implement backend for prompts

This commit is contained in:
Ivan 2025-07-13 17:58:32 +03:00
parent 1fda7c79c3
commit e4411c2c78
26 changed files with 504 additions and 22 deletions

View File

@ -0,0 +1,12 @@
''' Admin view: Prompts for AI helper. '''
from django.contrib import admin
from . import models
@admin.register(models.PromptTemplate)
class PromptTemplateAdmin(admin.ModelAdmin):
''' Admin model: PromptTemplate. '''
list_display = ('id', 'label', 'owner', 'is_shared')
list_filter = ('is_shared', 'owner')
search_fields = ('label', 'description', 'text')

View File

@ -0,0 +1,8 @@
''' Application: Prompts for AI helper. '''
from django.apps import AppConfig
class PromptConfig(AppConfig):
''' Application config. '''
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.prompt'

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2.4 on 2025-07-13 13:11
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PromptTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_shared', models.BooleanField(default=False, verbose_name='Общий доступ')),
('label', models.CharField(max_length=255, verbose_name='Название')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('text', models.TextField(blank=True, verbose_name='Содержание')),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prompt_templates', to=settings.AUTH_USER_MODEL, verbose_name='Владелец')),
],
),
]

View File

@ -0,0 +1,46 @@
''' Model: PromptTemplate for AI prompt storage and sharing. '''
from django.db import models
from apps.users.models import User
class PromptTemplate(models.Model):
'''Represents an AI prompt template, which can be user-owned or shared globally.'''
owner = models.ForeignKey(
verbose_name='Владелец',
to=User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='prompt_templates'
)
is_shared = models.BooleanField(
verbose_name='Общий доступ',
default=False
)
label = models.CharField(
verbose_name='Название',
max_length=255
)
description = models.TextField(
verbose_name='Описание',
blank=True
)
text = models.TextField(
verbose_name='Содержание',
blank=True
)
def can_set_shared(self, user: User) -> bool:
'''Return True if the user can set is_shared=True (admin/staff only).'''
return user.is_superuser or user.is_staff
def can_access(self, user: User) -> bool:
'''Return True if the user can access this template (shared or owner).'''
if self.is_shared:
return True
return self.owner == user
def __str__(self) -> str:
return f'{self.label}'

View File

@ -0,0 +1,3 @@
''' Django: Models for AI Prompts. '''
from .PromptTemplate import PromptTemplate

View File

@ -0,0 +1,2 @@
''' Serializers for persistent data manipulation (AI Prompts). '''
from .data_access import PromptTemplateSerializer

View File

@ -0,0 +1,39 @@
''' Serializers for prompt template data access. '''
from rest_framework import serializers
from shared import messages as msg
from ..models import PromptTemplate
class PromptTemplateSerializer(serializers.ModelSerializer):
'''Serializer for PromptTemplate, enforcing permissions and ownership logic.'''
class Meta:
''' serializer metadata. '''
model = PromptTemplate
fields = ['id', 'owner', 'is_shared', 'label', 'description', 'text']
read_only_fields = ['id', 'owner']
def validate_label(self, value):
user = self.context['request'].user
if PromptTemplate.objects.filter(owner=user, label=value).exists():
raise serializers.ValidationError(msg.promptLabelTaken(value))
return value
def validate_is_shared(self, value):
user = self.context['request'].user
if value and not (user.is_superuser or user.is_staff):
raise serializers.ValidationError(msg.promptSharedPermissionDenied())
return value
def create(self, validated_data):
validated_data['owner'] = self.context['request'].user
return super().create(validated_data)
def update(self, instance, validated_data):
user = self.context['request'].user
if 'is_shared' in validated_data:
if validated_data['is_shared'] and not (user.is_superuser or user.is_staff):
raise serializers.ValidationError(msg.promptSharedPermissionDenied())
return super().update(instance, validated_data)

View File

@ -0,0 +1,2 @@
''' Tests. '''
from .t_prompts import *

View File

@ -0,0 +1,113 @@
''' Testing API: Prompts. '''
from rest_framework import status
from shared.EndpointTester import EndpointTester, decl_endpoint
from ..models import PromptTemplate
class TestPromptTemplateViewSet(EndpointTester):
''' Testing PromptTemplate viewset. '''
def setUp(self):
super().setUp()
self.admin = self.user2
self.admin.is_superuser = True
self.admin.save()
@decl_endpoint('/api/prompts/', method='post')
def test_create_prompt(self):
data = {
'label': 'Test',
'description': 'desc',
'text': 'prompt text',
'is_shared': False
}
response = self.executeCreated(data=data)
self.assertEqual(response.data['label'], 'Test')
self.assertEqual(response.data['owner'], self.user.pk)
@decl_endpoint('/api/prompts/', method='post')
def test_create_shared_prompt_by_admin(self):
self.client.force_authenticate(user=self.admin)
data = {
'label': 'Shared',
'description': 'desc',
'text': 'prompt text',
'is_shared': True
}
response = self.executeCreated(data=data)
self.assertTrue(response.data['is_shared'])
@decl_endpoint('/api/prompts/', method='post')
def test_create_shared_prompt_by_user_forbidden(self):
data = {
'label': 'Shared',
'description': 'desc',
'text': 'prompt text',
'is_shared': True
}
response = self.executeBadData(data=data)
self.assertIn('is_shared', response.data)
@decl_endpoint('/api/prompts/{item}/', method='patch')
def test_update_prompt_owner(self):
prompt = PromptTemplate.objects.create(owner=self.user, label='ToUpdate', description='', text='t')
response = self.executeOK(data={'label': 'Updated'}, item=prompt.id)
self.assertEqual(response.data['label'], 'Updated')
@decl_endpoint('/api/prompts/{item}/', method='patch')
def test_update_prompt_not_owner_forbidden(self):
prompt = PromptTemplate.objects.create(owner=self.admin, label='Other', description='', text='t')
response = self.executeForbidden(data={'label': 'Updated'}, item=prompt.id)
@decl_endpoint('/api/prompts/{item}/', method='delete')
def test_delete_prompt_owner(self):
prompt = PromptTemplate.objects.create(owner=self.user, label='ToDelete', description='', text='t')
self.executeNoContent(item=prompt.id)
@decl_endpoint('/api/prompts/{item}/', method='delete')
def test_delete_prompt_not_owner_forbidden(self):
prompt = PromptTemplate.objects.create(owner=self.admin, label='Other2', description='', text='t')
self.executeForbidden(item=prompt.id)
@decl_endpoint('/api/prompts/available/', method='get')
def test_available_endpoint(self):
PromptTemplate.objects.create(
owner=self.user,
label='Mine',
description='',
text='t'
)
PromptTemplate.objects.create(
owner=self.admin,
label='Shared',
description='',
text='t',
is_shared=True
)
response = self.executeOK()
labels = [item['label'] for item in response.data]
self.assertIn('Mine', labels)
self.assertIn('Shared', labels)
@decl_endpoint('/api/prompts/{item}/', method='patch')
def test_permissions_on_shared(self):
prompt = PromptTemplate.objects.create(
owner=self.admin,
label='Shared',
description='',
text='t',
is_shared=True
)
self.client.force_authenticate(user=self.user)
response = self.executeForbidden(data={'label': 'Nope'}, item=prompt.id)

View File

@ -0,0 +1,9 @@
''' Routing: Prompts for AI helper. '''
from rest_framework.routers import DefaultRouter
from .views import PromptTemplateViewSet
router = DefaultRouter()
router.register('prompts', PromptTemplateViewSet, 'prompt-template')
urlpatterns = router.urls

View File

@ -0,0 +1,2 @@
''' REST API: Endpoint processors for AI Prompts. '''
from .prompts import PromptTemplateViewSet

View File

@ -0,0 +1,52 @@
''' Views: PromptTemplate endpoints for AI prompt management. '''
from django.db import models
from drf_spectacular.utils import extend_schema
from rest_framework import permissions, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from ..models import PromptTemplate
from ..serializers import PromptTemplateSerializer
class IsOwnerOrAdmin(permissions.BasePermission):
'''Permission: Only owner or admin can modify, anyone can view shared.'''
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return obj.is_shared or obj.owner == request.user
return obj.owner == request.user or request.user.is_staff or request.user.is_superuser
@extend_schema(tags=['Prompts'])
class PromptTemplateViewSet(viewsets.ModelViewSet):
'''ViewSet: CRUD and listing for PromptTemplate, with sharing logic.'''
queryset = PromptTemplate.objects.all()
serializer_class = PromptTemplateSerializer
permission_classes = [permissions.IsAuthenticated, IsOwnerOrAdmin]
def get_queryset(self):
user = self.request.user
if self.action == 'available':
return PromptTemplate.objects.none()
return PromptTemplate.objects.filter(models.Q(owner=user) | models.Q(is_shared=True)).distinct()
def get_object(self):
obj = PromptTemplate.objects.get(pk=self.kwargs['pk'])
self.check_object_permissions(self.request, obj)
return obj
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
@extend_schema(summary='List user-owned and shared prompt templates')
@action(detail=False, methods=['get'], url_path='available')
def available(self, request):
'''Return user-owned and shared prompt templates.'''
user = request.user
owned = PromptTemplate.objects.filter(owner=user)
shared = PromptTemplate.objects.filter(is_shared=True)
templates = (owned | shared).distinct()
serializer = self.get_serializer(templates, many=True)
return Response(serializer.data)

View File

@ -4,11 +4,9 @@ from django.contrib import admin
from . import models
@admin.register(models.Constituenta)
class ConstituentaAdmin(admin.ModelAdmin):
''' Admin model: Constituenta. '''
ordering = ['schema', 'order']
list_display = ['schema', 'order', 'alias', 'term_resolved', 'definition_resolved']
search_fields = ['term_resolved', 'definition_resolved']
admin.site.register(models.Constituenta, ConstituentaAdmin)

View File

@ -76,6 +76,7 @@ INSTALLED_APPS = [
'apps.library',
'apps.rsform',
'apps.oss',
'apps.prompt',
'drf_spectacular',
'drf_spectacular_sidecar',

View File

@ -11,6 +11,7 @@ urlpatterns = [
path('api/', include('apps.library.urls')),
path('api/', include('apps.rsform.urls')),
path('api/', include('apps.oss.urls')),
path('api/', include('apps.prompt.urls')),
path('users/', include('apps.users.urls')),
path('schema', SpectacularAPIView.as_view(), name='schema'),
path('redoc', SpectacularRedocView.as_view()),

View File

@ -144,3 +144,19 @@ def passwordsNotMatch():
def emailAlreadyTaken():
return 'Пользователь с данным email уже существует'
def promptLabelTaken(label: str):
return f'Шаблон с меткой "{label}" уже существует у пользователя.'
def promptNotOwner():
return 'Вы не являетесь владельцем этого шаблона.'
def promptSharedPermissionDenied():
return 'Только администратор может сделать шаблон общедоступным.'
def promptNotFound():
return 'Шаблон не найден.'

View File

@ -2,7 +2,10 @@
export interface IPromptTemplate {
id: number;
owner: number | null;
is_shared: boolean;
label: string;
description: string;
text: string;
}
// ========= SCHEMAS ========

View File

@ -13,12 +13,14 @@ const mockPrompts: IPromptTemplate[] = [
id: 1,
owner: null,
label: 'Greeting',
is_shared: true,
description: 'A simple greeting prompt.',
text: 'Hello, ${name}! How can I assist you today?'
},
{
id: 2,
owner: null,
is_shared: true,
label: 'Summary',
description: 'Summarize the following text.',
text: 'Please summarize the following: ${text}'

View File

@ -0,0 +1,13 @@
import { PromptVariableType } from './models/prompting';
const describePromptVariableRecord: Record<PromptVariableType, string> = {
[PromptVariableType.BLOCK]: 'Текущий блок операционной схемы',
[PromptVariableType.OSS]: 'Текущая операционная схема',
[PromptVariableType.SCHEMA]: 'Текущая концептуальный схема',
[PromptVariableType.CONSTITUENTA]: 'Текущая конституента'
};
/** Retrieves description for {@link PromptVariableType}. */
export function describePromptVariable(itemType: PromptVariableType): string {
return describePromptVariableRecord[itemType] ?? `UNKNOWN VARIABLE TYPE: ${itemType}`;
}

View File

@ -0,0 +1,39 @@
import { extractPromptVariables } from './prompting-api';
describe('extractPromptVariables', () => {
it('extracts a single variable', () => {
expect(extractPromptVariables('Hello {{name}}!')).toEqual(['name']);
});
it('extracts multiple variables', () => {
expect(extractPromptVariables('Hi {{firstName}}, your ID is {{user.id}}.')).toEqual(['firstName', 'user.id']);
});
it('extracts variables with hyphens and dots', () => {
expect(extractPromptVariables('Welcome {{user-name}} and {{user.name}}!')).toEqual(['user-name', 'user.name']);
});
it('returns empty array if no variables', () => {
expect(extractPromptVariables('No variables here!')).toEqual([]);
});
it('ignores invalid variable patterns', () => {
expect(extractPromptVariables('Hello {name}, {{name!}}, {{123}}, {{user_name}}')).toEqual([]);
});
it('extracts repeated variables', () => {
expect(extractPromptVariables('Repeat: {{foo}}, again: {{foo}}')).toEqual(['foo', 'foo']);
});
it('works with adjacent variables', () => {
expect(extractPromptVariables('{{a}}{{b}}{{c}}')).toEqual(['a', 'b', 'c']);
});
it('returns empty array for empty string', () => {
expect(extractPromptVariables('')).toEqual([]);
});
it('extracts variables at string boundaries', () => {
expect(extractPromptVariables('{{start}} middle {{end}}')).toEqual(['start', 'end']);
});
});

View File

@ -0,0 +1,12 @@
/** Extracts a list of variables (as string[]) from a target string.
* Note: Variables are wrapped in {{...}} and can include a-zA-Z, hyphen, and dot inside curly braces.
* */
export function extractPromptVariables(target: string): string[] {
const regex = /\{\{([a-zA-Z.-]+)\}\}/g;
const result: string[] = [];
let match: RegExpExecArray | null;
while ((match = regex.exec(target)) !== null) {
result.push(match[1]);
}
return result;
}

View File

@ -1,29 +1,29 @@
/** Represents prompt variable type. */
export const PromptVariableType = {
BLOCK: 'block',
BLOCK_TITLE: 'block.title',
BLOCK_DESCRIPTION: 'block.description',
BLOCK_CONTENTS: 'block.contents',
// BLOCK_TITLE: 'block.title',
// BLOCK_DESCRIPTION: 'block.description',
// BLOCK_CONTENTS: 'block.contents',
OSS: 'oss',
OSS_CONTENTS: 'oss.contents',
OSS_ALIAS: 'oss.alias',
OSS_TITLE: 'oss.title',
OSS_DESCRIPTION: 'oss.description',
// OSS_CONTENTS: 'oss.contents',
// OSS_ALIAS: 'oss.alias',
// OSS_TITLE: 'oss.title',
// OSS_DESCRIPTION: 'oss.description',
SCHEMA: 'schema',
SCHEMA_ALIAS: 'schema.alias',
SCHEMA_TITLE: 'schema.title',
SCHEMA_DESCRIPTION: 'schema.description',
SCHEMA_THESAURUS: 'schema.thesaurus',
SCHEMA_GRAPH: 'schema.graph',
SCHEMA_TYPE_GRAPH: 'schema.type-graph',
// SCHEMA_ALIAS: 'schema.alias',
// SCHEMA_TITLE: 'schema.title',
// SCHEMA_DESCRIPTION: 'schema.description',
// SCHEMA_THESAURUS: 'schema.thesaurus',
// SCHEMA_GRAPH: 'schema.graph',
// SCHEMA_TYPE_GRAPH: 'schema.type-graph',
CONSTITUENTA: 'constituent',
CONSTITUENTA_ALIAS: 'constituent.alias',
CONSTITUENTA_CONVENTION: 'constituent.convention',
CONSTITUENTA_DEFINITION: 'constituent.definition',
CONSTITUENTA_DEFINITION_FORMAL: 'constituent.definition-formal',
CONSTITUENTA_EXPRESSION_TREE: 'constituent.expression-tree'
CONSTITUENTA: 'constituenta'
// CONSTITUENTA_ALIAS: 'constituent.alias',
// CONSTITUENTA_CONVENTION: 'constituent.convention',
// CONSTITUENTA_DEFINITION: 'constituent.definition',
// CONSTITUENTA_DEFINITION_FORMAL: 'constituent.definition-formal',
// CONSTITUENTA_EXPRESSION_TREE: 'constituent.expression-tree'
} as const;
export type PromptVariableType = (typeof PromptVariableType)[keyof typeof PromptVariableType];

View File

@ -0,0 +1,64 @@
import { create } from 'zustand';
import { type IBlock, type IOperationSchema } from '@/features/oss/models/oss';
import { type IConstituenta, type IRSForm } from '@/features/rsform';
import { PromptVariableType } from '../models/prompting';
interface AIContextStore {
currentOSS: IOperationSchema | null;
setCurrentOSS: (value: IOperationSchema | null) => void;
currentSchema: IRSForm | null;
setCurrentSchema: (value: IRSForm | null) => void;
currentBlock: IBlock | null;
setCurrentBlock: (value: IBlock | null) => void;
currentConstituenta: IConstituenta | null;
setCurrentConstituenta: (value: IConstituenta | null) => void;
}
export const useAIStore = create<AIContextStore>()(set => ({
currentOSS: null,
setCurrentOSS: value => set({ currentOSS: value }),
currentSchema: null,
setCurrentSchema: value => set({ currentSchema: value }),
currentBlock: null,
setCurrentBlock: value => set({ currentBlock: value }),
currentConstituenta: null,
setCurrentConstituenta: value => set({ currentConstituenta: value })
}));
/** Returns a selector function for Zustand based on variable type */
export function makeVariableSelector(variableType: PromptVariableType) {
switch (variableType) {
case PromptVariableType.OSS:
return (state: AIContextStore) => ({ currentOSS: state.currentOSS });
case PromptVariableType.SCHEMA:
return (state: AIContextStore) => ({ currentSchema: state.currentSchema });
case PromptVariableType.BLOCK:
return (state: AIContextStore) => ({ currentBlock: state.currentBlock });
case PromptVariableType.CONSTITUENTA:
return (state: AIContextStore) => ({ currentConstituenta: state.currentConstituenta });
default:
return () => ({});
}
}
/** Evaluates a prompt variable */
export function evaluatePromptVariable(variableType: PromptVariableType, context: Partial<AIContextStore>): string {
switch (variableType) {
case PromptVariableType.OSS:
return context.currentOSS?.title ?? '';
case PromptVariableType.SCHEMA:
return context.currentSchema?.title ?? '';
case PromptVariableType.BLOCK:
return context.currentBlock?.title ?? '';
case PromptVariableType.CONSTITUENTA:
return context.currentConstituenta?.alias ?? '';
}
}

View File

@ -0,0 +1,17 @@
import { PromptVariableType } from '../models/prompting';
import { useAIStore } from './ai-context';
export function useAvailableVariables(): PromptVariableType[] {
const hasCurrentOSS = useAIStore(state => !!state.currentOSS);
const hasCurrentSchema = useAIStore(state => !!state.currentSchema);
const hasCurrentBlock = useAIStore(state => !!state.currentBlock);
const hasCurrentConstituenta = useAIStore(state => !!state.currentConstituenta);
return [
...(hasCurrentOSS ? [PromptVariableType.OSS] : []),
...(hasCurrentSchema ? [PromptVariableType.SCHEMA] : []),
...(hasCurrentBlock ? [PromptVariableType.BLOCK] : []),
...(hasCurrentConstituenta ? [PromptVariableType.CONSTITUENTA] : [])
];
}