From c4dcd6a0d319e2070f86b6297f70afba0b900926 Mon Sep 17 00:00:00 2001 From: Josako Date: Mon, 9 Jun 2025 11:06:36 +0200 Subject: [PATCH] - Add a new 'system' type to dynamic forms, first one defined = 'tenant_make' - Add active field to Specialist model - Improve Specialists view - Propagate make for Role Definition Specialist to Selection Specialist (make is defined at the role level) - Ensure a make with a given name can only be defined once --- common/models/interaction.py | 1 + common/models/user.py | 3 +- .../1.2.0.yaml | 2 +- .../1.3.0.yaml | 50 +++++ .../TRAICIE_SELECTION_SPECIALIST/1.1.0.yaml | 120 +++++++++++ .../templates/interaction/specialists.html | 2 +- eveai_app/views/dynamic_form_base.py | 23 +- eveai_app/views/interaction_forms.py | 4 +- eveai_app/views/interaction_views.py | 4 +- eveai_app/views/user_forms.py | 17 +- eveai_app/views/user_views.py | 11 +- .../TRAICIE_ROLE_DEFINITION_SPECIALIST/1_3.py | 198 ++++++++++++++++++ .../TRAICIE_SELECTION_SPECIALIST/1_1.py | 197 +++++++++++++++++ ...40d16a0965a_make_tenantmake_name_unique.py | 31 +++ ...785e5362_add_active_field_to_specialist.py | 29 +++ 15 files changed, 679 insertions(+), 13 deletions(-) create mode 100644 config/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1.3.0.yaml create mode 100644 config/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1.1.0.yaml create mode 100644 eveai_chat_workers/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1_3.py create mode 100644 eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_1.py create mode 100644 migrations/public/versions/f40d16a0965a_make_tenantmake_name_unique.py create mode 100644 migrations/tenant/versions/a179785e5362_add_active_field_to_specialist.py diff --git a/common/models/interaction.py b/common/models/interaction.py index 9b32495..9c86af5 100644 --- a/common/models/interaction.py +++ b/common/models/interaction.py @@ -29,6 +29,7 @@ class Specialist(db.Model): tuning = db.Column(db.Boolean, nullable=True, default=False) configuration = db.Column(JSONB, nullable=True) arguments = db.Column(JSONB, nullable=True) + active = db.Column(db.Boolean, nullable=True, default=True) # Relationship to retrievers through the association table retrievers = db.relationship('SpecialistRetriever', backref='specialist', lazy=True, diff --git a/common/models/user.py b/common/models/user.py index d4fb683..321bb5b 100644 --- a/common/models/user.py +++ b/common/models/user.py @@ -39,6 +39,7 @@ class Tenant(db.Model): domains = db.relationship('TenantDomain', backref='tenant') licenses = db.relationship('License', back_populates='tenant') license_usages = db.relationship('LicenseUsage', backref='tenant') + makes = db.relationship('TenantMake', backref='tenant') @property def current_license(self): @@ -179,7 +180,7 @@ class TenantMake(db.Model): id = db.Column(db.Integer, primary_key=True) tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False) - name = db.Column(db.String(50), nullable=False) + name = db.Column(db.String(50), nullable=False, unique=True) description = db.Column(db.Text, nullable=True) active = db.Column(db.Boolean, nullable=False, default=True) website = db.Column(db.String(255), nullable=True) diff --git a/config/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1.2.0.yaml b/config/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1.2.0.yaml index 385a676..2541004 100644 --- a/config/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1.2.0.yaml +++ b/config/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1.2.0.yaml @@ -1,4 +1,4 @@ -version: "1.1.0" +version: "1.2.0" name: "Traicie Role Definition Specialist" framework: "crewai" partner: "traicie" diff --git a/config/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1.3.0.yaml b/config/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1.3.0.yaml new file mode 100644 index 0000000..c1e1d4d --- /dev/null +++ b/config/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1.3.0.yaml @@ -0,0 +1,50 @@ +version: "1.3.0" +name: "Traicie Role Definition Specialist" +framework: "crewai" +partner: "traicie" +chat: false +configuration: {} +arguments: + role_name: + name: "Role Name" + description: "The name of the role that is being processed. Will be used to create a selection specialist" + type: "str" + required: true + specialist_name: + name: "Specialist Name" + description: "The name the specialist will be called upon" + type: "str" + required: true + make: + name: "Make" + description: "The make for which the role is defined and the selection specialist is created" + type: "system" + system_name: "tenant_make" + required: true + role_reference: + name: "Role Reference" + description: "A customer reference to the role" + type: "str" + required: false + vacancy_text: + name: "vacancy_text" + type: "text" + description: "The Vacancy Text" + required: true +results: + competencies: + name: "competencies" + type: "List[str, str]" + description: "List of vacancy competencies and their descriptions" + required: false +agents: + - type: "TRAICIE_HR_BP_AGENT" + version: "1.0" +tasks: + - type: "TRAICIE_GET_COMPETENCIES_TASK" + version: "1.1" +metadata: + author: "Josako" + date_added: "2025-05-27" + changes: "Added a make to be specified (a selection specialist now is created in context of a make" + description: "Assistant to create a new Vacancy based on Vacancy Text" \ No newline at end of file diff --git a/config/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1.1.0.yaml b/config/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1.1.0.yaml new file mode 100644 index 0000000..55389ff --- /dev/null +++ b/config/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1.1.0.yaml @@ -0,0 +1,120 @@ +version: "1.1.0" +name: "Traicie Selection Specialist" +framework: "crewai" +partner: "traicie" +chat: false +configuration: + name: + name: "Name" + description: "The name the specialist is called upon." + type: "str" + required: true + role_reference: + name: "Role Reference" + description: "A customer reference to the role" + type: "str" + required: false + make: + name: "Make" + description: "The make for which the role is defined and the selection specialist is created" + type: "system" + system_name: "tenant_make" + required: true + competencies: + name: "Competencies" + description: "An ordered list of competencies." + type: "ordered_list" + list_type: "competency_details" + required: true + tone_of_voice: + name: "Tone of Voice" + description: "The tone of voice the specialist uses to communicate" + type: "enum" + allowed_values: ["Professional & Neutral", "Warm & Empathetic", "Energetic & Enthusiastic", "Accessible & Informal", "Expert & Trustworthy", "No-nonsense & Goal-driven"] + default: "Professional & Neutral" + required: true + language_level: + name: "Language Level" + description: "Language level to be used when communicating, relating to CEFR levels" + type: "enum" + allowed_values: ["Basic", "Standard", "Professional"] + default: "Standard" + required: true + welcome_message: + name: "Welcome Message" + description: "Introductory text given by the specialist - but translated according to Tone of Voice, Language Level and Starting Language" + type: "text" + required: false + closing_message: + name: "Closing Message" + description: "Closing message given by the specialist - but translated according to Tone of Voice, Language Level and Starting Language" + type: "text" + required: false +competency_details: + title: + name: "Title" + description: "Competency Title" + type: "str" + required: true + description: + name: "Description" + description: "Description (in context of the role) of the competency" + type: "text" + required: true + is_knockout: + name: "KO" + description: "Defines if the competency is a knock-out criterium" + type: "boolean" + required: true + default: false + assess: + name: "Assess" + description: "Indication if this competency is to be assessed" + type: "boolean" + required: true + default: true +arguments: + region: + name: "Region" + type: "str" + description: "The region of the specific vacancy" + required: false + working_schedule: + name: "Work Schedule" + type: "str" + description: "The work schedule or employment type of the specific vacancy" + required: false + start_date: + name: "Start Date" + type: "date" + description: "The start date of the specific vacancy" + required: false + language: + name: "Language" + type: "str" + description: "The language (2-letter code) used to start the conversation" + required: true + interaction_mode: + name: "Interaction Mode" + type: "enum" + description: "The interaction mode the specialist will start working in." + allowed_values: ["Job Application", "Seduction"] + default: "Job Application" + required: true +results: + competencies: + name: "competencies" + type: "List[str, str]" + description: "List of vacancy competencies and their descriptions" + required: false +agents: + - type: "TRAICIE_HR_BP_AGENT" + version: "1.0" +tasks: + - type: "TRAICIE_GET_COMPETENCIES_TASK" + version: "1.1" +metadata: + author: "Josako" + date_added: "2025-05-27" + changes: "Add make to the selection specialist" + description: "Assistant to create a new Vacancy based on Vacancy Text" \ No newline at end of file diff --git a/eveai_app/templates/interaction/specialists.html b/eveai_app/templates/interaction/specialists.html index 69eb863..00a38bc 100644 --- a/eveai_app/templates/interaction/specialists.html +++ b/eveai_app/templates/interaction/specialists.html @@ -10,7 +10,7 @@ {% block content %}
- {{ render_selectable_table(headers=["Specialist ID", "Name", "Type"], rows=rows, selectable=True, id="specialistsTable") }} + {{ render_selectable_table(headers=["Specialist ID", "Name", "Type", "Type Version", "Active"], rows=rows, selectable=True, id="specialistsTable") }}
diff --git a/eveai_app/views/dynamic_form_base.py b/eveai_app/views/dynamic_form_base.py index 404ef52..7d41b31 100644 --- a/eveai_app/views/dynamic_form_base.py +++ b/eveai_app/views/dynamic_form_base.py @@ -3,12 +3,14 @@ from datetime import date from flask_wtf import FlaskForm from wtforms import (IntegerField, FloatField, BooleanField, StringField, TextAreaField, FileField, validators, ValidationError) -from flask import current_app, request +from flask import current_app, request, session import json from wtforms.fields.choices import SelectField from wtforms.fields.datetime import DateField from wtforms.fields.simple import ColorField + +from common.models.user import TenantMake from common.utils.config_field_types import TaggingFields, json_to_patterns, patterns_to_json @@ -300,6 +302,22 @@ class DynamicFormBase(FlaskForm): except Exception as e: raise ValidationError(f"Invalid ordered list: {str(e)}") + def _get_system_field(self, system_name): + """Get the field class and kwargs for a system field. Add system field cases as you need them.""" + field_class = None + extra_classes = '' + field_kwargs = {} + match system_name: + case 'tenant_make': + field_class = SelectField + tenant_id = session.get('tenant').get('id') + makes = TenantMake.query.filter_by(tenant_id=tenant_id).all() + choices = [(make.name, make.name) for make in makes] + extra_classes = '' + field_kwargs = {'choices': choices} + + return field_class, extra_classes, field_kwargs + def add_dynamic_fields(self, collection_name, config, initial_data=None): """Add dynamic fields to the form based on the configuration. @@ -357,11 +375,12 @@ class DynamicFormBase(FlaskForm): extra_classes = ['monospace-text', 'pattern-input'] field_kwargs = {} elif field_type == 'ordered_list': - current_app.logger.debug(f"Adding ordered list field for {full_field_name}") field_class = OrderedListField extra_classes = '' list_type = field_def.get('list_type', '') field_kwargs = {'list_type': list_type} + elif field_type == 'system': + field_class, extra_classes, field_kwargs = self._get_system_field(field_def.get('system_name', '')) else: extra_classes = '' field_class = { diff --git a/eveai_app/views/interaction_forms.py b/eveai_app/views/interaction_forms.py index 8186b0b..9c0320d 100644 --- a/eveai_app/views/interaction_forms.py +++ b/eveai_app/views/interaction_forms.py @@ -24,6 +24,7 @@ def get_tools(): class SpecialistForm(FlaskForm): name = StringField('Name', validators=[DataRequired(), Length(max=50)]) + description = TextAreaField('Description', validators=[Optional()]) retrievers = QuerySelectMultipleField( 'Retrievers', @@ -34,7 +35,7 @@ class SpecialistForm(FlaskForm): ) type = SelectField('Specialist Type', validators=[DataRequired()]) - + active = BooleanField('Active', validators=[Optional()], default=True) tuning = BooleanField('Enable Specialist Tuning', default=False) def __init__(self, *args, **kwargs): @@ -47,6 +48,7 @@ class SpecialistForm(FlaskForm): class EditSpecialistForm(DynamicFormBase): name = StringField('Name', validators=[DataRequired()]) description = TextAreaField('Description', validators=[Optional()]) + active = BooleanField('Active', validators=[Optional()], default=True) retrievers = QuerySelectMultipleField( 'Retrievers', diff --git a/eveai_app/views/interaction_views.py b/eveai_app/views/interaction_views.py index dd800e4..97507b4 100644 --- a/eveai_app/views/interaction_views.py +++ b/eveai_app/views/interaction_views.py @@ -162,6 +162,7 @@ def specialist(): new_specialist.type = form.type.data new_specialist.type_version = cache_manager.specialists_version_tree_cache.get_latest_version( new_specialist.type) + new_specialist.active = form.active.data new_specialist.tuning = form.tuning.data set_logging_information(new_specialist, dt.now(tz.utc)) @@ -231,6 +232,7 @@ def edit_specialist(specialist_id): specialist.name = form.name.data specialist.description = form.description.data specialist.tuning = form.tuning.data + specialist.active = form.active.data # Update the configuration dynamic fields specialist.configuration = form.get_dynamic_data("configuration") @@ -297,7 +299,7 @@ def specialists(): # prepare table data rows = prepare_table_for_macro(the_specialists, - [('id', ''), ('name', ''), ('type', '')]) + [('id', ''), ('name', ''), ('type', ''), ('type_version', ''), ('active', ''),]) # Render the catalogs in a template return render_template('interaction/specialists.html', rows=rows, pagination=pagination) diff --git a/eveai_app/views/user_forms.py b/eveai_app/views/user_forms.py index d7b5548..bdcb234 100644 --- a/eveai_app/views/user_forms.py +++ b/eveai_app/views/user_forms.py @@ -2,10 +2,12 @@ from flask import current_app, session from flask_wtf import FlaskForm from wtforms import (StringField, BooleanField, SubmitField, EmailField, IntegerField, DateField, SelectField, SelectMultipleField, FieldList, FormField, TextAreaField) -from wtforms.validators import DataRequired, Length, Email, NumberRange, Optional +from wtforms.validators import DataRequired, Length, Email, NumberRange, Optional, ValidationError import pytz from flask_security import current_user +from wtforms.widgets.core import HiddenInput +from common.models.user import TenantMake from common.services.user import UserServices from config.type_defs.service_types import SERVICE_TYPES from eveai_app.views.dynamic_form_base import DynamicFormBase @@ -132,8 +134,19 @@ class EditTenantProjectForm(FlaskForm): self.services.choices = [(key, value['description']) for key, value in SERVICE_TYPES.items()] +def validate_make_name(form, field): + # Controleer of een TenantMake met deze naam al bestaat + existing_make = TenantMake.query.filter_by(name=field.data).first() + + # Als er een bestaande make is gevonden en we zijn niet in edit mode, + # of als we wel in edit mode zijn maar het is een ander record (andere id) + if existing_make and (not hasattr(form, 'id') or form.id.data != existing_make.id): + raise ValidationError(f'A Make with name "{field.data}" already exists. Choose another name.') + + class TenantMakeForm(DynamicFormBase): - name = StringField('Name', validators=[DataRequired(), Length(max=50)]) + id = IntegerField('ID', widget=HiddenInput()) + name = StringField('Name', validators=[DataRequired(), Length(max=50), validate_make_name]) description = TextAreaField('Description', validators=[Optional()]) active = BooleanField('Active', validators=[Optional()], default=True) website = StringField('Website', validators=[DataRequired(), Length(max=255)]) diff --git a/eveai_app/views/user_views.py b/eveai_app/views/user_views.py index 8192d2b..3618659 100644 --- a/eveai_app/views/user_views.py +++ b/eveai_app/views/user_views.py @@ -1,3 +1,4 @@ +import json import uuid from datetime import datetime as dt, timezone as tz from flask import request, redirect, flash, render_template, Blueprint, session, current_app @@ -627,15 +628,17 @@ def delete_tenant_project(tenant_project_id): @roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') def tenant_make(): form = TenantMakeForm() + customisation_config = cache_manager.customisations_config_cache.get_config("CHAT_CLIENT_CUSTOMISATION") + default_customisation_options = create_default_config_from_type_config(customisation_config["configuration"]) + form.add_dynamic_fields("configuration", customisation_config, default_customisation_options) + if form.validate_on_submit(): tenant_id = session['tenant']['id'] new_tenant_make = TenantMake() form.populate_obj(new_tenant_make) new_tenant_make.tenant_id = tenant_id - customisation_config = cache_manager.customisations_config_cache.get_config("CHAT_CLIENT_CUSTOMISATION") - new_tenant_make.chat_customisation_options = create_default_config_from_type_config( - customisation_config["configuration"]) - form.add_dynamic_fields("configuration", customisation_config, new_tenant_make.chat_customisation_options) + customisation_options = form.get_dynamic_data("configuration") + new_tenant_make.chat_customisation_options = json.dumps(customisation_options) set_logging_information(new_tenant_make, dt.now(tz.utc)) try: diff --git a/eveai_chat_workers/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1_3.py b/eveai_chat_workers/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1_3.py new file mode 100644 index 0000000..5b14b96 --- /dev/null +++ b/eveai_chat_workers/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1_3.py @@ -0,0 +1,198 @@ +import asyncio +import json +from os import wait +from typing import Optional, List + +from crewai.flow.flow import start, listen, and_ +from flask import current_app +from pydantic import BaseModel, Field +from sqlalchemy.exc import SQLAlchemyError + +from common.extensions import db +from common.models.user import Tenant +from common.models.interaction import Specialist +from eveai_chat_workers.outputs.globals.basic_types.list_item import ListItem +from eveai_chat_workers.specialists.crewai_base_specialist import CrewAIBaseSpecialistExecutor +from eveai_chat_workers.specialists.specialist_typing import SpecialistResult, SpecialistArguments +from eveai_chat_workers.outputs.traicie.competencies.competencies_v1_1 import Competencies +from eveai_chat_workers.specialists.crewai_base_classes import EveAICrewAICrew, EveAICrewAIFlow, EveAIFlowState +from common.services.interaction.specialist_services import SpecialistServices + + +class SpecialistExecutor(CrewAIBaseSpecialistExecutor): + """ + type: TRAICIE_ROLE_DEFINITION_SPECIALIST + type_version: 1.0 + Traicie Role Definition Specialist Executor class + """ + + def __init__(self, tenant_id, specialist_id, session_id, task_id, **kwargs): + self.role_definition_crew = None + + super().__init__(tenant_id, specialist_id, session_id, task_id) + + # Load the Tenant & set language + self.tenant = Tenant.query.get_or_404(tenant_id) + + @property + def type(self) -> str: + return "TRAICIE_ROLE_DEFINITION_SPECIALIST" + + @property + def type_version(self) -> str: + return "1.3" + + def _config_task_agents(self): + self._add_task_agent("traicie_get_competencies_task", "traicie_hr_bp_agent") + + def _config_pydantic_outputs(self): + self._add_pydantic_output("traicie_get_competencies_task", Competencies, "competencies") + + def _instantiate_specialist(self): + verbose = self.tuning + + role_definition_agents = [self.traicie_hr_bp_agent] + role_definition_tasks = [self.traicie_get_competencies_task] + self.role_definition_crew = EveAICrewAICrew( + self, + "Role Definition Crew", + agents=role_definition_agents, + tasks=role_definition_tasks, + verbose=verbose, + ) + + self.flow = RoleDefinitionFlow( + self, + self.role_definition_crew + ) + + def execute(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult: + self.log_tuning("Traicie Role Definition Specialist execution started", {}) + + flow_inputs = { + "vacancy_text": arguments.vacancy_text, + "role_name": arguments.role_name, + 'role_reference': arguments.role_reference, + } + + flow_results = self.flow.kickoff(inputs=flow_inputs) + + flow_state = self.flow.state + + results = RoleDefinitionSpecialistResult.create_for_type(self.type, self.type_version) + if flow_state.competencies: + results.competencies = flow_state.competencies + + self.create_selection_specialist(arguments, flow_state.competencies) + + self.log_tuning(f"Traicie Role Definition Specialist execution ended", {"Results": results.model_dump()}) + + return results + + def create_selection_specialist(self, arguments: SpecialistArguments, competencies: List[ListItem]): + """This method creates a new TRAICIE_SELECTION_SPECIALIST specialist with the given competencies.""" + current_app.logger.info(f"Creating selection with arguments: {arguments.model_dump()}") + selection_comptencies = [] + for competency in competencies: + selection_competency = { + "title": competency.title, + "description": competency.description, + "assess": True, + "is_knockout": False, + } + selection_comptencies.append(selection_competency) + + selection_config = { + "name": arguments.specialist_name, + "competencies": selection_comptencies, + "tone_of_voice": "Professional & Neutral", + "language_level": "Standard", + "role_reference": arguments.role_reference, + "make": arguments.make, + } + name = arguments.role_name + if len(name) > 50: + name = name[:47] + "..." + + new_specialist = Specialist( + name=name, + description=f"Specialist for {arguments.role_name} role", + type="TRAICIE_SELECTION_SPECIALIST", + type_version="1.1", + tuning=False, + configuration=selection_config, + ) + try: + db.session.add(new_specialist) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + current_app.logger.error(f"Error creating selection specialist: {str(e)}") + raise e + + SpecialistServices.initialize_specialist(new_specialist.id, "TRAICIE_SELECTION_SPECIALIST", "1.0") + + + + +class RoleDefinitionSpecialistInput(BaseModel): + role_name: str = Field(..., alias="role_name") + role_reference: Optional[str] = Field(..., alias="role_reference") + vacancy_text: Optional[str] = Field(None, alias="vacancy_text") + + +class RoleDefinitionSpecialistResult(SpecialistResult): + competencies: Optional[List[ListItem]] = None + + +class RoleDefFlowState(EveAIFlowState): + """Flow state for Traicie Role Definition specialist that automatically updates from task outputs""" + input: Optional[RoleDefinitionSpecialistInput] = None + competencies: Optional[List[ListItem]] = None + + +class RoleDefinitionFlow(EveAICrewAIFlow[RoleDefFlowState]): + def __init__(self, + specialist_executor: CrewAIBaseSpecialistExecutor, + role_definitiion_crew: EveAICrewAICrew, + **kwargs): + super().__init__(specialist_executor, "Traicie Role Definition Specialist Flow", **kwargs) + self.specialist_executor = specialist_executor + self.role_definition_crew = role_definitiion_crew + self.exception_raised = False + + @start() + def process_inputs(self): + return "" + + @listen(process_inputs) + async def execute_role_definition (self): + inputs = self.state.input.model_dump() + try: + current_app.logger.debug("In execute_role_definition") + crew_output = await self.role_definition_crew.kickoff_async(inputs=inputs) + # Unfortunately, crew_output will only contain the output of the latest task. + # As we will only take into account the flow state, we need to ensure both competencies and criteria + # are copies to the flow state. + update = {} + for task in self.role_definition_crew.tasks: + current_app.logger.debug(f"Task {task.name} output:\n{task.output}") + if task.name == "traicie_get_competencies_task": + # update["competencies"] = task.output.pydantic.competencies + self.state.competencies = task.output.pydantic.competencies + # crew_output.pydantic = crew_output.pydantic.model_copy(update=update) + current_app.logger.debug(f"State after execute_role_definition: {self.state}") + current_app.logger.debug(f"State dump after execute_role_definition: {self.state.model_dump()}") + return crew_output + except Exception as e: + current_app.logger.error(f"CREW execute_role_definition Kickoff Error: {str(e)}") + self.exception_raised = True + raise e + + async def kickoff_async(self, inputs=None): + current_app.logger.debug(f"Async kickoff {self.name}") + current_app.logger.debug(f"Inputs: {inputs}") + self.state.input = RoleDefinitionSpecialistInput.model_validate(inputs) + current_app.logger.debug(f"State: {self.state}") + result = await super().kickoff_async(inputs) + return self.state diff --git a/eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_1.py b/eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_1.py new file mode 100644 index 0000000..b7b8076 --- /dev/null +++ b/eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_1.py @@ -0,0 +1,197 @@ +import asyncio +import json +from os import wait +from typing import Optional, List + +from crewai.flow.flow import start, listen, and_ +from flask import current_app +from pydantic import BaseModel, Field +from sqlalchemy.exc import SQLAlchemyError + +from common.extensions import db +from common.models.user import Tenant +from common.models.interaction import Specialist +from eveai_chat_workers.outputs.globals.basic_types.list_item import ListItem +from eveai_chat_workers.specialists.crewai_base_specialist import CrewAIBaseSpecialistExecutor +from eveai_chat_workers.specialists.specialist_typing import SpecialistResult, SpecialistArguments +from eveai_chat_workers.outputs.traicie.competencies.competencies_v1_1 import Competencies +from eveai_chat_workers.specialists.crewai_base_classes import EveAICrewAICrew, EveAICrewAIFlow, EveAIFlowState +from common.services.interaction.specialist_services import SpecialistServices + + +class SpecialistExecutor(CrewAIBaseSpecialistExecutor): + """ + type: TRAICIE_SELECTION_SPECIALIST + type_version: 1.0 + Traicie Selection Specialist Executor class + """ + + def __init__(self, tenant_id, specialist_id, session_id, task_id, **kwargs): + self.role_definition_crew = None + + super().__init__(tenant_id, specialist_id, session_id, task_id) + + # Load the Tenant & set language + self.tenant = Tenant.query.get_or_404(tenant_id) + + @property + def type(self) -> str: + return "TRAICIE_SELECTION_SPECIALIST" + + @property + def type_version(self) -> str: + return "1.0" + + def _config_task_agents(self): + self._add_task_agent("traicie_get_competencies_task", "traicie_hr_bp_agent") + + def _config_pydantic_outputs(self): + self._add_pydantic_output("traicie_get_competencies_task", Competencies, "competencies") + + def _instantiate_specialist(self): + verbose = self.tuning + + role_definition_agents = [self.traicie_hr_bp_agent] + role_definition_tasks = [self.traicie_get_competencies_task] + self.role_definition_crew = EveAICrewAICrew( + self, + "Role Definition Crew", + agents=role_definition_agents, + tasks=role_definition_tasks, + verbose=verbose, + ) + + self.flow = RoleDefinitionFlow( + self, + self.role_definition_crew + ) + + def execute(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult: + self.log_tuning("Traicie Role Definition Specialist execution started", {}) + + flow_inputs = { + "vacancy_text": arguments.vacancy_text, + "role_name": arguments.role_name, + 'role_reference': arguments.role_reference, + } + + flow_results = self.flow.kickoff(inputs=flow_inputs) + + flow_state = self.flow.state + + results = RoleDefinitionSpecialistResult.create_for_type(self.type, self.type_version) + if flow_state.competencies: + results.competencies = flow_state.competencies + + self.create_selection_specialist(arguments, flow_state.competencies) + + self.log_tuning(f"Traicie Role Definition Specialist execution ended", {"Results": results.model_dump()}) + + return results + + def create_selection_specialist(self, arguments: SpecialistArguments, competencies: List[ListItem]): + """This method creates a new TRAICIE_SELECTION_SPECIALIST specialist with the given competencies.""" + current_app.logger.info(f"Creating selection with arguments: {arguments.model_dump()}") + selection_comptencies = [] + for competency in competencies: + selection_competency = { + "title": competency.title, + "description": competency.description, + "assess": True, + "is_knockout": False, + } + selection_comptencies.append(selection_competency) + + selection_config = { + "name": arguments.specialist_name, + "competencies": selection_comptencies, + "tone_of_voice": "Professional & Neutral", + "language_level": "Standard", + "role_reference": arguments.role_reference, + } + name = arguments.role_name + if len(name) > 50: + name = name[:47] + "..." + + new_specialist = Specialist( + name=name, + description=f"Specialist for {arguments.role_name} role", + type="TRAICIE_SELECTION_SPECIALIST", + type_version="1.0", + tuning=False, + configuration=selection_config, + ) + try: + db.session.add(new_specialist) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + current_app.logger.error(f"Error creating selection specialist: {str(e)}") + raise e + + SpecialistServices.initialize_specialist(new_specialist.id, "TRAICIE_SELECTION_SPECIALIST", "1.0") + + + + +class RoleDefinitionSpecialistInput(BaseModel): + role_name: str = Field(..., alias="role_name") + role_reference: Optional[str] = Field(..., alias="role_reference") + vacancy_text: Optional[str] = Field(None, alias="vacancy_text") + + +class RoleDefinitionSpecialistResult(SpecialistResult): + competencies: Optional[List[ListItem]] = None + + +class RoleDefFlowState(EveAIFlowState): + """Flow state for Traicie Role Definition specialist that automatically updates from task outputs""" + input: Optional[RoleDefinitionSpecialistInput] = None + competencies: Optional[List[ListItem]] = None + + +class RoleDefinitionFlow(EveAICrewAIFlow[RoleDefFlowState]): + def __init__(self, + specialist_executor: CrewAIBaseSpecialistExecutor, + role_definitiion_crew: EveAICrewAICrew, + **kwargs): + super().__init__(specialist_executor, "Traicie Role Definition Specialist Flow", **kwargs) + self.specialist_executor = specialist_executor + self.role_definition_crew = role_definitiion_crew + self.exception_raised = False + + @start() + def process_inputs(self): + return "" + + @listen(process_inputs) + async def execute_role_definition (self): + inputs = self.state.input.model_dump() + try: + current_app.logger.debug("In execute_role_definition") + crew_output = await self.role_definition_crew.kickoff_async(inputs=inputs) + # Unfortunately, crew_output will only contain the output of the latest task. + # As we will only take into account the flow state, we need to ensure both competencies and criteria + # are copies to the flow state. + update = {} + for task in self.role_definition_crew.tasks: + current_app.logger.debug(f"Task {task.name} output:\n{task.output}") + if task.name == "traicie_get_competencies_task": + # update["competencies"] = task.output.pydantic.competencies + self.state.competencies = task.output.pydantic.competencies + # crew_output.pydantic = crew_output.pydantic.model_copy(update=update) + current_app.logger.debug(f"State after execute_role_definition: {self.state}") + current_app.logger.debug(f"State dump after execute_role_definition: {self.state.model_dump()}") + return crew_output + except Exception as e: + current_app.logger.error(f"CREW execute_role_definition Kickoff Error: {str(e)}") + self.exception_raised = True + raise e + + async def kickoff_async(self, inputs=None): + current_app.logger.debug(f"Async kickoff {self.name}") + current_app.logger.debug(f"Inputs: {inputs}") + self.state.input = RoleDefinitionSpecialistInput.model_validate(inputs) + current_app.logger.debug(f"State: {self.state}") + result = await super().kickoff_async(inputs) + return self.state diff --git a/migrations/public/versions/f40d16a0965a_make_tenantmake_name_unique.py b/migrations/public/versions/f40d16a0965a_make_tenantmake_name_unique.py new file mode 100644 index 0000000..007352a --- /dev/null +++ b/migrations/public/versions/f40d16a0965a_make_tenantmake_name_unique.py @@ -0,0 +1,31 @@ +"""Make TenantMake name unique + +Revision ID: f40d16a0965a +Revises: 200bda7f5251 +Create Date: 2025-06-09 06:15:56.791634 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f40d16a0965a' +down_revision = '200bda7f5251' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tenant_make', schema=None) as batch_op: + batch_op.create_unique_constraint(None, ['name']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tenant_make', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='unique') + + # ### end Alembic commands ### diff --git a/migrations/tenant/versions/a179785e5362_add_active_field_to_specialist.py b/migrations/tenant/versions/a179785e5362_add_active_field_to_specialist.py new file mode 100644 index 0000000..141b634 --- /dev/null +++ b/migrations/tenant/versions/a179785e5362_add_active_field_to_specialist.py @@ -0,0 +1,29 @@ +"""Add active field to Specialist + +Revision ID: a179785e5362 +Revises: c71facc0ce7e +Create Date: 2025-06-09 08:30:18.532600 + +""" +from alembic import op +import sqlalchemy as sa +import pgvector +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'a179785e5362' +down_revision = 'c71facc0ce7e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('specialist', sa.Column('active', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('specialist', 'active') + # ### end Alembic commands ###