feat(ai): upgrade to OpenAI Responses API and enhance template generation#183
feat(ai): upgrade to OpenAI Responses API and enhance template generation#183izak-fisher wants to merge 2 commits into
Conversation
…tion
- Migrate from legacy Chat Completions API (openai SDK v0.27) to Responses API via direct HTTP calls
- Add new model choices: gpt-4.1, gpt-4.1-mini, gpt-4.1-nano, gpt-5, gpt-5-mini, o3, o4-mini
- Change default model from gpt-4o to gpt-4.1-mini
- Enhance system prompt to generate templates with kickoff fields, output fields, and variable references
- Preserve AI-provided api_name on fields so {{field-name}} references resolve correctly
- Make OPENAI_API_ORG optional (only OPENAI_API_KEY required)
- Replace single-line input with multi-line textarea in template generator UI
- Add documentation: AI_GENERATION.md, SYSTEM_PROMPT.md, CHANGES.md, RUN_BRANCH.md
- Update tests for new Responses API integration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: JSON test response breaks legacy fallback
- Legacy
_get_responsefallback now returns a dedicated pipe-delimited_test_steps_response, so offline legacy parsing produces tasks instead of failing on JSON.
- Legacy
- ✅ Fixed: Prompt penalties ignored in Responses payload
- Both
_get_responseand_get_json_responsenow includepresence_penaltyandfrequency_penaltyin the Responses API payload from prompt settings (or sane defaults).
- Both
- ✅ Fixed: Only last prompt messages are used
- Prompt message handling now preserves ordered active messages by aggregating all system instructions and sending ordered user/assistant inputs instead of overwriting with the last entries.
Or push these changes by commenting:
@cursor push c54102a461
Preview (c54102a461)
diff --git a/backend/src/processes/services/templates/ai.py b/backend/src/processes/services/templates/ai.py
--- a/backend/src/processes/services/templates/ai.py
+++ b/backend/src/processes/services/templates/ai.py
@@ -200,26 +200,20 @@
) -> str:
if not settings.OPENAI_API_KEY:
- return self._test_response()
+ return self._test_steps_response()
- # Build instructions and input from prompt messages
- instructions = None
- user_input = user_description
- for elem in prompt.messages.order_by('order'):
- content = insert_fields_values_to_text(
- text=elem.content,
- fields_values={'user_description': user_description},
- )
- if elem.role == 'system':
- instructions = content
- elif elem.role == 'user':
- user_input = content
+ instructions, user_input = self._get_prompt_instructions_and_input(
+ prompt=prompt,
+ user_description=user_description,
+ )
payload = {
'model': prompt.model,
'input': user_input,
'temperature': prompt.temperature,
'top_p': prompt.top_p,
+ 'presence_penalty': prompt.presence_penalty,
+ 'frequency_penalty': prompt.frequency_penalty,
}
if instructions:
payload['instructions'] = instructions
@@ -244,19 +238,10 @@
return self._test_response()
if prompt and prompt.messages.active().exists():
- instructions = None
- user_input = user_description
- for elem in prompt.messages.order_by('order'):
- content = insert_fields_values_to_text(
- text=elem.content,
- fields_values={
- 'user_description': user_description,
- },
- )
- if elem.role == 'system':
- instructions = content
- elif elem.role == 'user':
- user_input = content
+ instructions, user_input = self._get_prompt_instructions_and_input(
+ prompt=prompt,
+ user_description=user_description,
+ )
else:
instructions = DEFAULT_TEMPLATE_INSTRUCTION
user_input = user_description
@@ -268,6 +253,12 @@
'input': user_input,
'temperature': prompt.temperature if prompt else 0.7,
'top_p': prompt.top_p if prompt else 1,
+ 'presence_penalty': (
+ prompt.presence_penalty if prompt else 0
+ ),
+ 'frequency_penalty': (
+ prompt.frequency_penalty if prompt else 0
+ ),
}
if instructions:
payload['instructions'] = instructions
@@ -283,6 +274,44 @@
)
raise
+ def _get_prompt_instructions_and_input(
+ self,
+ prompt: OpenAiPrompt,
+ user_description: str,
+ ):
+ instructions_parts = []
+ input_messages = []
+ for elem in prompt.messages.active().order_by('order'):
+ content = insert_fields_values_to_text(
+ text=elem.content,
+ fields_values={'user_description': user_description},
+ )
+ if elem.role == 'system':
+ instructions_parts.append(content)
+ elif elem.role in ('user', 'assistant'):
+ input_messages.append({
+ 'role': elem.role,
+ 'content': content,
+ })
+ instructions = (
+ '\n\n'.join(instructions_parts)
+ if instructions_parts
+ else None
+ )
+ if not input_messages:
+ return instructions, user_description
+ if len(input_messages) == 1 and input_messages[0]['role'] == 'user':
+ return instructions, input_messages[0]['content']
+ return instructions, input_messages
+
+ def _test_steps_response(self):
+ return '\n'.join((
+ 'Inspect hive | Inspect the beehive to determine readiness for honey collection.',
+ 'Smoke the bees | Use a smoker to calm the bees before working with the frames.',
+ 'Extract honey | Extract honey from the hive frames and collect it for processing.',
+ 'Bottle and label | Bottle the honey and label each jar for storage and sale.',
+ ))
+
def _test_response(self):
return json.dumps({
'name': 'Honey Harvesting',
diff --git a/backend/src/processes/tests/test_services/test_templates/test_ai/test_anon_open_ai_service.py b/backend/src/processes/tests/test_services/test_templates/test_ai/test_anon_open_ai_service.py
--- a/backend/src/processes/tests/test_services/test_templates/test_ai/test_anon_open_ai_service.py
+++ b/backend/src/processes/tests/test_services/test_templates/test_ai/test_anon_open_ai_service.py
@@ -107,6 +107,34 @@
assert task_2_predicate['value'] is None
+def test_get_short_template_data__legacy_path_without_api_key__ok(mocker):
+
+ # arrange
+ description = 'My lovely business process'
+ create_test_prompt()
+ mocker.patch(
+ 'src.processes.services.templates.ai.settings.OPENAI_API_KEY',
+ None,
+ )
+ ip = '168.01.01.8'
+ user_agent = 'Some browser'
+
+ service = AnonOpenAiService(
+ ident=ip,
+ user_agent=user_agent,
+ )
+
+ # act
+ template_data = service.get_short_template_data(
+ user_description=description,
+ )
+
+ # assert
+ assert template_data['name'] == description
+ assert len(template_data['tasks']) == 4
+ assert template_data['tasks'][0]['name'] == 'Inspect hive'
+
+
# === JSON path with GET_TEMPLATE prompt ===
diff --git a/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py b/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py
--- a/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py
+++ b/backend/src/processes/tests/test_services/test_templates/test_ai/test_open_ai_service.py
@@ -66,7 +66,7 @@
test_response = mocker.Mock()
test_response_mock = mocker.patch(
'src.processes.services.templates.'
- 'ai.OpenAiService._test_response',
+ 'ai.OpenAiService._test_steps_response',
return_value=test_response,
)
@@ -121,8 +121,64 @@
# assert
assert response == ai_response
call_api_mock.assert_called_once()
+ payload = call_api_mock.call_args[0][0]
+ assert payload['presence_penalty'] == prompt.presence_penalty
+ assert payload['frequency_penalty'] == prompt.frequency_penalty
+def test_get_response__multiple_messages__uses_ordered_input(mocker):
+
+ # arrange
+ description = 'some description'
+ prompt = create_test_prompt(messages_count=4)
+ message_1 = prompt.messages.filter(order=1).first()
+ message_1.role = OpenAIRole.SYSTEM
+ message_1.content = 'System 1'
+ message_1.save()
+ message_2 = prompt.messages.filter(order=2).first()
+ message_2.role = OpenAIRole.USER
+ message_2.content = 'User 1 {{ user_description }}'
+ message_2.save()
+ message_3 = prompt.messages.filter(order=3).first()
+ message_3.role = OpenAIRole.ASSISTANT
+ message_3.content = 'Assistant example'
+ message_3.save()
+ message_4 = prompt.messages.filter(order=4).first()
+ message_4.role = OpenAIRole.SYSTEM
+ message_4.content = 'System 2'
+ message_4.save()
+
+ mocker.patch(
+ 'src.processes.services.templates.ai.settings.OPENAI_API_KEY',
+ 'some_key',
+ )
+ user = create_test_user()
+ service = OpenAiService(
+ ident=user.id,
+ user=user,
+ auth_type=AuthTokenType.USER,
+ )
+ call_api_mock = mocker.patch(
+ 'src.processes.services.templates.'
+ 'ai.BaseAiService._call_responses_api',
+ return_value='ok',
+ )
+
+ # act
+ service._get_response(
+ user_description=description,
+ prompt=prompt,
+ )
+
+ # assert
+ payload = call_api_mock.call_args[0][0]
+ assert payload['instructions'] == 'System 1\n\nSystem 2'
+ assert payload['input'] == [
+ {'role': OpenAIRole.USER, 'content': 'User 1 some description'},
+ {'role': OpenAIRole.ASSISTANT, 'content': 'Assistant example'},
+ ]
+
+
def test_get_response__api_error__raise_exception(mocker):
# arrange
@@ -235,8 +291,64 @@
assert payload['model'] == 'gpt-4.1-mini'
assert 'instructions' in payload
assert payload['input'] == description
+ assert payload['presence_penalty'] == 0
+ assert payload['frequency_penalty'] == 0
+def test_get_json_response__prompt_messages__uses_ordered_input(mocker):
+
+ # arrange
+ description = 'some description'
+ prompt = create_test_prompt(messages_count=3)
+ prompt.presence_penalty = 0.7
+ prompt.frequency_penalty = -0.4
+ prompt.save()
+ message_1 = prompt.messages.filter(order=1).first()
+ message_1.role = OpenAIRole.SYSTEM
+ message_1.content = 'System message'
+ message_1.save()
+ message_2 = prompt.messages.filter(order=2).first()
+ message_2.role = OpenAIRole.ASSISTANT
+ message_2.content = 'Assistant example'
+ message_2.save()
+ message_3 = prompt.messages.filter(order=3).first()
+ message_3.role = OpenAIRole.USER
+ message_3.content = 'User asks: {{ user_description }}'
+ message_3.save()
+
+ mocker.patch(
+ 'src.processes.services.templates.ai.settings.OPENAI_API_KEY',
+ 'some_key',
+ )
+ user = create_test_user()
+ service = OpenAiService(
+ ident=user.id,
+ user=user,
+ auth_type=AuthTokenType.USER,
+ )
+ call_api_mock = mocker.patch(
+ 'src.processes.services.templates.'
+ 'ai.BaseAiService._call_responses_api',
+ return_value='{"name":"T","tasks":[]}',
+ )
+
+ # act
+ service._get_json_response(
+ user_description=description,
+ prompt=prompt,
+ )
+
+ # assert
+ payload = call_api_mock.call_args[0][0]
+ assert payload['instructions'] == 'System message'
+ assert payload['input'] == [
+ {'role': OpenAIRole.ASSISTANT, 'content': 'Assistant example'},
+ {'role': OpenAIRole.USER, 'content': 'User asks: some description'},
+ ]
+ assert payload['presence_penalty'] == prompt.presence_penalty
+ assert payload['frequency_penalty'] == prompt.frequency_penalty
+
+
def test_get_json_response__api_error__raise_exception(mocker):
# arrangeThis Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
| if elem.role == 'system': | ||
| instructions = content | ||
| elif elem.role == 'user': | ||
| user_input = content |
There was a problem hiding this comment.
Only last prompt messages are used
Medium Severity
Message iteration overwrites instructions and user_input, so only the last system and last user entries are sent. Earlier messages and all assistant examples are dropped, breaking multi-message prompt configurations.
Additional Locations (1)
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): |
There was a problem hiding this comment.
Union 0004 and 0005 migrations
| @@ -0,0 +1,99 @@ | |||
| # Backend — Django REST API | |||
|
|
|||
There was a problem hiding this comment.
Urelated changes, remove backend/CLAUDE.md
| def test_get_short_template_data__ok(mocker): | ||
| # === Legacy text path === | ||
|
|
||
|
|
|
|
||
| UserModel = get_user_model() | ||
|
|
||
| DEFAULT_TEMPLATE_INSTRUCTION = """You are a workflow template designer. |
There was a problem hiding this comment.
Why is promt hardcoded?
| FieldType.NUMBER, | ||
| } | ||
|
|
||
| SELECTION_FIELD_TYPES = FieldType.TYPES_WITH_SELECTIONS |
|
|
||
| Given a user description of a business process, generate a workflow template JSON as per the structure above.""" | ||
|
|
||
| VALID_FIELD_TYPES = { |
There was a problem hiding this comment.
Really need? Just use FieldType
| EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} | ||
| AI: ${AI:-no} | ||
| AI_PROVIDER: ${AI_PROVIDER:-} | ||
| OPENAI_API_KEY: ${OPENAI_API_KEY:-} |
There was a problem hiding this comment.
Add new variables to the each docker-compose file in the repository
| ## How it works | ||
|
|
||
| 1. A user enters a business process description in the template editor | ||
| 2. The backend sends the description to the OpenAI Responses API along with a system prompt (instructions) |
There was a problem hiding this comment.
Remove all trash files from the root directory
- Remove doc files from git tracking, add to .gitignore - Merge migrations 0004 and 0005 into single migration - Add default field to _normalize_field - Re-add presence_penalty and frequency_penalty to API payloads - Add dict validation in _parse_template_from_json - Fix _get_response to raise when no API key (legacy text path) - Replace VALID_FIELD_TYPES with FieldType.CHOICES - Add OPENAI vars to all docker-compose files - Fix linter violations - Add 87 unit tests covering BaseAiService, OpenAiService, AnonOpenAiService Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| if elem.role == 'system': | ||
| instructions = content | ||
| elif elem.role == 'user': | ||
| user_input = content |
There was a problem hiding this comment.
Inactive prompt messages still affect API calls
High Severity
Message iteration uses prompt.messages.order_by('order') instead of prompt.messages.active(). Deactivated prompt messages are still included and can override instructions/input, so admin disabling a message has no effect.
Additional Locations (1)
|
|
||
| task_fields = [] | ||
| for field_data in raw_task.get('fields', []): | ||
| task_fields.append(self._normalize_field(field_data)) |
There was a problem hiding this comment.
JSON parser crashes on malformed response shapes
Medium Severity
_parse_template_from_json assumes kickoff, tasks, and each task are dict/list shapes and calls .get() unguarded. Non-conforming AI JSON raises AttributeError, which is not caught by caller error handling, causing an unhandled server error instead of OpenAiTemplateStepsNotExist.
| def _normalize_field(field_data: dict) -> dict: | ||
| field_type = field_data.get('type', FieldType.STRING) | ||
| if field_type not in VALID_FIELD_TYPES: | ||
| field_type = FieldType.STRING | ||
| normalized = { | ||
| 'order': field_data.get('order', 1), | ||
| 'name': field_data.get('name', '')[:50], | ||
| 'type': field_type, | ||
| 'is_required': bool(field_data.get('is_required', False)), | ||
| 'description': field_data.get('description', ''), | ||
| 'default': field_data.get('default', ''), | ||
| 'api_name': field_data.get('api_name') or create_api_name('field'), |
There was a problem hiding this comment.
🟢 Low templates/ai.py:451
When the AI returns {"name": null}, field_data.get('name', '') returns None instead of the default empty string, and None[:50] raises a TypeError. The same bug exists for description and default.
@staticmethod
def _normalize_field(field_data: dict) -> dict:
field_type = field_data.get('type', FieldType.STRING)
if field_type not in VALID_FIELD_TYPES:
field_type = FieldType.STRING
normalized = {
'order': field_data.get('order', 1),
- 'name': field_data.get('name', '')[:50],
+ 'name': (field_data.get('name') or '')[:50],
'type': field_type,
'is_required': bool(field_data.get('is_required', False)),
- 'description': field_data.get('description', ''),
- 'default': field_data.get('default', ''),
+ 'description': field_data.get('description') or '',
+ 'default': field_data.get('default') or '',
'api_name': field_data.get('api_name') or create_api_name('field'),
}Also found in 1 other location(s)
backend/src/processes/tests/test_services/test_templates/test_ai/test_anon_open_ai_service.py:638
The test will fail because the mock JSON response doesn't include
api_namein the tasks, but the production codeget_short_template_datadoestask['api_name']directly (not.get()), which will raiseKeyError. The test expectstask_1['api_name']to be truthy (line 687), but the service will raise an exception before returning any data.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file backend/src/processes/services/templates/ai.py around lines 451-462:
When the AI returns `{"name": null}`, `field_data.get('name', '')` returns `None` instead of the default empty string, and `None[:50]` raises a `TypeError`. The same bug exists for `description` and `default`.
Evidence trail:
backend/src/processes/services/templates/ai.py lines 451-462 (REVIEWED_COMMIT) - `_normalize_field` method showing `field_data.get('name', '')[:50]` at line 457, `description` at line 459, `default` at line 460. Lines 485-492 show `field_data` comes directly from parsed AI JSON with no null value validation.
Also found in 1 other location(s):
- backend/src/processes/tests/test_services/test_templates/test_ai/test_anon_open_ai_service.py:638 -- The test will fail because the mock JSON response doesn't include `api_name` in the tasks, but the production code `get_short_template_data` does `task['api_name']` directly (not `.get()`), which will raise `KeyError`. The test expects `task_1['api_name']` to be truthy (line 687), but the service will raise an exception before returning any data.



Problem
Users could only enter a single line of text when describing their business process for AI template generation, making it hard to provide enough detail for complex workflows. The generated templates lacked structured forms — they produced only task names and descriptions, without input fields, output fields, or the ability to pass data between workflow steps. The system was also limited to older AI models with no way for administrators to choose newer, more capable ones.
Fix / Solution
Release notes
AI template generation now produces richer workflow templates with kickoff forms, task output fields, and data flow between steps. Users can enter multi-line descriptions, and administrators can choose from the latest AI models. The system uses OpenAI's Responses API for improved performance and reliability.
Changes
API
OpenAiModel: added gpt-4.1, gpt-4.1-mini, gpt-4.1-nano, gpt-5, gpt-5-mini, o3, o4-miniOpenAIPromptTarget: added GET_TEMPLATE target for structured JSON generationOpenAiPromptQueryset: addedtarget_template()filter method0004: updated model choices and target fieldBaseAiService: new methods for Responses API calls, JSON parsing, field normalizationOpenAiService._get_template_data: supports JSON template path (primary) and legacy text path (fallback)AnonOpenAiService.get_short_template_data: supports JSON template path with minimal outputWeb-client
Test cases
API
OpenAI API integration
Template generation (authenticated user)
Template generation (anonymous user)
Field normalization
JSON extraction and parsing
Logging and error tracking
Web-client