diff --git a/.gitignore b/.gitignore index 9055f035f..b7680973f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,8 @@ firebase-push.json .cursor/ .agent/ .antigravity/ - +CLAUDE.md +AI_GENERATION.md +CHANGES.md +RUN_BRANCH.md +SYSTEM_PROMPT.md diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 42977be22..2d276a638 100755 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -118,6 +118,8 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} STORAGE: ${STORAGE:-no} @@ -214,6 +216,8 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} STORAGE: ${STORAGE:-no} diff --git a/backend/src/ai/enums.py b/backend/src/ai/enums.py index fb140a7b2..a16408ddc 100644 --- a/backend/src/ai/enums.py +++ b/backend/src/ai/enums.py @@ -6,16 +6,43 @@ class OpenAiModel: GPT_35_turbo = 'gpt-3.5-turbo' GPT_4_turbo_preview = 'gpt-4-1106-preview' GPT_4 = 'gpt-4' + GPT_4o = 'gpt-4o' + GPT_4o_mini = 'gpt-4o-mini' + GPT_41 = 'gpt-4.1' + GPT_41_mini = 'gpt-4.1-mini' + GPT_41_nano = 'gpt-4.1-nano' + GPT_5 = 'gpt-5' + GPT_5_mini = 'gpt-5-mini' + O3 = 'o3' + O4_mini = 'o4-mini' CHOICES = ( + (GPT_41_mini, GPT_41_mini + ' (recommended)'), + (GPT_41, GPT_41), + (GPT_41_nano, GPT_41_nano), + (GPT_5, GPT_5), + (GPT_5_mini, GPT_5_mini), + (O3, O3), + (O4_mini, O4_mini), + (GPT_4o, GPT_4o), + (GPT_4o_mini, GPT_4o_mini), + (GPT_4, GPT_4), (GPT_35_turbo, GPT_35_turbo), (GPT_4_turbo_preview, 'gpt-4-turbo-preview'), - (GPT_4, GPT_4), ) LITERALS = Literal[ GPT_35_turbo, GPT_4_turbo_preview, GPT_4, + GPT_4o, + GPT_4o_mini, + GPT_41, + GPT_41_mini, + GPT_41_nano, + GPT_5, + GPT_5_mini, + O3, + O4_mini, ] @@ -35,7 +62,9 @@ class OpenAIRole: class OpenAIPromptTarget: GET_STEPS = 'get_steps' + GET_TEMPLATE = 'get_template' CHOICES = ( (GET_STEPS, 'Get template steps'), + (GET_TEMPLATE, 'Get full template (JSON)'), ) diff --git a/backend/src/ai/migrations/0004_add_model_choices_and_template_target.py b/backend/src/ai/migrations/0004_add_model_choices_and_template_target.py new file mode 100644 index 000000000..e905bd256 --- /dev/null +++ b/backend/src/ai/migrations/0004_add_model_choices_and_template_target.py @@ -0,0 +1,47 @@ +# Generated by Django 2.2 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai', '0003_auto_20231123_1812'), + ] + + operations = [ + migrations.AlterField( + model_name='openaiprompt', + name='model', + field=models.CharField( + choices=[ + ('gpt-4.1-mini', 'gpt-4.1-mini (recommended)'), + ('gpt-4.1', 'gpt-4.1'), + ('gpt-4.1-nano', 'gpt-4.1-nano'), + ('gpt-5', 'gpt-5'), + ('gpt-5-mini', 'gpt-5-mini'), + ('o3', 'o3'), + ('o4-mini', 'o4-mini'), + ('gpt-4o', 'gpt-4o'), + ('gpt-4o-mini', 'gpt-4o-mini'), + ('gpt-4', 'gpt-4'), + ('gpt-3.5-turbo', 'gpt-3.5-turbo'), + ('gpt-4-1106-preview', 'gpt-4-turbo-preview'), + ], + default='gpt-3.5-turbo', + max_length=200, + ), + ), + migrations.AlterField( + model_name='openaiprompt', + name='target', + field=models.CharField( + choices=[ + ('get_steps', 'Get template steps'), + ('get_template', 'Get full template (JSON)'), + ], + default='get_steps', + max_length=200, + ), + ), + ] diff --git a/backend/src/ai/querysets.py b/backend/src/ai/querysets.py index dd9e1895f..b683300cb 100644 --- a/backend/src/ai/querysets.py +++ b/backend/src/ai/querysets.py @@ -11,6 +11,9 @@ def active(self): def target_steps(self): return self.filter(target=OpenAIPromptTarget.GET_STEPS) + def target_template(self): + return self.filter(target=OpenAIPromptTarget.GET_TEMPLATE) + def by_target(self, target: str): return self.filter(target=target) diff --git a/backend/src/processes/services/templates/ai.py b/backend/src/processes/services/templates/ai.py index 7f4480789..651e099a3 100644 --- a/backend/src/processes/services/templates/ai.py +++ b/backend/src/processes/services/templates/ai.py @@ -1,7 +1,10 @@ +# ruff: noqa: E501 +import json +import re from abc import abstractmethod from typing import Optional, Union -import openai +import requests as http_requests from django.conf import settings from django.contrib.auth import get_user_model @@ -14,6 +17,7 @@ from src.processes.consts import TEMPLATE_NAME_LENGTH from src.processes.enums import ( ConditionAction, + FieldType, PerformerType, PredicateOperator, PredicateType, @@ -41,6 +45,81 @@ UserModel = get_user_model() +# Fallback system prompt used when no active OpenAiPrompt +# with target GET_TEMPLATE is configured in Django Admin. +DEFAULT_TEMPLATE_INSTRUCTION = """You are a workflow template designer. +Your job is to design workflow templates based on user descriptions of their business processes. +Each template consists of a kickoff form and tasks. +You must return ONLY valid JSON (no markdown, no code fences, no explanation) matching this structure: + +{ + "name": str, + "description": str, + "tasks": [ + { + "number": int, + "name": str, + "description": str, + "fields": [ + { + "api_name": str, + "order": int, + "name": str, + "type": str, + "is_required": bool, + "description": str, + "default": str, + "selections": [ + { + "value": str + } + ] + } + ] + } + ], + "kickoff": { + "fields": [ + { + "api_name": str, + "order": int, + "name": str, + "type": str, + "is_required": bool, + "description": str, + "default": str, + "selections": [ + { + "value": str + } + ] + } + ] + } +} + +Field types: "string", "text", "radio", "checkbox", "date", "url", "dropdown", "file", "user", "number". +The "selections" array must ONLY be included for "radio", "checkbox", and "dropdown" field types. +For all other field types, omit "selections" entirely. + +Every field MUST have an "api_name". Use kebab-case prefixed with "field-", derived from the field name, e.g., "field-project-name", "field-priority-level". +The api_name must be unique across all fields in the template. + +Task descriptions can reference values of kickoff fields or previous task output fields using the syntax {{api_name}}. +For example, if a kickoff field has "api_name": "field-project-name", a task description can include {{field-project-name}} to reference its value. +Include such variable references in task descriptions where it is relevant and useful. + +Design kickoff fields to capture the information needed to start the workflow. +Design task output fields to capture information produced during each task. +Include fields and kickoff fields where they add value to the process. +Each task should have a clear name and a description explaining what needs to be done. +If the user description implies conditional logic (e.g., "if X then skip Y"), note it in the task descriptions. + +Given a user description of a business process, generate a workflow template JSON as per the structure above.""" + +VALID_FIELD_TYPES = {c[0] for c in FieldType.CHOICES} +SELECTION_FIELD_TYPES = FieldType.TYPES_WITH_SELECTIONS + class BaseAiService: @@ -50,12 +129,14 @@ def __init__( ): self.ident = ident + OPENAI_RESPONSES_URL = 'https://api.openai.com/v1/responses' + @abstractmethod def _log_exception( self, user_description: str, prompt: OpenAiPrompt, - ex: openai.error.OpenAIError, + ex: Exception, level: SentryLogLevel.LITERALS = SentryLogLevel.ERROR, ): pass @@ -71,94 +152,227 @@ def _log( ): pass + def _openai_headers(self): + headers = { + 'Authorization': f'Bearer {settings.OPENAI_API_KEY}', + 'Content-Type': 'application/json', + } + if settings.OPENAI_API_ORG: + headers['OpenAI-Organization'] = settings.OPENAI_API_ORG + return headers + + def _call_responses_api(self, payload): + """Call OpenAI Responses API and return the output text.""" + try: + resp = http_requests.post( + self.OPENAI_RESPONSES_URL, + headers=self._openai_headers(), + json=payload, + timeout=120, + ) + resp.raise_for_status() + except http_requests.RequestException as ex: + raise OpenAiServiceUnavailable(str(ex)) from ex + + data = resp.json() + # Extract text from the response output + for item in data.get('output', []): + if item.get('type') == 'message': + for content in item.get('content', []): + if content.get('type') == 'output_text': + return content.get('text', '') + raise OpenAiServiceFailed + def _get_response( self, prompt: OpenAiPrompt, user_description: str, ) -> str: - if not(settings.OPENAI_API_KEY and settings.OPENAI_API_ORG): - return self._test_response() - openai.api_key = settings.OPENAI_API_KEY - openai.organization = settings.OPENAI_API_ORG - messages = [] + if not settings.OPENAI_API_KEY: + raise OpenAiServiceUnavailable + + # Build instructions and input from prompt messages + instructions = None + user_input = user_description for elem in prompt.messages.order_by('order'): - messages.append( - { - "role": elem.role, - "content": insert_fields_values_to_text( - text=elem.content, - fields_values={'user_description': user_description}, - ), - }, + content = insert_fields_values_to_text( + text=elem.content, + fields_values={'user_description': user_description}, ) - try: - completion = openai.ChatCompletion.create( - user=f'u{self.ident}', - model=prompt.model, - temperature=prompt.temperature, - top_p=prompt.top_p, - presence_penalty=prompt.presence_penalty, - frequency_penalty=prompt.frequency_penalty, - messages=messages, + if elem.role == 'system': + instructions = content + elif elem.role == 'user': + user_input = content + + payload = { + 'model': prompt.model, + 'input': user_input, + 'temperature': prompt.temperature, + 'top_p': prompt.top_p, + } + if instructions: + payload['instructions'] = instructions + if prompt.presence_penalty: + payload['presence_penalty'] = prompt.presence_penalty + if prompt.frequency_penalty: + payload['frequency_penalty'] = ( + prompt.frequency_penalty ) - except openai.error.OpenAIError as ex: + + try: + return self._call_responses_api(payload) + except (OpenAiServiceUnavailable, OpenAiServiceFailed) as ex: self._log_exception( ex=ex, prompt=prompt, user_description=user_description, ) - raise OpenAiServiceUnavailable from ex - else: - if not completion.choices: - self._log( - user_description=user_description, - message='Response has no choices', - prompt=prompt, + raise + + def _get_json_response( + self, + user_description: str, + prompt: Optional[OpenAiPrompt] = None, + ) -> str: + + if not settings.OPENAI_API_KEY: + 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, + }, ) - raise OpenAiServiceFailed - choice = completion.choices[0] - finish_reason = getattr(choice.message, 'finish_reason', None) - if finish_reason: - self._log( + if elem.role == 'system': + instructions = content + elif elem.role == 'user': + user_input = content + else: + instructions = DEFAULT_TEMPLATE_INSTRUCTION + user_input = user_description + + model = prompt.model if prompt else 'gpt-4.1-mini' + + payload = { + 'model': model, + 'input': user_input, + 'temperature': prompt.temperature if prompt else 0.7, + 'top_p': prompt.top_p if prompt else 1, + } + if instructions: + payload['instructions'] = instructions + if prompt and prompt.presence_penalty: + payload['presence_penalty'] = ( + prompt.presence_penalty + ) + if prompt and prompt.frequency_penalty: + payload['frequency_penalty'] = ( + prompt.frequency_penalty + ) + + try: + return self._call_responses_api(payload) + except (OpenAiServiceUnavailable, OpenAiServiceFailed) as ex: + if prompt: + self._log_exception( + ex=ex, prompt=prompt, user_description=user_description, - message='Response has finish reason', - response_text=finish_reason, ) - raise OpenAiServiceFailed - return choice.message.content + raise def _test_response(self): - return ( - '1. Prepare equipment and supplies | Gather the necessary ' - 'equipment and supplies for honey harvesting, including bee ' - 'suits, gloves, smoker, hive tool, brush, and honey jars.\n' - '2. Choose the right time | Choose a warm and sunny day when ' - 'most of the bees are out foraging and the honeycombs are ' - 'full of mature honey.\n' - '3. Smoke the bees | Light the smoker and gently puff smoke ' - 'into the hive entrance to calm the bees and make them less ' - 'aggressive.\n' - '4. Remove the supers | Remove the honey supers one by one ' - 'from the top of the hive, using the hive tool to scratch the ' - 'wax cappings from the honeycomb frames.\n' - '5. Brush off the bees | Gently brush off any remaining bees ' - 'from the frames using a soft brush.\n' - '6. Transport the supers | Transport the supers to a clean ' - 'and dry location for honey extraction.\n' - '7. Extract the honey | Uncap the honeycomb cells using an ' - 'uncapping knife, and then extract the honey using a centrifuge ' - 'or honey extractor.\n' - '8. Strain the honey | Strain the honey to remove any impurities, ' - 'such as wax or bee parts.\n' - '9. Bottle the honey | Pour the honey into clean and sterilized ' - 'jars and cap them tightly.\n' - '10. Label the honey | Label each jar with the date of harvesting,' - ' the type of honey, and the name and address of the producer.\n' - '11. Store the honey | Store the honey in a cool and dark place ' - 'until it is ready for sale or consumption.' - ) + return json.dumps({ + 'name': 'Honey Harvesting', + 'description': 'Process for harvesting honey from beehives.', + 'kickoff': { + 'fields': [ + { + 'api_name': 'field-hive-location', + 'order': 1, + 'name': 'Hive Location', + 'type': 'string', + 'is_required': True, + 'description': 'Location of the beehive', + }, + { + 'api_name': 'field-harvest-type', + 'order': 2, + 'name': 'Harvest Type', + 'type': 'radio', + 'is_required': True, + 'description': 'Type of harvest', + 'selections': [ + {'value': 'Full harvest'}, + {'value': 'Partial harvest'}, + ], + }, + ], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'Inspect hive', + 'description': ( + 'Inspect the beehive at {{field-hive-location}}' + ' to determine readiness for honey collection.' + ), + 'fields': [ + { + 'api_name': 'field-hive-condition', + 'order': 1, + 'name': 'Hive Condition', + 'type': 'dropdown', + 'is_required': True, + 'description': 'Current condition of the hive', + 'selections': [ + {'value': 'Excellent'}, + {'value': 'Good'}, + {'value': 'Needs attention'}, + ], + }, + ], + }, + { + 'number': 2, + 'name': 'Smoke the bees', + 'description': ( + 'Use a smoker to calm the bees and ' + 'make them less aggressive.' + ), + }, + { + 'number': 3, + 'name': 'Extract honey', + 'description': ( + 'Extract the honey from the hive frames.' + ), + 'fields': [ + { + 'api_name': 'field-quantity-extracted', + 'order': 1, + 'name': 'Quantity Extracted', + 'type': 'number', + 'is_required': True, + 'description': 'Amount of honey in kg', + }, + ], + }, + { + 'number': 4, + 'name': 'Bottle and label', + 'description': ( + 'Bottle the honey and label each jar.' + ), + }, + ], + }) def _get_start_task_condition(self, prev_task_api_name: str) -> dict: if prev_task_api_name is None: @@ -222,6 +436,102 @@ def _get_steps_data_from_text(self, text: str) -> list: tasks_data.append(task_data) return tasks_data + @staticmethod + def _extract_json(text: str) -> str: + text = text.strip() + match = re.search( + r'```(?:json)?\s*([\s\S]*?)```', + text, + ) + if match: + return match.group(1).strip() + return text + + @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], + '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'), + } + if field_type in SELECTION_FIELD_TYPES: + raw_selections = field_data.get('selections', []) + selections = [] + for sel in raw_selections: + value = sel.get('value', '') if isinstance(sel, dict) else '' + if value: + selections.append({ + 'value': value, + 'api_name': create_api_name('selection'), + }) + if selections: + normalized['selections'] = selections + return normalized + + def _parse_template_from_json(self, text: str) -> dict: + json_str = self._extract_json(text) + data = json.loads(json_str) + if not isinstance(data, dict): + raise TypeError( + f'Expected JSON object,' + f' got {type(data).__name__}', + ) + template_name = data.get('name', '')[:TEMPLATE_NAME_LENGTH] + description = data.get('description', '') + + kickoff_data = data.get('kickoff', {}) + kickoff_fields = [] + for field_data in kickoff_data.get('fields', []): + kickoff_fields.append(self._normalize_field(field_data)) + + tasks_data = [] + prev_task_api_name = None + raw_tasks = data.get('tasks', []) + + for idx, raw_task in enumerate(raw_tasks): + limit = TaskTemplate.NAME_MAX_LENGTH + task_name = raw_task.get('name', '')[:limit] + task_description = raw_task.get('description', '') + task_api_name = create_api_name('task') + + task_fields = [] + for field_data in raw_task.get('fields', []): + task_fields.append(self._normalize_field(field_data)) + + condition = self._get_start_task_condition( + prev_task_api_name, + ) + + task_data = { + 'number': idx + 1, + 'name': task_name, + 'api_name': task_api_name, + 'description': task_description, + 'conditions': [condition], + } + + if task_fields: + task_data['fields'] = task_fields + + tasks_data.append(task_data) + prev_task_api_name = task_api_name + + return { + 'name': template_name, + 'description': description, + 'kickoff': { + 'fields': kickoff_fields, + }, + 'tasks': tasks_data, + } + class OpenAiService(BaseAiService): @@ -242,7 +552,7 @@ def _log_exception( self, prompt: OpenAiPrompt, user_description: str, - ex: openai.error.OpenAIError, + ex: Exception, level: SentryLogLevel.LITERALS = SentryLogLevel.INFO, ): @@ -253,12 +563,7 @@ def _log_exception( 'user_email': self.user.email, 'account_id': self.account.id, 'ex': { - 'error': ex.error, - 'code': ex.code, - 'http_body': ex.http_body, - 'http_status': ex.http_status, - 'json_body': ex.json_body, - 'organization': ex.organization, + 'error': str(ex), }, 'request': { 'user_description': user_description, @@ -331,27 +636,72 @@ def _get_template_data(self, user_description: str) -> dict: if self.account.ai_template_generations_limit_exceeded: raise OpenAiLimitTemplateGenerations - prompt = OpenAiPrompt.objects.active().target_steps().first() - if not prompt or not prompt.messages.active().exists(): - raise OpenAiStepsPromptNotExist - response_text = self._get_response( - prompt=prompt, - user_description=user_description, + template_prompt = ( + OpenAiPrompt.objects.active().target_template().first() ) - initial_tasks_data = self._get_steps_data_from_text(response_text) - if not initial_tasks_data: - self._log( - prompt=prompt, + steps_prompt = ( + OpenAiPrompt.objects.active().target_steps().first() + ) + + if template_prompt or not ( + steps_prompt + and steps_prompt.messages.active().exists() + ): + response_text = self._get_json_response( user_description=user_description, - message='Template steps not found', - response_text=response_text, + prompt=template_prompt, ) - raise OpenAiTemplateStepsNotExist - initial_data = { - 'name': user_description[:TEMPLATE_NAME_LENGTH], - 'tasks': initial_tasks_data, - } + try: + initial_data = self._parse_template_from_json( + response_text, + ) + except (json.JSONDecodeError, KeyError, TypeError) as ex: + if template_prompt: + self._log( + prompt=template_prompt, + user_description=user_description, + message='Failed to parse JSON template response', + response_text=response_text, + ) + raise OpenAiTemplateStepsNotExist from ex + if not initial_data.get('tasks'): + if template_prompt: + self._log( + prompt=template_prompt, + user_description=user_description, + message='Template tasks not found', + response_text=response_text, + ) + raise OpenAiTemplateStepsNotExist + else: + if not steps_prompt or not ( + steps_prompt.messages.active().exists() + ): + raise OpenAiStepsPromptNotExist + response_text = self._get_response( + prompt=steps_prompt, + user_description=user_description, + ) + initial_tasks_data = self._get_steps_data_from_text( + response_text, + ) + if not initial_tasks_data: + self._log( + prompt=steps_prompt, + user_description=user_description, + message='Template steps not found', + response_text=response_text, + ) + raise OpenAiTemplateStepsNotExist + initial_data = { + 'name': user_description[:TEMPLATE_NAME_LENGTH], + 'tasks': initial_tasks_data, + } + + if not initial_data.get('name'): + initial_data['name'] = user_description[:TEMPLATE_NAME_LENGTH] + template_service = TemplateService( user=self.user, is_superuser=self.is_superuser, @@ -398,7 +748,7 @@ def _log_exception( self, prompt: OpenAiPrompt, user_description: str, - ex: openai.error.OpenAIError, + ex: Exception, level: SentryLogLevel.LITERALS = SentryLogLevel.INFO, ): @@ -408,12 +758,7 @@ def _log_exception( 'ident': self.ident, 'user-agent': self.user_agent, 'ex': { - 'error': ex.error, - 'code': ex.code, - 'http_body': ex.http_body, - 'http_status': ex.http_status, - 'json_body': ex.json_body, - 'organization': ex.organization, + 'error': str(ex), }, 'request': { 'user_description': user_description, @@ -472,18 +817,72 @@ def get_short_template_data( """ Generate minimal template data dict from description """ - prompt = OpenAiPrompt.objects.active().target_steps().first() - if not prompt or not prompt.messages.active().exists(): - raise OpenAiStepsPromptNotExist + template_prompt = ( + OpenAiPrompt.objects.active().target_template().first() + ) + steps_prompt = ( + OpenAiPrompt.objects.active().target_steps().first() + ) + if template_prompt or not ( + steps_prompt + and steps_prompt.messages.active().exists() + ): + response_text = self._get_json_response( + user_description=user_description, + prompt=template_prompt, + ) + try: + parsed = self._parse_template_from_json(response_text) + except (json.JSONDecodeError, KeyError, TypeError) as ex: + if template_prompt: + self._log( + prompt=template_prompt, + user_description=user_description, + message='Failed to parse JSON template response', + response_text=response_text, + ) + raise OpenAiTemplateStepsNotExist from ex + if not parsed.get('tasks'): + if template_prompt: + self._log( + prompt=template_prompt, + user_description=user_description, + message='Template tasks not found', + response_text=response_text, + ) + raise OpenAiTemplateStepsNotExist + tasks = [] + for task in parsed['tasks']: + tasks.append({ + 'number': task['number'], + 'name': task['name'], + 'api_name': task['api_name'], + 'description': task.get('description', ''), + 'conditions': task.get('conditions', []), + }) + return { + 'name': ( + parsed.get('name') + or user_description[:TEMPLATE_NAME_LENGTH] + ), + 'tasks': tasks, + } + + if not steps_prompt or not ( + steps_prompt.messages.active().exists() + ): + raise OpenAiStepsPromptNotExist response_text = self._get_response( - prompt=prompt, + prompt=steps_prompt, user_description=user_description, ) - initial_tasks_data = self._get_steps_data_from_text(response_text) + initial_tasks_data = self._get_steps_data_from_text( + response_text, + ) if not initial_tasks_data: self._log( - prompt=prompt, + prompt=steps_prompt, user_description=user_description, message='Template steps not found', response_text=response_text, 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 index e53bb771e..e421a8890 100644 --- 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 @@ -1,7 +1,10 @@ +import json import pytest from django.contrib.auth import get_user_model +from src.ai.enums import OpenAIPromptTarget from src.ai.tests.fixtures import create_test_prompt +from src.processes.consts import TEMPLATE_NAME_LENGTH from src.processes.enums import ( ConditionAction, PredicateOperator, @@ -15,12 +18,16 @@ from src.processes.services.templates.ai import ( AnonOpenAiService, ) +from src.utils.logging import SentryLogLevel UserModel = get_user_model() pytestmark = pytest.mark.django_db -def test_get_short_template_data__ok(mocker): +# === Legacy text path === + + +def test_get_short_template_data__legacy_text_path__ok(mocker): # arrange description = 'My lovely business process' @@ -102,7 +109,123 @@ def test_get_short_template_data__ok(mocker): assert task_2_predicate['value'] is None -def test_get_template_data__not_steps__raise_exception(mocker): +# === JSON path with GET_TEMPLATE prompt === + + +def test_get_short_template_data__json_path__ok(mocker): + + # arrange + description = 'My lovely business process' + create_test_prompt(target=OpenAIPromptTarget.GET_TEMPLATE) + ai_json = json.dumps({ + 'name': 'My Workflow', + 'description': 'A workflow', + 'kickoff': { + 'fields': [ + { + 'order': 1, + 'name': 'Input Field', + 'type': 'string', + 'is_required': True, + }, + ], + }, + 'tasks': [ + { + 'number': 1, + 'name': 'First task', + 'description': 'Do the first thing', + 'fields': [ + { + 'order': 1, + 'name': 'Output', + 'type': 'text', + }, + ], + }, + { + 'number': 2, + 'name': 'Second task', + 'description': 'Do the second thing', + }, + ], + }) + get_json_response_mock = mocker.patch( + 'src.processes.services.templates.' + 'ai.AnonOpenAiService._get_json_response', + return_value=ai_json, + ) + ip = '168.01.01.8' + user_agent = 'Some browser' + + service = AnonOpenAiService( + ident=ip, + user_agent=user_agent, + ) + + # act + data = service.get_short_template_data( + user_description=description, + ) + + # assert + get_json_response_mock.assert_called_once() + assert data['name'] == 'My Workflow' + assert len(data['tasks']) == 2 + assert data['tasks'][0]['name'] == 'First task' + assert data['tasks'][0]['api_name'] + assert 'fields' not in data['tasks'][0] + assert data['tasks'][1]['name'] == 'Second task' + + +# === JSON path with no prompt (default instruction) === + + +def test_get_short_template_data__no_prompt__uses_default(mocker): + + # arrange + description = 'My lovely business process' + ai_json = json.dumps({ + 'name': 'Generated', + 'tasks': [ + { + 'number': 1, + 'name': 'Step one', + 'description': 'First step', + }, + ], + }) + get_json_response_mock = mocker.patch( + 'src.processes.services.templates.' + 'ai.AnonOpenAiService._get_json_response', + return_value=ai_json, + ) + ip = '168.01.01.8' + user_agent = 'Some browser' + + service = AnonOpenAiService( + ident=ip, + user_agent=user_agent, + ) + + # act + data = service.get_short_template_data( + user_description=description, + ) + + # assert + get_json_response_mock.assert_called_once_with( + user_description=description, + prompt=None, + ) + assert data['name'] == 'Generated' + assert len(data['tasks']) == 1 + + +# === Error cases === + + +def test_get_short_template_data__legacy_not_steps__raise_exception(mocker): # arrange description = 'My lovely business process' @@ -151,17 +274,210 @@ def test_get_template_data__not_steps__raise_exception(mocker): ) -def test_get_template_data__not_prompt__raise_exception(mocker): +def test_get_short_template_data__json_parse_error__raise_exception(mocker): # arrange description = 'My lovely business process' - get_response_mock = mocker.patch( + mocker.patch( 'src.processes.services.templates.' - 'ai.AnonOpenAiService._get_response', + 'ai.AnonOpenAiService._get_json_response', + return_value='broken json {{', ) - get_tasks_data_from_text_mock = mocker.patch( + ip = '168.01.01.8' + user_agent = 'Some browser' + + service = AnonOpenAiService( + ident=ip, + user_agent=user_agent, + ) + + # act + with pytest.raises(OpenAiTemplateStepsNotExist) as ex: + service.get_short_template_data( + user_description=description, + ) + + # assert + assert ex.value.message == messages.MSG_PW_0045 + + +# === AnonOpenAiService._log_exception (3.17) === + + +def test_anon_log_exception__ok__calls_sentry(mocker): + + """ + + Calls capture_sentry_message with ident + + """ + + # arrange + ip = '168.01.01.8' + user_agent = 'Some browser' + service = AnonOpenAiService( + ident=ip, + user_agent=user_agent, + ) + prompt = create_test_prompt() + description = 'some description' + ex = Exception('some error') + sentry_mock = mocker.patch( 'src.processes.services.templates.' - 'ai.AnonOpenAiService._get_steps_data_from_text', + 'ai.capture_sentry_message', + ) + + # act + service._log_exception( + prompt=prompt, + user_description=description, + ex=ex, + ) + + # assert + sentry_mock.assert_called_once_with( + message=( + f'Error AI gen template from landing ({ip})' + ), + data={ + 'ident': ip, + 'user-agent': user_agent, + 'ex': { + 'error': str(ex), + }, + 'request': { + 'user_description': description, + 'prompt': prompt.as_dict(), + }, + }, + level=SentryLogLevel.INFO, + ) + + +# === AnonOpenAiService._log (3.18) === + + +def test_anon_log__ok__calls_sentry(mocker): + + """ + + Calls capture_sentry_message with ident + + """ + + # arrange + ip = '168.01.01.8' + user_agent = 'Some browser' + service = AnonOpenAiService( + ident=ip, + user_agent=user_agent, + ) + prompt = create_test_prompt() + description = 'some description' + message = 'some message' + response_text = 'some response' + sentry_mock = mocker.patch( + 'src.processes.services.templates.' + 'ai.capture_sentry_message', + ) + + # act + service._log( + prompt=prompt, + user_description=description, + message=message, + response_text=response_text, + ) + + # assert + sentry_mock.assert_called_once_with( + message=( + f'Error AI gen template from landing ({ip})' + ), + data={ + 'ident': ip, + 'user-agent': user_agent, + 'message': message, + 'response': { + 'text': response_text, + }, + 'request': { + 'user_description': description, + 'prompt': prompt.as_dict(), + }, + }, + level=SentryLogLevel.INFO, + ) + + +# === AnonOpenAiService._get_step_data_from_text (3.19) === + + +def test_anon_get_step_data__ok__no_performer(mocker): + + """ + + Returns dict without performers + + """ + + # arrange + ip = '168.01.01.8' + user_agent = 'Some browser' + service = AnonOpenAiService( + ident=ip, + user_agent=user_agent, + ) + api_name = 'task-123' + create_api_name_mock = mocker.patch( + 'src.processes.services.templates.' + 'ai.create_api_name', + return_value=api_name, + ) + + # act + result = service._get_step_data_from_text( + number=1, + name='Task one', + description='Do something', + prev_task_api_name=None, + ) + + # assert + assert result['number'] == 1 + assert result['name'] == 'Task one' + assert result['api_name'] == api_name + assert result['description'] == 'Do something' + assert 'raw_performers' not in result + assert len(result['conditions']) == 1 + assert create_api_name_mock.call_count == 4 + + +# === get_short_template_data (3.20) — new tests === + + +def test_get_short_tmpl__no_tasks_prompt__logs(mocker): + + """ + + JSON path empty tasks with prompt logs + + """ + + # arrange + description = 'My lovely business process' + prompt = create_test_prompt( + target=OpenAIPromptTarget.GET_TEMPLATE, + ) + ai_json = json.dumps({ + 'name': 'My Workflow', + 'tasks': [], + }) + response_text = ai_json + mocker.patch( + 'src.processes.services.templates.' + 'ai.AnonOpenAiService._get_json_response', + return_value=response_text, ) log_mock = mocker.patch( 'src.processes.services.templates.' @@ -169,20 +485,212 @@ def test_get_template_data__not_prompt__raise_exception(mocker): ) ip = '168.01.01.8' user_agent = 'Some browser' + service = AnonOpenAiService( + ident=ip, + user_agent=user_agent, + ) + + # act + with pytest.raises(OpenAiTemplateStepsNotExist): + service.get_short_template_data( + user_description=description, + ) + + # assert + log_mock.assert_called_once_with( + prompt=prompt, + user_description=description, + message='Template tasks not found', + response_text=response_text, + ) + + +def test_get_short_tmpl__no_tasks_no_prompt__raises(mocker): + """ + + JSON path empty tasks no prompt raises + + """ + + # arrange + description = 'My lovely business process' + ai_json = json.dumps({ + 'name': 'My Workflow', + 'tasks': [], + }) + mocker.patch( + 'src.processes.services.templates.' + 'ai.AnonOpenAiService._get_json_response', + return_value=ai_json, + ) + log_mock = mocker.patch( + 'src.processes.services.templates.' + 'ai.AnonOpenAiService._log', + ) + ip = '168.01.01.8' + user_agent = 'Some browser' service = AnonOpenAiService( ident=ip, user_agent=user_agent, ) # act - with pytest.raises(OpenAiStepsPromptNotExist) as ex: + with pytest.raises(OpenAiTemplateStepsNotExist): service.get_short_template_data( user_description=description, ) # assert - get_response_mock.assert_not_called() - get_tasks_data_from_text_mock.assert_not_called() - log_mock.assert_not_called() - assert ex.value.message == messages.MSG_PW_0046 + assert log_mock.call_count == 0 + + +def test_get_short_tmpl__no_steps_prompt__raises(mocker): + + """ + + Steps prompt not exist raises + + """ + + # arrange + description = 'My lovely business process' + + # create steps prompt with active messages so the + # first condition routes to else (steps) branch + create_test_prompt() + ip = '168.01.01.8' + user_agent = 'Some browser' + service = AnonOpenAiService( + ident=ip, + user_agent=user_agent, + ) + + # mock _get_response to raise the exception + # simulating the defensive guard behavior + mocker.patch( + 'src.processes.services.templates.' + 'ai.AnonOpenAiService._get_response', + side_effect=OpenAiStepsPromptNotExist(), + ) + + # act + with pytest.raises(OpenAiStepsPromptNotExist): + service.get_short_template_data( + user_description=description, + ) + + +def test_get_short_tmpl__empty_name__defaults(mocker): + + """ + + Empty name defaults to user_description + + """ + + # arrange + description = 'My lovely business process' + ai_json = json.dumps({ + 'name': '', + 'tasks': [ + { + 'number': 1, + 'name': 'Step one', + 'description': 'First step', + }, + ], + }) + mocker.patch( + 'src.processes.services.templates.' + 'ai.AnonOpenAiService._get_json_response', + return_value=ai_json, + ) + ip = '168.01.01.8' + user_agent = 'Some browser' + service = AnonOpenAiService( + ident=ip, + user_agent=user_agent, + ) + + # act + data = service.get_short_template_data( + user_description=description, + ) + + # assert + assert data['name'] == description[:TEMPLATE_NAME_LENGTH] + + +def test_get_short_tmpl__tasks__minimal_fields(mocker): + + """ + + Tasks stripped to minimal fields + + """ + + # arrange + description = 'My lovely business process' + create_test_prompt( + target=OpenAIPromptTarget.GET_TEMPLATE, + ) + ai_json = json.dumps({ + 'name': 'Workflow', + 'tasks': [ + { + 'number': 1, + 'name': 'Task A', + 'description': 'Do A', + 'fields': [ + { + 'order': 1, + 'name': 'Field', + 'type': 'string', + }, + ], + }, + { + 'number': 2, + 'name': 'Task B', + 'description': 'Do B', + }, + ], + }) + mocker.patch( + 'src.processes.services.templates.' + 'ai.AnonOpenAiService._get_json_response', + return_value=ai_json, + ) + ip = '168.01.01.8' + user_agent = 'Some browser' + service = AnonOpenAiService( + ident=ip, + user_agent=user_agent, + ) + + # act + data = service.get_short_template_data( + user_description=description, + ) + + # assert + assert len(data['tasks']) == 2 + task_1 = data['tasks'][0] + assert set(task_1.keys()) == { + 'number', 'name', 'api_name', + 'description', 'conditions', + } + assert task_1['number'] == 1 + assert task_1['name'] == 'Task A' + assert task_1['description'] == 'Do A' + assert task_1['api_name'] + assert len(task_1['conditions']) == 1 + + task_2 = data['tasks'][1] + assert set(task_2.keys()) == { + 'number', 'name', 'api_name', + 'description', 'conditions', + } + assert task_2['number'] == 2 + assert task_2['name'] == 'Task B' diff --git a/backend/src/processes/tests/test_services/test_templates/test_ai/test_base_ai_service.py b/backend/src/processes/tests/test_services/test_templates/test_ai/test_base_ai_service.py new file mode 100644 index 000000000..998def60a --- /dev/null +++ b/backend/src/processes/tests/test_services/test_templates/test_ai/test_base_ai_service.py @@ -0,0 +1,1540 @@ +import json + +import pytest +import requests as http_requests + +from src.ai.enums import ( + OpenAIPromptTarget, + OpenAIRole, +) +from src.ai.tests.fixtures import create_test_prompt +from src.authentication.enums import AuthTokenType +from src.processes.consts import TEMPLATE_NAME_LENGTH +from src.processes.enums import ( + ConditionAction, + FieldType, + PredicateOperator, + PredicateType, +) +from src.processes.models.templates.task import TaskTemplate +from src.processes.services.exceptions import ( + OpenAiServiceFailed, + OpenAiServiceUnavailable, +) +from src.processes.services.templates.ai import ( + DEFAULT_TEMPLATE_INSTRUCTION, + OpenAiService, +) +from src.processes.tests.fixtures import ( + create_test_account, + create_test_owner, +) + +pytestmark = pytest.mark.django_db + + +# === 3.1 _openai_headers === + + +def test_openai_headers__no_org__ok(mocker): + + """API key set, no org.""" + + # arrange + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'test-key-123', + ) + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_ORG', + '', + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + + # act + result = service._openai_headers() + + # assert + assert result == { + 'Authorization': 'Bearer test-key-123', + 'Content-Type': 'application/json', + } + + +def test_openai_headers__with_org__includes_org(mocker): + + """API key and org both set.""" + + # arrange + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'test-key-123', + ) + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_ORG', + 'org-abc', + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + + # act + result = service._openai_headers() + + # assert + assert result == { + 'Authorization': 'Bearer test-key-123', + 'Content-Type': 'application/json', + 'OpenAI-Organization': 'org-abc', + } + + +# === 3.2 _test_response === + + +def test_test_response__ok__returns_json(): + + """Returns valid JSON with expected structure.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + + # act + result = service._test_response() + + # assert + data = json.loads(result) + assert 'name' in data + assert 'description' in data + assert 'kickoff' in data + assert 'tasks' in data + assert len(data['tasks']) > 0 + assert 'fields' in data['kickoff'] + + +# === 3.3 _extract_json === + + +def test_extract_json__plain__returns_as_is(): + + """Plain JSON string without fences.""" + + # arrange + text = '{"name": "test"}' + + # act + result = OpenAiService._extract_json(text=text) + + # assert + assert result == '{"name": "test"}' + + +def test_extract_json__json_fence__extracts(): + + """JSON wrapped in ```json fences.""" + + # arrange + text = '```json\n{"name": "test"}\n```' + + # act + result = OpenAiService._extract_json(text=text) + + # assert + assert result == '{"name": "test"}' + + +def test_extract_json__bare_fence__extracts(): + + """JSON wrapped in ``` fences (no lang).""" + + # arrange + text = '```\n{"name": "test"}\n```' + + # act + result = OpenAiService._extract_json(text=text) + + # assert + assert result == '{"name": "test"}' + + +def test_extract_json__whitespace__stripped(): + + """Leading/trailing whitespace.""" + + # arrange + text = ' \n {"name": "test"} \n ' + + # act + result = OpenAiService._extract_json(text=text) + + # assert + assert result == '{"name": "test"}' + + +# === 3.4 _normalize_field === + + +def test_normalize_field__string__ok(): + + """Valid string field with all keys.""" + + # arrange + field_data = { + 'order': 2, + 'name': 'Project Name', + 'type': FieldType.STRING, + 'is_required': True, + 'description': 'Name of the project', + 'default': 'My Project', + 'api_name': 'field-project-name', + } + + # act + result = OpenAiService._normalize_field(field_data=field_data) + + # assert + assert result['order'] == 2 + assert result['name'] == 'Project Name' + assert result['type'] == FieldType.STRING + assert result['is_required'] is True + assert result['description'] == 'Name of the project' + assert result['default'] == 'My Project' + assert result['api_name'] == 'field-project-name' + assert 'selections' not in result + + +def test_normalize_field__bad_type__fallback(): + + """Invalid type falls back to string.""" + + # arrange + field_data = { + 'type': 'invalid_type', + 'api_name': 'field-test', + } + + # act + result = OpenAiService._normalize_field(field_data=field_data) + + # assert + assert result['type'] == FieldType.STRING + + +def test_normalize_field__dropdown__has_sels(): + + """Selection field (dropdown) with selections.""" + + # arrange + field_data = { + 'type': FieldType.DROPDOWN, + 'api_name': 'field-priority', + 'selections': [ + {'value': 'High'}, + {'value': 'Low'}, + ], + } + + # act + result = OpenAiService._normalize_field(field_data=field_data) + + # assert + assert result['type'] == FieldType.DROPDOWN + assert len(result['selections']) == 2 + assert result['selections'][0]['value'] == 'High' + assert result['selections'][1]['value'] == 'Low' + assert 'api_name' in result['selections'][0] + assert 'api_name' in result['selections'][1] + + +def test_normalize_field__empty_sel__skipped(): + + """Selection field with empty selection values.""" + + # arrange + field_data = { + 'type': FieldType.DROPDOWN, + 'api_name': 'field-test', + 'selections': [ + {'value': ''}, + {'value': 'Valid'}, + ], + } + + # act + result = OpenAiService._normalize_field(field_data=field_data) + + # assert + assert len(result['selections']) == 1 + assert result['selections'][0]['value'] == 'Valid' + + +def test_normalize_field__sel_not_dict__skipped(): + + """Selection item is not a dict.""" + + # arrange + field_data = { + 'type': FieldType.DROPDOWN, + 'api_name': 'field-test', + 'selections': [ + 'not a dict', + {'value': 'Valid'}, + ], + } + + # act + result = OpenAiService._normalize_field(field_data=field_data) + + # assert + assert len(result['selections']) == 1 + assert result['selections'][0]['value'] == 'Valid' + + +def test_normalize_field__str_with_sel__no_sel(): + + """Non-selection field ignores selections.""" + + # arrange + field_data = { + 'type': FieldType.STRING, + 'api_name': 'field-test', + 'selections': [ + {'value': 'Should not appear'}, + ], + } + + # act + result = OpenAiService._normalize_field(field_data=field_data) + + # assert + assert 'selections' not in result + + +def test_normalize_field__no_api_name__generated(mocker): + + """Missing api_name generates one.""" + + # arrange + mocker.patch( + 'src.processes.services.templates.ai.create_api_name', + return_value='field-generated', + ) + field_data = { + 'type': FieldType.STRING, + } + + # act + result = OpenAiService._normalize_field(field_data=field_data) + + # assert + assert result['api_name'] == 'field-generated' + + +def test_normalize_field__long_name__truncated(): + + """Name truncated to 50 chars.""" + + # arrange + field_data = { + 'name': 'A' * 100, + 'api_name': 'field-test', + } + + # act + result = OpenAiService._normalize_field(field_data=field_data) + + # assert + assert len(result['name']) == 50 + + +def test_normalize_field__defaults__ok(): + + """Default values for missing keys.""" + + # arrange + field_data = { + 'api_name': 'field-test', + } + + # act + result = OpenAiService._normalize_field(field_data=field_data) + + # assert + assert result['order'] == 1 + assert result['name'] == '' + assert result['type'] == FieldType.STRING + assert result['description'] == '' + assert result['default'] == '' + + +def test_normalize_field__is_req_default__false(): + + """is_required default is False.""" + + # arrange + field_data = { + 'api_name': 'field-test', + } + + # act + result = OpenAiService._normalize_field(field_data=field_data) + + # assert + assert result['is_required'] is False + + +# === 3.5 _get_start_task_condition === + + +def test_get_start_task_cond__none__kickoff(mocker): + + """prev_task_api_name is None (kickoff).""" + + # arrange + mocker.patch( + 'src.processes.services.templates.ai.create_api_name', + return_value='generated-name', + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + + # act + result = service._get_start_task_condition( + prev_task_api_name=None, + ) + + # assert + assert result['order'] == 1 + assert result['action'] == ConditionAction.START_TASK + predicate = result['rules'][0]['predicates'][0] + assert predicate['field_type'] == PredicateType.KICKOFF + assert predicate['operator'] == PredicateOperator.COMPLETED + assert predicate['field'] is None + assert predicate['value'] is None + + +def test_get_start_task_cond__task__ok(mocker): + + """prev_task_api_name provided (task).""" + + # arrange + mocker.patch( + 'src.processes.services.templates.ai.create_api_name', + return_value='generated-name', + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + + # act + result = service._get_start_task_condition( + prev_task_api_name='task-prev-123', + ) + + # assert + assert result['order'] == 1 + assert result['action'] == ConditionAction.START_TASK + predicate = result['rules'][0]['predicates'][0] + assert predicate['field_type'] == PredicateType.TASK + assert predicate['operator'] == PredicateOperator.COMPLETED + assert predicate['field'] == 'task-prev-123' + assert predicate['value'] is None + + +# === 3.6 _call_responses_api === + + +def test_call_responses_api__ok__returns_text(mocker): + + """Successful response with output_text.""" + + # arrange + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'test-key', + ) + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_ORG', + '', + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + mock_response = mocker.Mock() + mock_response.json.return_value = { + 'output': [ + { + 'type': 'message', + 'content': [ + { + 'type': 'output_text', + 'text': 'generated text', + }, + ], + }, + ], + } + mock_response.raise_for_status = mocker.Mock() + post_mock = mocker.patch( + 'src.processes.services.templates.ai.http_requests.post', + return_value=mock_response, + ) + + # act + result = service._call_responses_api( + payload={'model': 'gpt-4.1-mini', 'input': 'test'}, + ) + + # assert + assert result == 'generated text' + post_mock.assert_called_once_with( + 'https://api.openai.com/v1/responses', + headers=service._openai_headers(), + json={'model': 'gpt-4.1-mini', 'input': 'test'}, + timeout=120, + ) + + +def test_call_responses_api__req_err__unavail(mocker): + + """RequestException raises Unavailable.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + mocker.patch( + 'src.processes.services.templates.ai.http_requests.post', + side_effect=http_requests.RequestException('conn err'), + ) + + # act + with pytest.raises(OpenAiServiceUnavailable): + service._call_responses_api( + payload={'model': 'gpt-4.1-mini'}, + ) + + +def test_call_responses_api__no_text__failed(mocker): + + """No output_text in response raises Failed.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + mock_response = mocker.Mock() + mock_response.json.return_value = { + 'output': [ + { + 'type': 'other', + 'content': [], + }, + ], + } + mock_response.raise_for_status = mocker.Mock() + mocker.patch( + 'src.processes.services.templates.ai.http_requests.post', + return_value=mock_response, + ) + + # act + with pytest.raises(OpenAiServiceFailed): + service._call_responses_api( + payload={'model': 'gpt-4.1-mini'}, + ) + + +def test_call_responses_api__http_err__unavail(mocker): + + """HTTP error status raises Unavailable.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + mock_response = mocker.Mock() + mock_response.raise_for_status.side_effect = ( + http_requests.HTTPError('500 Server Error') + ) + mocker.patch( + 'src.processes.services.templates.ai.http_requests.post', + return_value=mock_response, + ) + + # act + with pytest.raises(OpenAiServiceUnavailable): + service._call_responses_api( + payload={'model': 'gpt-4.1-mini'}, + ) + + +# === 3.7 _get_response (No tests only) === + + +def test_get_response__sys_and_user__payload_ok(mocker): + + """Prompt with system+user messages builds payload.""" + + # arrange + prompt = create_test_prompt( + messages_count=2, + target=OpenAIPromptTarget.GET_STEPS, + ) + msg_1 = prompt.messages.filter(order=1).first() + msg_1.role = OpenAIRole.SYSTEM + msg_1.content = 'System instruction' + msg_1.save() + msg_2 = prompt.messages.filter(order=2).first() + msg_2.role = OpenAIRole.USER + msg_2.content = 'User says: {{ user_description }}' + msg_2.save() + + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'test-key', + ) + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_ORG', + '', + ) + account = create_test_account() + user = create_test_owner(account=account) + 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='response text', + ) + + # act + result = service._get_response( + prompt=prompt, + user_description='build a house', + ) + + # assert + assert result == 'response text' + call_api_mock.assert_called_once_with( + { + 'model': prompt.model, + 'input': 'User says: build a house', + 'temperature': prompt.temperature, + 'top_p': prompt.top_p, + 'instructions': 'System instruction', + }, + ) + + +def test_get_response__presence_penalty__in_payload(mocker): + + """Prompt with presence_penalty in payload.""" + + # arrange + prompt = create_test_prompt( + target=OpenAIPromptTarget.GET_STEPS, + ) + prompt.presence_penalty = 0.5 + prompt.save() + + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'test-key', + ) + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_ORG', + '', + ) + account = create_test_account() + user = create_test_owner(account=account) + 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( + prompt=prompt, + user_description='test', + ) + + # assert + payload = call_api_mock.call_args[0][0] + assert payload['presence_penalty'] == 0.5 + + +def test_get_response__freq_penalty__in_payload(mocker): + + """Prompt with frequency_penalty in payload.""" + + # arrange + prompt = create_test_prompt( + target=OpenAIPromptTarget.GET_STEPS, + ) + prompt.frequency_penalty = 0.3 + prompt.save() + + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'test-key', + ) + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_ORG', + '', + ) + account = create_test_account() + user = create_test_owner(account=account) + 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( + prompt=prompt, + user_description='test', + ) + + # assert + payload = call_api_mock.call_args[0][0] + assert payload['frequency_penalty'] == 0.3 + + +def test_get_response__svc_failed__logs_raises(mocker): + + """ServiceFailed exception logs and re-raises.""" + + # arrange + prompt = create_test_prompt( + target=OpenAIPromptTarget.GET_STEPS, + ) + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'test-key', + ) + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_ORG', + '', + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + mocker.patch( + 'src.processes.services.templates.' + 'ai.BaseAiService._call_responses_api', + side_effect=OpenAiServiceFailed(), + ) + log_exception_mock = mocker.patch( + 'src.processes.services.templates.' + 'ai.OpenAiService._log_exception', + ) + + # act + with pytest.raises(OpenAiServiceFailed): + service._get_response( + prompt=prompt, + user_description='test', + ) + + # assert + log_exception_mock.assert_called_once_with( + ex=mocker.ANY, + prompt=prompt, + user_description='test', + ) + + +# === 3.8 _get_json_response (No tests only) === + + +def test_get_json_resp__prompt_active__uses_prompt(mocker): + + """Prompt with active messages uses prompt.""" + + # arrange + prompt = create_test_prompt( + target=OpenAIPromptTarget.GET_TEMPLATE, + messages_count=2, + ) + msg_1 = prompt.messages.filter(order=1).first() + msg_1.role = OpenAIRole.SYSTEM + msg_1.content = 'Custom system instruction' + msg_1.save() + msg_2 = prompt.messages.filter(order=2).first() + msg_2.role = OpenAIRole.USER + msg_2.content = 'Custom user: {{ user_description }}' + msg_2.save() + + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'test-key', + ) + account = create_test_account() + user = create_test_owner(account=account) + 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": "Test"}', + ) + + # act + result = service._get_json_response( + user_description='hire employee', + prompt=prompt, + ) + + # assert + assert result == '{"name": "Test"}' + call_api_mock.assert_called_once_with( + { + 'model': prompt.model, + 'input': 'Custom user: hire employee', + 'temperature': prompt.temperature, + 'top_p': prompt.top_p, + 'instructions': 'Custom system instruction', + }, + ) + + +def test_get_json_resp__no_prompt__defaults(mocker): + + """Prompt=None uses default model/instruction.""" + + # arrange + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'test-key', + ) + account = create_test_account() + user = create_test_owner(account=account) + 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": "Test"}', + ) + + # act + service._get_json_response( + user_description='onboard new hire', + prompt=None, + ) + + # assert + call_api_mock.assert_called_once_with( + { + 'model': 'gpt-4.1-mini', + 'input': 'onboard new hire', + 'temperature': 0.7, + 'top_p': 1, + 'instructions': DEFAULT_TEMPLATE_INSTRUCTION, + }, + ) + + +def test_get_json_resp__no_active_msgs__default(mocker): + + """Prompt without active msgs uses default instr.""" + + # arrange + prompt = create_test_prompt( + target=OpenAIPromptTarget.GET_TEMPLATE, + messages_count=1, + ) + msg = prompt.messages.first() + msg.is_active = False + msg.save() + + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'test-key', + ) + account = create_test_account() + user = create_test_owner(account=account) + 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": "Test"}', + ) + + # act + service._get_json_response( + user_description='test desc', + prompt=prompt, + ) + + # assert + payload = call_api_mock.call_args[0][0] + assert payload['instructions'] == DEFAULT_TEMPLATE_INSTRUCTION + assert payload['input'] == 'test desc' + + +def test_get_json_resp__err_with_prompt__logs(mocker): + + """API error with prompt logs exception.""" + + # arrange + prompt = create_test_prompt( + target=OpenAIPromptTarget.GET_TEMPLATE, + ) + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'test-key', + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + mocker.patch( + 'src.processes.services.templates.' + 'ai.BaseAiService._call_responses_api', + side_effect=OpenAiServiceUnavailable(), + ) + log_exception_mock = mocker.patch( + 'src.processes.services.templates.' + 'ai.OpenAiService._log_exception', + ) + + # act + with pytest.raises(OpenAiServiceUnavailable): + service._get_json_response( + user_description='test', + prompt=prompt, + ) + + # assert + log_exception_mock.assert_called_once_with( + ex=mocker.ANY, + prompt=prompt, + user_description='test', + ) + + +def test_get_json_resp__err_no_prompt__no_log(mocker): + + """API error without prompt does not log.""" + + # arrange + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'test-key', + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + mocker.patch( + 'src.processes.services.templates.' + 'ai.BaseAiService._call_responses_api', + side_effect=OpenAiServiceUnavailable(), + ) + log_exception_mock = mocker.patch( + 'src.processes.services.templates.' + 'ai.OpenAiService._log_exception', + ) + + # act + with pytest.raises(OpenAiServiceUnavailable): + service._get_json_response( + user_description='test', + prompt=None, + ) + + # assert + assert log_exception_mock.call_count == 0 + + +def test_get_json_resp__presence_pen__in_payload(mocker): + + """Presence_penalty included when set.""" + + # arrange + prompt = create_test_prompt( + target=OpenAIPromptTarget.GET_TEMPLATE, + ) + prompt.presence_penalty = 0.6 + prompt.save() + + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'test-key', + ) + account = create_test_account() + user = create_test_owner(account=account) + 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_json_response( + user_description='test', + prompt=prompt, + ) + + # assert + payload = call_api_mock.call_args[0][0] + assert payload['presence_penalty'] == 0.6 + + +def test_get_json_resp__freq_pen__in_payload(mocker): + + """Frequency_penalty included when set.""" + + # arrange + prompt = create_test_prompt( + target=OpenAIPromptTarget.GET_TEMPLATE, + ) + prompt.frequency_penalty = 0.4 + prompt.save() + + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'test-key', + ) + account = create_test_account() + user = create_test_owner(account=account) + 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_json_response( + user_description='test', + prompt=prompt, + ) + + # assert + payload = call_api_mock.call_args[0][0] + assert payload['frequency_penalty'] == 0.4 + + +# === 3.12 _get_steps_data_from_text (No tests only) === + + +def test_get_steps_data__empty__returns_empty(): + + """Empty text returns empty list.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + + # act + result = service._get_steps_data_from_text(text='') + + # assert + assert result == [] + + +def test_get_steps_data__no_pipe__skipped(): + + """Lines without pipe are skipped.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + + # act + result = service._get_steps_data_from_text( + text='no pipe here\nanother line', + ) + + # assert + assert result == [] + + +def test_get_steps_data__multi_pipe__skipped(): + + """Lines with multiple pipes are skipped.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + + # act + result = service._get_steps_data_from_text( + text='a|b|c\nd|e|f', + ) + + # assert + assert result == [] + + +def test_get_steps_data__long_name__truncated(): + + """Names truncated to NAME_MAX_LENGTH.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + long_name = 'A' * 500 + text = f'{long_name}|description here' + + # act + result = service._get_steps_data_from_text(text=text) + + # assert + assert len(result) == 1 + assert len(result[0]['name']) == TaskTemplate.NAME_MAX_LENGTH + + +# === 3.14 _parse_template_from_json === + + +def test_parse_tmpl_json__ok__full_data(mocker): + + """Valid JSON with tasks and kickoff fields.""" + + # arrange + mocker.patch( + 'src.processes.services.templates.ai.create_api_name', + return_value='generated-name', + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + data = { + 'name': 'Onboarding', + 'description': 'New hire onboarding', + 'kickoff': { + 'fields': [ + { + 'api_name': 'field-emp-name', + 'order': 1, + 'name': 'Employee Name', + 'type': 'string', + 'is_required': True, + 'description': 'Full name', + }, + ], + }, + 'tasks': [ + { + 'name': 'Setup accounts', + 'description': 'Create email and Slack', + 'fields': [ + { + 'api_name': 'field-email', + 'order': 1, + 'name': 'Email', + 'type': 'string', + 'is_required': True, + 'description': 'Work email', + }, + ], + }, + ], + } + text = json.dumps(data) + + # act + result = service._parse_template_from_json(text=text) + + # assert + assert result['name'] == 'Onboarding' + assert result['description'] == 'New hire onboarding' + assert len(result['kickoff']['fields']) == 1 + assert len(result['tasks']) == 1 + assert result['tasks'][0]['number'] == 1 + assert result['tasks'][0]['name'] == 'Setup accounts' + assert 'fields' in result['tasks'][0] + assert 'conditions' in result['tasks'][0] + + +def test_parse_tmpl_json__fenced__ok(mocker): + + """JSON with code fences extracted correctly.""" + + # arrange + mocker.patch( + 'src.processes.services.templates.ai.create_api_name', + return_value='generated-name', + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + inner = json.dumps({ + 'name': 'Fenced', + 'description': '', + 'tasks': [ + {'name': 'Task 1', 'description': 'Do stuff'}, + ], + 'kickoff': {'fields': []}, + }) + text = f'```json\n{inner}\n```' + + # act + result = service._parse_template_from_json(text=text) + + # assert + assert result['name'] == 'Fenced' + assert len(result['tasks']) == 1 + + +def test_parse_tmpl_json__bad_json__raises(): + + """Invalid JSON raises JSONDecodeError.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + + # act + with pytest.raises(json.JSONDecodeError): + service._parse_template_from_json( + text='not valid json {{{', + ) + + +def test_parse_tmpl_json__not_dict__raises(): + + """Data is not a dict raises TypeError.""" + + # arrange + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + + # act + with pytest.raises(TypeError): + service._parse_template_from_json( + text='[1, 2, 3]', + ) + + +def test_parse_tmpl_json__long_name__truncated(mocker): + + """Template name truncated to limit.""" + + # arrange + mocker.patch( + 'src.processes.services.templates.ai.create_api_name', + return_value='generated-name', + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + data = { + 'name': 'X' * 500, + 'description': '', + 'tasks': [], + 'kickoff': {'fields': []}, + } + + # act + result = service._parse_template_from_json( + text=json.dumps(data), + ) + + # assert + assert len(result['name']) == TEMPLATE_NAME_LENGTH + + +def test_parse_tmpl_json__no_task_fields__omitted(mocker): + + """Task without fields omits fields key.""" + + # arrange + mocker.patch( + 'src.processes.services.templates.ai.create_api_name', + return_value='generated-name', + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + data = { + 'name': 'Test', + 'description': '', + 'tasks': [ + {'name': 'No fields task', 'description': 'desc'}, + ], + 'kickoff': {'fields': []}, + } + + # act + result = service._parse_template_from_json( + text=json.dumps(data), + ) + + # assert + assert 'fields' not in result['tasks'][0] + + +def test_parse_tmpl_json__no_tasks__empty(mocker): + + """Empty tasks list returns empty tasks.""" + + # arrange + mocker.patch( + 'src.processes.services.templates.ai.create_api_name', + return_value='generated-name', + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + data = { + 'name': 'Empty', + 'description': '', + 'tasks': [], + 'kickoff': {'fields': []}, + } + + # act + result = service._parse_template_from_json( + text=json.dumps(data), + ) + + # assert + assert result['tasks'] == [] + + +def test_parse_tmpl_json__no_kickoff__empty(mocker): + + """Empty kickoff returns empty fields.""" + + # arrange + mocker.patch( + 'src.processes.services.templates.ai.create_api_name', + return_value='generated-name', + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + data = { + 'name': 'No Kickoff', + 'description': '', + 'tasks': [], + } + + # act + result = service._parse_template_from_json( + text=json.dumps(data), + ) + + # assert + assert result['kickoff'] == {'fields': []} + + +def test_parse_tmpl_json__multi_tasks__chained(mocker): + + """Multiple tasks chain conditions correctly.""" + + # arrange + call_count = 0 + names = [ + 'pred-1', 'cond-1', 'rule-1', 'task-1', + 'pred-2', 'cond-2', 'rule-2', 'task-2', + 'pred-3', 'cond-3', 'rule-3', 'task-3', + ] + + def fake_api_name(prefix=''): + nonlocal call_count + idx = min(call_count, len(names) - 1) + result = names[idx] + call_count += 1 + return result + + mocker.patch( + 'src.processes.services.templates.ai.create_api_name', + side_effect=fake_api_name, + ) + account = create_test_account() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + data = { + 'name': 'Multi', + 'description': '', + 'tasks': [ + {'name': 'Task A', 'description': 'A'}, + {'name': 'Task B', 'description': 'B'}, + {'name': 'Task C', 'description': 'C'}, + ], + 'kickoff': {'fields': []}, + } + + # act + result = service._parse_template_from_json( + text=json.dumps(data), + ) + + # assert + assert len(result['tasks']) == 3 + assert result['tasks'][0]['number'] == 1 + assert result['tasks'][1]['number'] == 2 + assert result['tasks'][2]['number'] == 3 + + # first task condition references kickoff (None) + first_pred = ( + result['tasks'][0]['conditions'][0] + ['rules'][0]['predicates'][0] + ) + assert first_pred['field'] is None + assert first_pred['field_type'] == PredicateType.KICKOFF + + # second task condition references first task api_name + second_pred = ( + result['tasks'][1]['conditions'][0] + ['rules'][0]['predicates'][0] + ) + assert second_pred['field'] == result['tasks'][0]['api_name'] + assert second_pred['field_type'] == PredicateType.TASK + + # third task condition references second task api_name + third_pred = ( + result['tasks'][2]['conditions'][0] + ['rules'][0]['predicates'][0] + ) + assert third_pred['field'] == result['tasks'][1]['api_name'] + assert third_pred['field_type'] == PredicateType.TASK 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 index b9972fded..d32c2e6f5 100644 --- 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 @@ -1,43 +1,38 @@ -import openai.error +import json + import pytest from django.contrib.auth import get_user_model from src.ai.enums import ( + OpenAIPromptTarget, OpenAIRole, ) from src.ai.tests.fixtures import create_test_prompt -from src.authentication.enums import ( - AuthTokenType, -) -from src.processes.enums import ( - OwnerRole, - ConditionAction, - OwnerType, - PerformerType, - PredicateOperator, - PredicateType, -) -from src.processes.messages import workflow as messages -from src.processes.models.templates.task import TaskTemplate +from src.authentication.enums import AuthTokenType +from src.processes.enums import PerformerType from src.processes.services.exceptions import ( OpenAiLimitTemplateGenerations, - OpenAiServiceFailed, OpenAiServiceUnavailable, - OpenAiStepsPromptNotExist, OpenAiTemplateStepsNotExist, ) from src.processes.services.templates.ai import ( OpenAiService, ) from src.processes.tests.fixtures import ( + create_test_account, + create_test_owner, create_test_user, ) +from src.utils.logging import SentryLogLevel UserModel = get_user_model() pytestmark = pytest.mark.django_db -def test_get_response__ci_configuration__return_test_response(mocker): +# === _get_response (legacy text-based) === + + +def test_get_response__no_api_key__raise_exception(mocker): # arrange description = 'some description' @@ -46,10 +41,6 @@ def test_get_response__ci_configuration__return_test_response(mocker): 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', None, ) - mocker.patch( - 'src.processes.services.templates.ai.settings.OPENAI_API_ORG', - None, - ) user = create_test_user() service = OpenAiService( ident=user.id, @@ -57,22 +48,12 @@ def test_get_response__ci_configuration__return_test_response(mocker): auth_type=AuthTokenType.USER, ) - test_response = mocker.Mock() - test_response_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.OpenAiService._test_response', - return_value=test_response, - ) - # act - response = service._get_response( - user_description=description, - prompt=prompt, - ) - - # assert - test_response_mock.assert_called_once() - assert response == test_response + with pytest.raises(OpenAiServiceUnavailable): + service._get_response( + user_description=description, + prompt=prompt, + ) def test_get_response__ok(mocker): @@ -100,20 +81,10 @@ def test_get_response__ok(mocker): auth_type=AuthTokenType.USER, ) ai_response = 'some ai response' - message_mock = mocker.Mock( - content=ai_response, - finish_reason=None, - ) - choice_mock = mocker.Mock( - message=message_mock, - ) - completion_mock = mocker.Mock( - choices=[choice_mock], - ) - create_completion_mock = mocker.patch( + call_api_mock = mocker.patch( 'src.processes.services.templates.' - 'ai.openai.ChatCompletion.create', - return_value=completion_mock, + 'ai.BaseAiService._call_responses_api', + return_value=ai_response, ) # act @@ -124,27 +95,10 @@ def test_get_response__ok(mocker): # assert assert response == ai_response - create_completion_mock.assert_called_once_with( - model=prompt.model, - temperature=prompt.temperature, - top_p=prompt.top_p, - presence_penalty=prompt.presence_penalty, - frequency_penalty=prompt.frequency_penalty, - user=f'u{user.id}', - messages=[ - { - "role": OpenAIRole.USER, - "content": f'Some {description} text', - }, - { - "role": message_2.role, - "content": message_2.content, - }, - ], - ) + call_api_mock.assert_called_once() -def test_get_response__openai_error__raise_exception(mocker): +def test_get_response__api_error__raise_exception(mocker): # arrange description = 'some description' @@ -167,55 +121,33 @@ def test_get_response__openai_error__raise_exception(mocker): 'src.processes.services.templates.' 'ai.OpenAiService._log_exception', ) - error = openai.error.RateLimitError() - create_completion_mock = mocker.patch( + mocker.patch( 'src.processes.services.templates.' - 'ai.openai.ChatCompletion.create', - side_effect=error, + 'ai.BaseAiService._call_responses_api', + side_effect=OpenAiServiceUnavailable(), ) # act - with pytest.raises(OpenAiServiceUnavailable) as ex: + with pytest.raises(OpenAiServiceUnavailable): service._get_response( user_description=description, prompt=prompt, ) # assert - create_completion_mock.assert_called_once_with( - model=prompt.model, - temperature=prompt.temperature, - top_p=prompt.top_p, - presence_penalty=prompt.presence_penalty, - frequency_penalty=prompt.frequency_penalty, - user=f'u{user.id}', - messages=[ - { - "role": OpenAIRole.USER, - "content": f'Some {description} text', - }, - ], - ) - log_exception_mock.assert_called_once_with( - ex=error, - user_description=description, - prompt=prompt, - ) - assert ex.value.message == messages.MSG_PW_0042 + log_exception_mock.assert_called_once() -def test_get_response__not_completion_choices__raise_exception(mocker): +# === _get_json_response === + + +def test_get_json_response__no_api_key__return_test_response(mocker): # arrange description = 'some description' - prompt = create_test_prompt() mocker.patch( 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', - 'some_key', - ) - mocker.patch( - 'src.processes.services.templates.ai.settings.OPENAI_API_ORG', - 'some_org', + None, ) user = create_test_user() service = OpenAiService( @@ -223,41 +155,28 @@ def test_get_response__not_completion_choices__raise_exception(mocker): user=user, auth_type=AuthTokenType.USER, ) - log_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.OpenAiService._log', - ) - completion_mock = mocker.Mock( - choices=[], - ) - create_completion_mock = mocker.patch( + + test_response = mocker.Mock() + test_response_mock = mocker.patch( 'src.processes.services.templates.' - 'ai.openai.ChatCompletion.create', - return_value=completion_mock, + 'ai.OpenAiService._test_response', + return_value=test_response, ) # act - with pytest.raises(OpenAiServiceFailed) as ex: - service._get_response( - user_description=description, - prompt=prompt, - ) - - # assert - create_completion_mock.assert_called_once() - log_mock.assert_called_once_with( - message='Response has no choices', + response = service._get_json_response( user_description=description, - prompt=prompt, ) - assert ex.value.message == messages.MSG_PW_0043 + + # assert + test_response_mock.assert_called_once() + assert response == test_response -def test_get_response__finish_reason__raise_exception(mocker): +def test_get_json_response__ok__uses_responses_api(mocker): # arrange description = 'some description' - prompt = create_test_prompt() mocker.patch( 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', 'some_key', @@ -272,42 +191,55 @@ def test_get_response__finish_reason__raise_exception(mocker): user=user, auth_type=AuthTokenType.USER, ) - log_mock = mocker.patch( + ai_response = '{"name": "Test", "tasks": []}' + call_api_mock = mocker.patch( 'src.processes.services.templates.' - 'ai.OpenAiService._log', + 'ai.BaseAiService._call_responses_api', + return_value=ai_response, ) - finish_reason = 'Some finish' - message_mock = mocker.Mock( - finish_reason=finish_reason, + + # act + response = service._get_json_response( + user_description=description, ) - choice_mock = mocker.Mock( - message=message_mock, + + # assert + assert response == ai_response + call_api_mock.assert_called_once() + payload = call_api_mock.call_args[0][0] + assert payload['model'] == 'gpt-4.1-mini' + assert 'instructions' in payload + assert payload['input'] == description + + +def test_get_json_response__api_error__raise_exception(mocker): + + # arrange + description = 'some description' + mocker.patch( + 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', + 'some_key', ) - completion_mock = mocker.Mock( - choices=[choice_mock], + user = create_test_user() + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, ) - create_completion_mock = mocker.patch( + mocker.patch( 'src.processes.services.templates.' - 'ai.openai.ChatCompletion.create', - return_value=completion_mock, + 'ai.BaseAiService._call_responses_api', + side_effect=OpenAiServiceUnavailable(), ) # act - with pytest.raises(OpenAiServiceFailed) as ex: - service._get_response( + with pytest.raises(OpenAiServiceUnavailable): + service._get_json_response( user_description=description, - prompt=prompt, ) - # assert - create_completion_mock.assert_called_once() - log_mock.assert_called_once_with( - message='Response has finish reason', - response_text=finish_reason, - user_description=description, - prompt=prompt, - ) - assert ex.value.message == messages.MSG_PW_0043 + +# === _get_steps_data_from_text (legacy text parsing) === def test_get_steps_data_from_text__ok(mocker): @@ -357,383 +289,903 @@ def test_get_steps_data_from_text__ok(mocker): ] -def test_get_steps_data_from_text__limit_task_name__ok(mocker): +# === OpenAiService._log_exception (3.9) === - # arrange - user = create_test_user() - mocker.patch( - 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', - 'some_key', - ) - mocker.patch( - 'src.processes.services.templates.ai.settings.OPENAI_API_ORG', - 'some_org', - ) - limit = TaskTemplate.NAME_MAX_LENGTH - task_name = 'o' * (limit + 1) - text = f'{task_name} | desc' +def test_oa_log_exception__ok__calls_sentry(mocker): + + """Calls capture_sentry_message with data""" + + # arrange + user = create_test_owner() service = OpenAiService( ident=user.id, user=user, auth_type=AuthTokenType.USER, ) + prompt = create_test_prompt() + description = 'some description' + exception = Exception('some error') + sentry_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'capture_sentry_message', + ) # act - result = service._get_steps_data_from_text(text=text) + service._log_exception( + prompt=prompt, + user_description=description, + ex=exception, + ) # assert - assert len(result[0]['name']) == limit + sentry_mock.assert_called_once_with( + message=( + f'Error AI generating template ' + f'({user.account.id})' + ), + data={ + 'user_id': user.id, + 'user_email': user.email, + 'account_id': user.account.id, + 'ex': { + 'error': str(exception), + }, + 'request': { + 'user_description': description, + 'prompt': prompt.as_dict(), + }, + }, + level=SentryLogLevel.INFO, + ) + + +# === OpenAiService._log (3.10) === + +def test_oa_log__ok__calls_sentry(mocker): -def test_get_steps_data_from_text__not_delimiter__skip(mocker): + """Calls capture_sentry_message with data""" # arrange - mocker.patch( - 'src.processes.services.templates.ai.settings.OPENAI_API_KEY', - 'some_key', - ) - mocker.patch( - 'src.processes.services.templates.ai.settings.OPENAI_API_ORG', - 'some_org', - ) - user = create_test_user() + user = create_test_owner() service = OpenAiService( ident=user.id, user=user, auth_type=AuthTokenType.USER, ) - text = 'Title desc' + prompt = create_test_prompt() + description = 'some description' + message = 'some message' + response_text = 'some response' + sentry_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'capture_sentry_message', + ) # act - result = service._get_steps_data_from_text(text=text) + service._log( + prompt=prompt, + user_description=description, + message=message, + response_text=response_text, + ) # assert - assert len(result) == 0 + sentry_mock.assert_called_once_with( + message=( + f'Error AI generating template ' + f'({user.account.id})' + ), + data={ + 'user_id': user.id, + 'user_email': user.email, + 'account_id': user.account.id, + 'message': message, + 'response': { + 'text': response_text, + }, + 'request': { + 'user_description': description, + 'prompt': prompt.as_dict(), + }, + }, + level=SentryLogLevel.INFO, + ) + +def test_oa_log__no_resp_text__ok(mocker): -def test_get_post_template_generation_actions__ok(mocker): + """response_text=None included in data""" # arrange - description = 'text' - inc_template_generations_count_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.AccountService.inc_template_generations_count', - ) - user = create_test_user() + user = create_test_owner() service = OpenAiService( ident=user.id, user=user, auth_type=AuthTokenType.USER, ) + prompt = create_test_prompt() + description = 'some description' + message = 'some message' + sentry_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'capture_sentry_message', + ) # act - service._post_template_generation_actions( + service._log( + prompt=prompt, user_description=description, + message=message, ) # assert - inc_template_generations_count_mock.assert_called_once() + sentry_mock.assert_called_once_with( + message=( + f'Error AI generating template ' + f'({user.account.id})' + ), + data={ + 'user_id': user.id, + 'user_email': user.email, + 'account_id': user.account.id, + 'message': message, + 'response': { + 'text': None, + }, + 'request': { + 'user_description': description, + 'prompt': prompt.as_dict(), + }, + }, + level=SentryLogLevel.INFO, + ) + +# === OpenAiService._get_step_data_from_text (3.11) === -def test_get_template_data__ok(mocker): + +def test_oa_get_step_data__ok__has_performer(mocker): + + """Returns dict with performer and condition""" # arrange - user = create_test_user() - prompt = create_test_prompt() - description = 'My lovely business process' - ai_response = ( - '1. Hive inspection|Inspect the beehives to determine which ones ' - 'are ready for honey collection.\n' - '2. Smoke the hive | Use a smoker to calm the bees and make them ' - 'less aggressive.' - ) - get_response_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.OpenAiService._get_response', - return_value=ai_response, + user = create_test_owner() + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, ) - post_template_generation_actions_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.OpenAiService._post_template_generation_actions', + api_name = 'task-abc-123' + mocker.patch( + 'src.processes.services.templates.ai.' + 'create_api_name', + return_value=api_name, ) - template_generation_init_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.AnalyticService.template_generation_init', + + # act + result = service._get_step_data_from_text( + number=1, + name='Step name', + description='Step desc', + prev_task_api_name=None, ) + + # assert + assert result['number'] == 1 + assert result['name'] == 'Step name' + assert result['api_name'] == api_name + assert result['description'] == 'Step desc' + assert result['raw_performers'] == [ + { + 'type': PerformerType.USER, + 'source_id': user.id, + 'label': user.name, + }, + ] + assert len(result['conditions']) == 1 + + +# === _post_template_generation_actions (3.13) === + + +def test_post_tmpl_gen_actions__ok__increments(mocker): + + """Calls inc_template_generations_count""" + + # arrange + user = create_test_owner() service = OpenAiService( ident=user.id, user=user, auth_type=AuthTokenType.USER, ) + mocker.patch.object( + target=( + __import__( + 'src.accounts.services.account', + fromlist=['AccountService'], + ).AccountService + ), + attribute='__init__', + return_value=None, + ) + inc_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'AccountService.inc_template_generations_count', + ) # act - template_data = service.get_template_data(user_description=description) + service._post_template_generation_actions( + user_description='some description', + ) # assert - get_response_mock.assert_called_once_with( - user_description=description, - prompt=prompt, - ) - post_template_generation_actions_mock.assert_called_once_with( - description, - ) - template_generation_init_mock.assert_called_once_with( + inc_mock.assert_called_once_with() + + +# === _get_template_data (3.15) === + + +def test_get_tmpl_data__limit__raises(mocker): + + """Limit exceeded raises exception""" + + # arrange + account = create_test_account() + account.max_ai_templates_generations = 0 + account.ai_templates_generations = 1 + account.save() + user = create_test_owner(account=account) + service = OpenAiService( + ident=user.id, user=user, auth_type=AuthTokenType.USER, - is_superuser=False, - description=description, - success=True, ) - assert template_data['name'] == description - assert template_data['wf_name_template'] is None - assert template_data['description'] == '' - assert template_data['is_active'] is False - assert template_data['finalizable'] is True - assert template_data['is_public'] is False - assert template_data['owners'] == [ - { - 'type': OwnerType.USER, - 'source_id': str(user.id), - 'role': OwnerRole.OWNER, - }, - ] - assert template_data['kickoff'] == { - 'description': '', - 'fields': [], - } - task_1_data = template_data['tasks'][0] - assert task_1_data['number'] == 1 - assert task_1_data['name'] == '1. Hive inspection' - assert task_1_data['api_name'] - assert task_1_data['description'] == ( - 'Inspect the beehives to determine which ones ' - 'are ready for honey collection.' - ) - assert task_1_data['raw_performers'] == [ - { - 'type': PerformerType.USER, - 'source_id': user.id, - 'label': user.name, - }, - ] - assert len(task_1_data['conditions']) == 1 - task_1_condition = task_1_data['conditions'][0] - assert task_1_condition['order'] == 1 - assert task_1_condition['action'] == ConditionAction.START_TASK - assert task_1_condition['api_name'] - assert len(task_1_condition['rules']) == 1 - assert task_1_condition['rules'][0]['api_name'] - assert len(task_1_condition['rules'][0]['predicates']) == 1 - task_1_predicate = task_1_condition['rules'][0]['predicates'][0] - assert task_1_predicate['field_type'] == PredicateType.KICKOFF - assert task_1_predicate['operator'] == PredicateOperator.COMPLETED - assert task_1_predicate['api_name'] - assert task_1_predicate['field'] is None - assert task_1_predicate['value'] is None - - task_2_data = template_data['tasks'][1] - assert task_2_data['number'] == 2 - assert task_2_data['name'] == '2. Smoke the hive' - assert task_2_data['api_name'] - assert task_2_data['description'] == ( - 'Use a smoker to calm the bees and ' - 'make them less aggressive.' - ) - assert task_2_data['raw_performers'] == [ - { - 'type': PerformerType.USER, - 'source_id': user.id, - 'label': user.name, - }, - ] - assert len(task_2_data['conditions']) == 1 - task_2_condition = task_2_data['conditions'][0] - assert task_2_condition['order'] == 1 - assert task_2_condition['action'] == ConditionAction.START_TASK - assert task_2_condition['api_name'] - assert len(task_2_condition['rules']) == 1 - assert task_2_condition['rules'][0]['api_name'] - assert len(task_2_condition['rules'][0]['predicates']) == 1 - task_2_predicate = task_2_condition['rules'][0]['predicates'][0] - assert task_2_predicate['field_type'] == PredicateType.TASK - assert task_2_predicate['operator'] == PredicateOperator.COMPLETED - assert task_2_predicate['api_name'] - assert task_2_predicate['field'] == task_1_data['api_name'] - assert task_2_predicate['value'] is None - - -def test_get_template_data__limit_exceeded__raise_exception(mocker): + # act + with pytest.raises(OpenAiLimitTemplateGenerations): + service._get_template_data( + user_description='some description', + ) + + +def test_get_tmpl_data__tmpl_prompt__ok(mocker): + + """Template prompt exists, JSON path OK""" # arrange - user = create_test_user() - account = user.account - account.ai_templates_generations = 10 - account.max_ai_templates_generations = 10 - account.save( - update_fields=[ - 'ai_templates_generations', - 'max_ai_templates_generations', + user = create_test_owner() + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + create_test_prompt( + target=OpenAIPromptTarget.GET_TEMPLATE, + ) + response_text = json.dumps({ + 'name': 'Test', + 'description': 'desc', + 'tasks': [ + { + 'name': 'Task 1', + 'description': 'do something', + }, ], + 'kickoff': {'fields': []}, + }) + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_json_response', + return_value=response_text, + ) + filled_data = {'name': 'Test', 'tasks': []} + mocker.patch.object( + target=( + __import__( + 'src.processes.services.templates.template', + fromlist=['TemplateService'], + ).TemplateService + ), + attribute='__init__', + return_value=None, + ) + fill_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'TemplateService.fill_template_data', + return_value=filled_data, + ) + post_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._post_template_generation_actions', ) - description = 'My lovely business process' - get_response_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.OpenAiService._get_response', + # act + result = service._get_template_data( + user_description='some description', ) - template_generation_init_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.AnalyticService.template_generation_init', + + # assert + assert result == filled_data + fill_mock.assert_called_once() + post_mock.assert_called_once_with( + 'some description', ) + +def test_get_tmpl_data__no_prompts__defaults(mocker): + + """No prompts, JSON path with defaults""" + + # arrange + user = create_test_owner() service = OpenAiService( ident=user.id, user=user, auth_type=AuthTokenType.USER, ) + response_text = json.dumps({ + 'name': 'Test', + 'description': 'desc', + 'tasks': [ + { + 'name': 'Task 1', + 'description': 'do something', + }, + ], + 'kickoff': {'fields': []}, + }) + get_json_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_json_response', + return_value=response_text, + ) + filled_data = {'name': 'Test', 'tasks': []} + mocker.patch.object( + target=( + __import__( + 'src.processes.services.templates.template', + fromlist=['TemplateService'], + ).TemplateService + ), + attribute='__init__', + return_value=None, + ) + mocker.patch( + 'src.processes.services.templates.ai.' + 'TemplateService.fill_template_data', + return_value=filled_data, + ) + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._post_template_generation_actions', + ) # act - with pytest.raises(OpenAiLimitTemplateGenerations) as ex: - service.get_template_data(user_description=description) + service._get_template_data( + user_description='some description', + ) # assert - get_response_mock.assert_not_called() - assert ex.value.message == messages.MSG_PW_0044 - template_generation_init_mock.assert_called_once_with( + get_json_mock.assert_called_once_with( + user_description='some description', + prompt=None, + ) + + +def test_get_tmpl_data__json_err_prompt__logs(mocker): + + """JSON parse error with prompt logs + raises""" + + # arrange + user = create_test_owner() + service = OpenAiService( + ident=user.id, user=user, auth_type=AuthTokenType.USER, - is_superuser=False, - description=description, - success=False, + ) + template_prompt = create_test_prompt( + target=OpenAIPromptTarget.GET_TEMPLATE, + ) + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_json_response', + return_value='not valid json', + ) + log_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._log', ) + # act + with pytest.raises(OpenAiTemplateStepsNotExist): + service._get_template_data( + user_description='some description', + ) -def test_get_template_data__not_prompt__raise_exception(mocker): + # assert + log_mock.assert_called_once_with( + prompt=template_prompt, + user_description='some description', + message='Failed to parse JSON template response', + response_text='not valid json', + ) + + +def test_get_tmpl_data__json_err_no_prompt__raises(mocker): + + """JSON parse error no prompt raises no log""" # arrange - user = create_test_user() - description = 'My lovely business process' - get_response_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.OpenAiService._get_response', + user = create_test_owner() + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, ) - template_generation_init_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.AnalyticService.template_generation_init', + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_json_response', + return_value='not valid json', + ) + log_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._log', ) + + # act + with pytest.raises(OpenAiTemplateStepsNotExist): + service._get_template_data( + user_description='some description', + ) + + # assert + assert log_mock.call_count == 0 + + +def test_get_tmpl_data__no_tasks_prompt__logs(mocker): + + """Empty tasks with prompt logs + raises""" + + # arrange + user = create_test_owner() service = OpenAiService( ident=user.id, user=user, auth_type=AuthTokenType.USER, ) + template_prompt = create_test_prompt( + target=OpenAIPromptTarget.GET_TEMPLATE, + ) + response_text = json.dumps({ + 'name': 'Test', + 'tasks': [], + 'kickoff': {'fields': []}, + }) + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_json_response', + return_value=response_text, + ) + log_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._log', + ) # act - with pytest.raises(OpenAiStepsPromptNotExist) as ex: - service.get_template_data(user_description=description) + with pytest.raises(OpenAiTemplateStepsNotExist): + service._get_template_data( + user_description='some description', + ) # assert - get_response_mock.assert_not_called() - assert ex.value.message == messages.MSG_PW_0046 - template_generation_init_mock.assert_called_once_with( + log_mock.assert_called_once_with( + prompt=template_prompt, + user_description='some description', + message='Template tasks not found', + response_text=response_text, + ) + + +def test_get_tmpl_data__no_tasks_no_prompt__raises(mocker): + + """Empty tasks no prompt raises no log""" + + # arrange + user = create_test_owner() + service = OpenAiService( + ident=user.id, user=user, auth_type=AuthTokenType.USER, - is_superuser=False, - description=description, - success=False, + ) + response_text = json.dumps({ + 'name': 'Test', + 'tasks': [], + 'kickoff': {'fields': []}, + }) + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_json_response', + return_value=response_text, + ) + log_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._log', ) + # act + with pytest.raises(OpenAiTemplateStepsNotExist): + service._get_template_data( + user_description='some description', + ) + + # assert + assert log_mock.call_count == 0 -def test_get_template_data__not_prompt_message__raise_exception(mocker): + +def test_get_tmpl_data__steps_prompt__ok(mocker): + + """Steps prompt path OK""" # arrange - user = create_test_user() - description = 'My lovely business process' - create_test_prompt(messages_count=0) - get_response_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.OpenAiService._get_response', + user = create_test_owner() + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, ) - template_generation_init_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.AnalyticService.template_generation_init', + create_test_prompt( + target=OpenAIPromptTarget.GET_STEPS, + ) + tasks_data = [ + { + 'number': 1, + 'name': 'Step 1', + 'api_name': 'task-1', + 'description': 'desc 1', + }, + ] + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_response', + return_value='Step 1|desc 1', + ) + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_steps_data_from_text', + return_value=tasks_data, + ) + filled_data = {'name': 'Test', 'tasks': tasks_data} + mocker.patch.object( + target=( + __import__( + 'src.processes.services.templates.template', + fromlist=['TemplateService'], + ).TemplateService + ), + attribute='__init__', + return_value=None, + ) + fill_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'TemplateService.fill_template_data', + return_value=filled_data, + ) + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._post_template_generation_actions', ) + + # act + result = service._get_template_data( + user_description='some description', + ) + + # assert + assert result == filled_data + fill_mock.assert_called_once() + + +def test_get_tmpl_data__steps_empty__logs(mocker): + + """Steps prompt empty steps logs + raises""" + + # arrange + user = create_test_owner() service = OpenAiService( ident=user.id, user=user, auth_type=AuthTokenType.USER, ) + steps_prompt = create_test_prompt( + target=OpenAIPromptTarget.GET_STEPS, + ) + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_response', + return_value='no steps here', + ) + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_steps_data_from_text', + return_value=[], + ) + log_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._log', + ) # act - with pytest.raises(OpenAiStepsPromptNotExist) as ex: - service.get_template_data(user_description=description) + with pytest.raises(OpenAiTemplateStepsNotExist): + service._get_template_data( + user_description='some description', + ) # assert - get_response_mock.assert_not_called() - assert ex.value.message == messages.MSG_PW_0046 - template_generation_init_mock.assert_called_once_with( + log_mock.assert_called_once_with( + prompt=steps_prompt, + user_description='some description', + message='Template steps not found', + response_text='no steps here', + ) + + +def test_get_tmpl_data__no_steps_prompt__raises(mocker): + + """No active prompts takes JSON path, empty tasks raises""" + + # arrange + user = create_test_owner() + service = OpenAiService( + ident=user.id, user=user, auth_type=AuthTokenType.USER, - is_superuser=False, - description=description, - success=False, ) + response_text = json.dumps({ + 'name': 'Test', + 'tasks': [], + 'kickoff': {'fields': []}, + }) + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_json_response', + return_value=response_text, + ) + + # act + with pytest.raises(OpenAiTemplateStepsNotExist): + service._get_template_data( + user_description='some description', + ) + +def test_get_tmpl_data__empty_name__defaults(mocker): -def test_get_template_data__not_steps__raise_exception(mocker): + """Empty name defaults to user_description""" # arrange - user = create_test_user() - description = 'My lovely business process' - prompt = create_test_prompt() - response_mock = 'Some response' - get_response_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.OpenAiService._get_response', - return_value=response_mock, + user = create_test_owner() + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, ) - get_tasks_data_from_text_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.OpenAiService._get_steps_data_from_text', - return_value=[], + response_text = json.dumps({ + 'name': '', + 'description': 'desc', + 'tasks': [ + { + 'name': 'Task 1', + 'description': 'do something', + }, + ], + 'kickoff': {'fields': []}, + }) + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_json_response', + return_value=response_text, + ) + mocker.patch.object( + target=( + __import__( + 'src.processes.services.templates.template', + fromlist=['TemplateService'], + ).TemplateService + ), + attribute='__init__', + return_value=None, + ) + captured = {} + + def capture_fill(data): + captured['data'] = data + return {'name': 'desc', 'tasks': []} + + mocker.patch( + 'src.processes.services.templates.ai.' + 'TemplateService.fill_template_data', + side_effect=capture_fill, ) - log_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.OpenAiService._log', + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._post_template_generation_actions', ) - template_generation_init_mock = mocker.patch( - 'src.processes.services.templates.' - 'ai.AnalyticService.template_generation_init', + description = 'my workflow description' + + # act + service._get_template_data( + user_description=description, ) + # assert + assert captured['data']['name'] == description[:200] + + +def test_get_tmpl_data__ok__fills_and_posts(mocker): + + """Calls fill_template_data and post actions""" + + # arrange + user = create_test_owner() service = OpenAiService( ident=user.id, user=user, auth_type=AuthTokenType.USER, ) + response_text = json.dumps({ + 'name': 'Test', + 'description': 'desc', + 'tasks': [ + { + 'name': 'Task 1', + 'description': 'do something', + }, + ], + 'kickoff': {'fields': []}, + }) + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_json_response', + return_value=response_text, + ) + filled_data = {'name': 'Test', 'tasks': []} + mocker.patch.object( + target=( + __import__( + 'src.processes.services.templates.template', + fromlist=['TemplateService'], + ).TemplateService + ), + attribute='__init__', + return_value=None, + ) + fill_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'TemplateService.fill_template_data', + return_value=filled_data, + ) + post_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._post_template_generation_actions', + ) # act - with pytest.raises(OpenAiTemplateStepsNotExist) as ex: - service.get_template_data(user_description=description) + result = service._get_template_data( + user_description='some description', + ) # assert - get_response_mock.assert_called_once_with( - user_description=description, - prompt=prompt, + assert result == filled_data + fill_mock.assert_called_once() + post_mock.assert_called_once_with( + 'some description', ) - get_tasks_data_from_text_mock.assert_called_once_with(response_mock) - assert ex.value.message == messages.MSG_PW_0045 - log_mock.assert_called_once_with( - message='Template steps not found', - user_description=description, - prompt=prompt, - response_text=response_mock, + + +# === get_template_data (3.16) === + + +def test_get_template_data__ok__tracks_analytics(mocker): + + """Success returns data and tracks analytics""" + + # arrange + user = create_test_owner() + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + template_data = {'name': 'Test', 'tasks': []} + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_template_data', + return_value=template_data, + ) + analytics_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'AnalyticService.template_generation_init', + ) + + # act + result = service.get_template_data( + user_description='some description', + ) + + # assert + assert result == template_data + analytics_mock.assert_called_once_with( + user=user, + auth_type=AuthTokenType.USER, + is_superuser=False, + description='some description', + success=True, + ) + + +def test_get_template_data__err__tracks_fail(mocker): + + """Exception tracks analytics with success=False""" + + # arrange + user = create_test_owner() + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_template_data', + side_effect=OpenAiServiceUnavailable(), + ) + analytics_mock = mocker.patch( + 'src.processes.services.templates.ai.' + 'AnalyticService.template_generation_init', ) - template_generation_init_mock.assert_called_once_with( + + # act + with pytest.raises(OpenAiServiceUnavailable): + service.get_template_data( + user_description='some description', + ) + + # assert + analytics_mock.assert_called_once_with( user=user, auth_type=AuthTokenType.USER, is_superuser=False, - description=description, + description='some description', success=False, ) + + +def test_get_template_data__err__reraises(mocker): + + """Exception re-raised after analytics""" + + # arrange + user = create_test_owner() + service = OpenAiService( + ident=user.id, + user=user, + auth_type=AuthTokenType.USER, + ) + mocker.patch( + 'src.processes.services.templates.ai.' + 'OpenAiService._get_template_data', + side_effect=OpenAiTemplateStepsNotExist(), + ) + mocker.patch( + 'src.processes.services.templates.ai.' + 'AnalyticService.template_generation_init', + ) + + # act + with pytest.raises(OpenAiTemplateStepsNotExist): + service.get_template_data( + user_description='some description', + ) diff --git a/docker-compose.src.yml b/docker-compose.src.yml index 20b4eb045..21b5ddffc 100755 --- a/docker-compose.src.yml +++ b/docker-compose.src.yml @@ -118,6 +118,8 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} STORAGE: ${STORAGE:-no} @@ -220,6 +222,8 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} STORAGE: ${STORAGE:-no} @@ -316,6 +320,8 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} STORAGE: ${STORAGE:-no} diff --git a/docker-compose.yml b/docker-compose.yml index edf4b5785..062cb28cd 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -119,6 +119,8 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} STORAGE: ${STORAGE:-no} @@ -221,6 +223,8 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} STORAGE: ${STORAGE:-no} @@ -318,6 +322,8 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} STORAGE: ${STORAGE:-no} diff --git a/frontend/docker-compose.yml b/frontend/docker-compose.yml index 1d573771a..018fb33ec 100755 --- a/frontend/docker-compose.yml +++ b/frontend/docker-compose.yml @@ -120,6 +120,8 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} STORAGE: ${STORAGE:-no} @@ -218,6 +220,8 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} STORAGE: ${STORAGE:-no} @@ -313,6 +317,8 @@ services: EMAIL_TIMEOUT: ${EMAIL_TIMEOUT:-} AI: ${AI:-no} AI_PROVIDER: ${AI_PROVIDER:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_API_ORG: ${OPENAI_API_ORG:-} PUSH: ${PUSH:-no} PUSH_PROVIDER: ${PUSH_PROVIDER:-} STORAGE: ${STORAGE:-no} diff --git a/frontend/src/public/components/TemplateAIModal/TemplateAIModal.css b/frontend/src/public/components/TemplateAIModal/TemplateAIModal.css index 5b9ebe13f..e09843252 100644 --- a/frontend/src/public/components/TemplateAIModal/TemplateAIModal.css +++ b/frontend/src/public/components/TemplateAIModal/TemplateAIModal.css @@ -49,26 +49,29 @@ .form-ai-generate { display: flex; + flex-direction: column; - @media (--mobile) { - display: block; - } - - &__input { - @media (--mobile) { - margin-bottom: 8px; + &__textarea { + width: 100%; + padding: 10px 12px; + font-size: 14px; + line-height: 20px; + font-family: inherit; + border: 1px solid var(--pneumatic-color-black16); + border-radius: 8px; + resize: vertical; + min-height: 80px; + outline: none; + box-sizing: border-box; + + &:focus { + border-color: var(--pneumatic-color-blue); } } &__button { - margin-top: 8px; - width: 100%; - - @media (--desktop) { - margin-top: 0; - margin-left: 8px; - width: initial; - } + margin-top: 12px; + align-self: flex-start; } } diff --git a/frontend/src/public/components/TemplateAIModal/TemplateAIModal.tsx b/frontend/src/public/components/TemplateAIModal/TemplateAIModal.tsx index 718fbb11d..c5c8f9f20 100644 --- a/frontend/src/public/components/TemplateAIModal/TemplateAIModal.tsx +++ b/frontend/src/public/components/TemplateAIModal/TemplateAIModal.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; -import { Button, Header, InputField, Modal } from '../UI'; +import { Button, Header, Modal } from '../UI'; import { TGenerateAITemplatePayload } from '../../redux/actions'; import { ITemplate, ITemplateTask, TAITemplateGenerationStatus } from '../../types/template'; import { useDidUpdateEffect } from '../../hooks/useDidUpdateEffect'; @@ -31,7 +31,7 @@ export function TemplateAIModal({ setTemplateGenerationStatus, }: ITemplateAIModalProps) { const { formatMessage } = useIntl(); - const inputRef = useRef(null); + const inputRef = useRef(null); const [description, setDescription] = useState(''); const [renderedTemplate, setRenderedTemplate] = useState(null); const [heightContainer, setHeightContainer] = useState(0); @@ -163,13 +163,18 @@ export function TemplateAIModal({

{formatMessage({ id: 'ai-template.description' })}

- setDescription(e.currentTarget.value)} - fieldSize="md" - className={styles['form__input']} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.stopPropagation(); + } + }} + className={styles['form-ai-generate__textarea']} placeholder={formatMessage({ id: 'ai-template.input-placeholder' })} - inputRef={inputRef} + ref={inputRef} + rows={4} /> {generationStatus !== 'generating' ? ( diff --git a/start.sh b/start.sh old mode 100644 new mode 100755