- Introduction of TRACIE_KO_INTERVIEW_DEFINITION_SPECIALIST

- Re-introduction of EveAIAsset
- Make translation services resistent for situation with and without current_event defined.
- Ensure first question is asked in eveai_chat_client
- Start of version 1.4.0 of TRAICIE_SELECTION_SPECIALIST
This commit is contained in:
Josako
2025-07-02 16:58:43 +02:00
parent fbc9f44ac8
commit 51d029d960
34 changed files with 1292 additions and 302 deletions

View File

@@ -67,25 +67,23 @@ class EveAIAsset(db.Model):
description = db.Column(db.Text, nullable=True)
type = db.Column(db.String(50), nullable=False, default="DOCUMENT_TEMPLATE")
type_version = db.Column(db.String(20), nullable=True, default="1.0.0")
valid_from = db.Column(db.DateTime, nullable=True)
valid_to = db.Column(db.DateTime, nullable=True)
# Versioning Information
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
created_by = db.Column(db.Integer, db.ForeignKey(User.id), nullable=True)
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now())
updated_by = db.Column(db.Integer, db.ForeignKey(User.id))
# Relations
versions = db.relationship('EveAIAssetVersion', backref='asset', lazy=True)
class EveAIAssetVersion(db.Model):
id = db.Column(db.Integer, primary_key=True)
asset_id = db.Column(db.Integer, db.ForeignKey(EveAIAsset.id), nullable=False)
# Storage information
bucket_name = db.Column(db.String(255), nullable=True)
object_name = db.Column(db.String(200), nullable=True)
file_type = db.Column(db.String(20), nullable=True)
file_size = db.Column(db.Float, nullable=True)
# Metadata information
user_metadata = db.Column(JSONB, nullable=True)
system_metadata = db.Column(JSONB, nullable=True)
# Configuration information
configuration = db.Column(JSONB, nullable=True)
arguments = db.Column(JSONB, nullable=True)
# Cost information
prompt_tokens = db.Column(db.Integer, nullable=True)
completion_tokens = db.Column(db.Integer, nullable=True)
# Versioning Information
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
@@ -93,25 +91,7 @@ class EveAIAssetVersion(db.Model):
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now())
updated_by = db.Column(db.Integer, db.ForeignKey(User.id))
# Relations
instructions = db.relationship('EveAIAssetInstruction', backref='asset_version', lazy=True)
class EveAIAssetInstruction(db.Model):
id = db.Column(db.Integer, primary_key=True)
asset_version_id = db.Column(db.Integer, db.ForeignKey(EveAIAssetVersion.id), nullable=False)
name = db.Column(db.String(255), nullable=False)
content = db.Column(db.Text, nullable=True)
class EveAIProcessedAsset(db.Model):
id = db.Column(db.Integer, primary_key=True)
asset_version_id = db.Column(db.Integer, db.ForeignKey(EveAIAssetVersion.id), nullable=False)
specialist_id = db.Column(db.Integer, db.ForeignKey(Specialist.id), nullable=True)
chat_session_id = db.Column(db.Integer, db.ForeignKey(ChatSession.id), nullable=True)
bucket_name = db.Column(db.String(255), nullable=True)
object_name = db.Column(db.String(255), nullable=True)
created_at = db.Column(db.DateTime, nullable=True, server_default=db.func.now())
last_used_at = db.Column(db.DateTime, nullable=True)
class EveAIAgent(db.Model):

View File

@@ -0,0 +1,9 @@
from common.models.interaction import EveAIAsset
from common.extensions import minio_client
class AssetServices:
@staticmethod
def add_or_replace_asset_file(asset_id, file_data):
asset = EveAIAsset.query.get_or_404(asset_id)

View File

@@ -0,0 +1,65 @@
from flask import current_app, session
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from common.utils.business_event import BusinessEvent
from common.utils.business_event_context import current_event
from common.utils.model_utils import get_template
from eveai_chat_workers.outputs.globals.q_a_output.q_a_output_v1_0 import QAOutput
class AnswerCheckServices:
@staticmethod
def check_affirmative_answer(question: str, answer: str, language_iso: str) -> bool:
return AnswerCheckServices._check_answer(question, answer, language_iso, "check_affirmative_answer",
"Check Affirmative Answer")
@staticmethod
def check_additional_information(question: str, answer: str, language_iso: str) -> bool:
return AnswerCheckServices._check_answer(question, answer, language_iso, "check_additional_information",
"Check Additional Information")
@staticmethod
def _check_answer(question: str, answer: str, language_iso: str, template_name: str, span_name: str) -> bool:
if language_iso.strip() == '':
raise ValueError("Language cannot be empty")
language = current_app.config.get('SUPPORTED_LANGUAGE_ISO639_1_LOOKUP').get(language_iso)
if language is None:
raise ValueError(f"Unsupported language: {language_iso}")
if question.strip() == '':
raise ValueError("Question cannot be empty")
if answer.strip() == '':
raise ValueError("Answer cannot be empty")
tenant_id = session.get('tenant').get('id')
if not current_event:
with BusinessEvent('Answer Check Service', tenant_id):
with current_event.create_span(span_name):
return AnswerCheckServices._check_answer_logic(question, answer, language, template_name)
else:
with current_event.create_span('Check Affirmative Answer'):
return AnswerCheckServices._check_answer_logic(question, answer, language, template_name)
@staticmethod
def _check_answer_logic(question: str, answer: str, language: str, template_name: str) -> bool:
prompt_params = {
'question': question,
'answer': answer,
'language': language,
}
template, llm = get_template(template_name)
check_answer_prompt = ChatPromptTemplate.from_template(template)
setup = RunnablePassthrough()
output_schema = QAOutput
structured_llm = llm.with_structured_output(output_schema)
chain = (setup | check_answer_prompt | structured_llm )
raw_answer = chain.invoke(prompt_params)
current_app.logger.debug(f"Raw answer: {raw_answer}")
return raw_answer.answer

View File

@@ -1,5 +1,8 @@
import json
from typing import Dict, Any, Optional
from flask import session
from common.extensions import cache_manager
from common.utils.business_event import BusinessEvent
from common.utils.business_event_context import current_event
@@ -7,11 +10,13 @@ from common.utils.business_event_context import current_event
class TranslationServices:
@staticmethod
def translate_config(config_data: Dict[str, Any], field_config: str, target_language: str, source_language: Optional[str] = None, context: Optional[str] = None) -> Dict[str, Any]:
def translate_config(tenant_id: int, config_data: Dict[str, Any], field_config: str, target_language: str,
source_language: Optional[str] = None, context: Optional[str] = None) -> Dict[str, Any]:
"""
Vertaalt een configuratie op basis van een veld-configuratie.
Args:
tenant_id: Identificatie van de tenant waarvoor we de vertaling doen.
config_data: Een dictionary of JSON (die dan wordt geconverteerd naar een dictionary) met configuratiegegevens
field_config: De naam van een veld-configuratie (bijv. 'fields')
target_language: De taal waarnaar vertaald moet worden
@@ -21,6 +26,26 @@ class TranslationServices:
Returns:
Een dictionary met de vertaalde configuratie
"""
config_type = config_data.get('type', 'Unknown')
config_version = config_data.get('version', 'Unknown')
span_name = f"{config_type}-{config_version}-{field_config}"
if current_event:
with current_event.create_span(span_name):
translated_config = TranslationServices._translate_config(tenant_id, config_data, field_config,
target_language, source_language, context)
return translated_config
else:
with BusinessEvent('Config Translation Service', tenant_id):
with current_event.create_span(span_name):
translated_config = TranslationServices._translate_config(tenant_id, config_data, field_config,
target_language, source_language, context)
return translated_config
@staticmethod
def _translate_config(tenant_id: int, config_data: Dict[str, Any], field_config: str, target_language: str,
source_language: Optional[str] = None, context: Optional[str] = None) -> Dict[str, Any]:
# Zorg ervoor dat we een dictionary hebben
if isinstance(config_data, str):
config_data = json.loads(config_data)
@@ -31,12 +56,7 @@ class TranslationServices:
# Haal type en versie op voor de Business Event span
config_type = config_data.get('type', 'Unknown')
config_version = config_data.get('version', 'Unknown')
span_name = f"{config_type}-{config_version}-{field_config}"
# Start een Business Event context
with BusinessEvent('Config Translation Service', 0):
with current_event.create_span(span_name):
# Controleer of de gevraagde veld-configuratie bestaat
if field_config in config_data:
fields = config_data[field_config]
@@ -99,9 +119,15 @@ class TranslationServices:
return translated_config
@staticmethod
def translate(text: str, target_language: str, source_language: Optional[str] = None,
def translate(tenant_id: int, text: str, target_language: str, source_language: Optional[str] = None,
context: Optional[str] = None)-> str:
with BusinessEvent('Translation Service', 0):
if current_event:
with current_event.create_span('Translation'):
translation_cache = cache_manager.translation_cache.get_translation(text, target_language,
source_language, context)
return translation_cache.translated_text
else:
with BusinessEvent('Translation Service', tenant_id):
with current_event.create_span('Translation'):
translation_cache = cache_manager.translation_cache.get_translation(text, target_language,
source_language, context)

View File

@@ -4,59 +4,9 @@ from flask import current_app
from sqlalchemy.exc import SQLAlchemyError
from common.extensions import cache_manager, minio_client, db
from common.models.interaction import EveAIAsset, EveAIAssetVersion
from common.models.interaction import EveAIAsset
from common.utils.model_logging_utils import set_logging_information
def create_asset_stack(api_input, tenant_id):
type_version = cache_manager.assets_version_tree_cache.get_latest_version(api_input['type'])
api_input['type_version'] = type_version
new_asset = create_asset(api_input, tenant_id)
new_asset_version = create_version_for_asset(new_asset, tenant_id)
db.session.add(new_asset)
db.session.add(new_asset_version)
try:
db.session.commit()
except SQLAlchemyError as e:
current_app.logger.error(f"Could not add asset for tenant {tenant_id}: {str(e)}")
db.session.rollback()
raise e
return new_asset, new_asset_version
def create_asset(api_input, tenant_id):
new_asset = EveAIAsset()
new_asset.name = api_input['name']
new_asset.description = api_input['description']
new_asset.type = api_input['type']
new_asset.type_version = api_input['type_version']
if api_input['valid_from'] and api_input['valid_from'] != '':
new_asset.valid_from = api_input['valid_from']
else:
new_asset.valid_from = dt.now(tz.utc)
new_asset.valid_to = api_input['valid_to']
set_logging_information(new_asset, dt.now(tz.utc))
return new_asset
def create_version_for_asset(asset, tenant_id):
new_asset_version = EveAIAssetVersion()
new_asset_version.asset = asset
new_asset_version.bucket_name = minio_client.create_tenant_bucket(tenant_id)
set_logging_information(new_asset_version, dt.now(tz.utc))
return new_asset_version
def add_asset_version_file(asset_version, field_name, file, tenant_id):
object_name, file_size = minio_client.upload_file(asset_version.bucket_name, asset_version.id, field_name,
file.content_type)
# mark_tenant_storage_dirty(tenant_id)
# TODO - zorg ervoor dat de herberekening van storage onmiddellijk gebeurt!
return object_name

View File

@@ -33,8 +33,8 @@ class MinioClient:
def generate_object_name(self, document_id, language, version_id, filename):
return f"{document_id}/{language}/{version_id}/{filename}"
def generate_asset_name(self, asset_version_id, file_name, content_type):
return f"assets/{asset_version_id}/{file_name}.{content_type}"
def generate_asset_name(self, asset_id, asset_type, content_type):
return f"assets/{asset_type}/{asset_id}.{content_type}"
def upload_document_file(self, tenant_id, document_id, language, version_id, filename, file_data):
bucket_name = self.generate_bucket_name(tenant_id)
@@ -57,8 +57,10 @@ class MinioClient:
except S3Error as err:
raise Exception(f"Error occurred while uploading file: {err}")
def upload_asset_file(self, bucket_name, asset_version_id, file_name, file_type, file_data):
object_name = self.generate_asset_name(asset_version_id, file_name, file_type)
def upload_asset_file(self, tenant_id: int, asset_id: int, asset_type: str, file_type: str,
file_data: bytes | FileStorage | io.BytesIO | str,) -> tuple[str, str, int]:
bucket_name = self.generate_bucket_name(tenant_id)
object_name = self.generate_asset_name(asset_id, asset_type, file_type)
try:
if isinstance(file_data, FileStorage):
@@ -73,7 +75,7 @@ class MinioClient:
self.client.put_object(
bucket_name, object_name, io.BytesIO(file_data), len(file_data)
)
return object_name, len(file_data)
return bucket_name, object_name, len(file_data)
except S3Error as err:
raise Exception(f"Error occurred while uploading asset: {err}")
@@ -84,6 +86,13 @@ class MinioClient:
except S3Error as err:
raise Exception(f"Error occurred while downloading file: {err}")
def download_asset_file(self, tenant_id, bucket_name, object_name):
try:
response = self.client.get_object(bucket_name, object_name)
return response.read()
except S3Error as err:
raise Exception(f"Error occurred while downloading asset: {err}")
def list_document_files(self, tenant_id, document_id, language=None, version_id=None):
bucket_name = self.generate_bucket_name(tenant_id)
prefix = f"{document_id}/"
@@ -105,3 +114,9 @@ class MinioClient:
return True
except S3Error as err:
raise Exception(f"Error occurred while deleting file: {err}")
def delete_object(self, bucket_name, object_name):
try:
self.client.remove_object(bucket_name, object_name)
except S3Error as err:
raise Exception(f"Error occurred while deleting object: {err}")

View File

@@ -0,0 +1,15 @@
version: "1.0.0"
name: "Traicie KO Criteria Questions"
file_type: "yaml"
dynamic: true
configuration:
specialist_id:
name: "Specialist ID"
type: "int"
description: "The Specialist this asset is created for"
required: True
metadata:
author: "Josako"
date_added: "2025-07-01"
description: "Asset that defines a KO Criteria Questions and Answers"
changes: "Initial version"

View File

@@ -0,0 +1,14 @@
version: "1.0.0"
content: >
Check if additional information or questions are available in the answer (answer in {language}), additional to the
following question:
"{question}"
Answer with True or False, without additional information.
llm_model: "mistral.mistral-medium-latest"
metadata:
author: "Josako"
date_added: "2025-06-23"
description: "An assistant to check if the answer to a question is affirmative."
changes: "Initial version"

View File

@@ -0,0 +1,13 @@
version: "1.0.0"
content: >
Determine if there is an affirmative answer on the following question in the provided answer (answer in {language}):
{question}
Answer with True or False, without additional information.
llm_model: "mistral.mistral-medium-latest"
metadata:
author: "Josako"
date_added: "2025-06-23"
description: "An assistant to check if the answer to a question is affirmative."
changes: "Initial version"

View File

@@ -0,0 +1,29 @@
version: "1.1.0"
name: "Traicie KO Criteria Interview Definition Specialist"
framework: "crewai"
partner: "traicie"
chat: false
configuration:
arguments:
specialist_id:
name: "specialist_id"
description: "ID of the specialist for which to define KO Criteria Questions and Asnwers"
type: "integer"
required: true
results:
asset_id:
name: "asset_id"
description: "ID of the Asset containing questions and answers for each of the defined KO Criteria"
type: "integer"
required: true
agents:
- type: "TRAICIE_RECRUITER_AGENT"
version: "1.0"
tasks:
- type: "TRAICIE_KO_CRITERIA_INTERVIEW_DEFINITION_TASK"
version: "1.0"
metadata:
author: "Josako"
date_added: "2025-07-01"
changes: "Initial Version"
description: "Specialist assisting in questions and answers definition for KO Criteria"

View File

@@ -2,7 +2,7 @@ version: "1.0.0"
name: "Traicie Selection Specialist"
framework: "crewai"
partner: "traicie"
chat: false
chat: true
configuration:
name:
name: "Name"
@@ -111,4 +111,4 @@ metadata:
author: "Josako"
date_added: "2025-05-27"
changes: "Updated for unified competencies and ko criteria"
description: "Assistant to create a new Vacancy based on Vacancy Text"
description: "Assistant to assist in candidate selection"

View File

@@ -2,7 +2,7 @@ version: "1.1.0"
name: "Traicie Selection Specialist"
framework: "crewai"
partner: "traicie"
chat: false
chat: true
configuration:
name:
name: "Name"
@@ -117,4 +117,4 @@ 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"
description: "Assistant to assist in candidate selection"

View File

@@ -2,7 +2,7 @@ version: "1.3.0"
name: "Traicie Selection Specialist"
framework: "crewai"
partner: "traicie"
chat: false
chat: true
configuration:
name:
name: "Name"
@@ -117,4 +117,4 @@ metadata:
author: "Josako"
date_added: "2025-06-16"
changes: "Realising the actual interaction with the LLM"
description: "Assistant to create a new Vacancy based on Vacancy Text"
description: "Assistant to assist in candidate selection"

View File

@@ -2,7 +2,7 @@ version: "1.3.0"
name: "Traicie Selection Specialist"
framework: "crewai"
partner: "traicie"
chat: false
chat: true
configuration:
name:
name: "Name"
@@ -117,4 +117,4 @@ metadata:
author: "Josako"
date_added: "2025-06-18"
changes: "Add make to the selection specialist"
description: "Assistant to create a new Vacancy based on Vacancy Text"
description: "Assistant to assist in candidate selection"

View File

@@ -0,0 +1,124 @@
version: "1.4.0"
name: "Traicie Selection Specialist"
framework: "crewai"
partner: "traicie"
chat: true
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_RECRUITER_AGENT"
version: "1.0"
- type: "RAG_AGENT"
version: "1.0"
tasks:
- type: "TRAICIE_KO_CRITERIA_INTERVIEW_DEFINITION_TASK"
version: "1.0"
- type: "RAG_TASK"
version: "1.0"
metadata:
author: "Josako"
date_added: "2025-06-30"
changes: "Add 'RAG' to the selection specialist"
description: "Assistant to assist in candidate selection"

View File

@@ -11,7 +11,7 @@ task_description: >
Apply the following tone of voice in both questions and answers: {tone_of_voice}
Apply the following language level in both questions and answers: {language_level}
Use {language} as language for both questions and answers.
Respect the language of the competencies, and return all output in the same language.
```{competencies}```

View File

@@ -17,7 +17,7 @@ task_description: >
Apply the following language level in both questions and answers: {language_level}, i.e. {language_level_context}
Use {language} as language for both questions and answers.
Use the language used in the competencies as language for your answer / output. We call this the original language.
```{ko_criteria}```
@@ -26,9 +26,9 @@ task_description: >
expected_output: >
For each of the ko criteria, you provide:
- the exact title as specified in the original language
- the question in {language}
- a positive answer, resulting in a positive evaluation of the criterium. In {language}.
- a negative answer, resulting in a negative evaluation of the criterium. In {language}.
- the question in the original language
- a positive answer, resulting in a positive evaluation of the criterium, in the original language.
- a negative answer, resulting in a negative evaluation of the criterium, in the original langauge.
{custom_expected_output}
metadata:
author: "Josako"

View File

@@ -1,5 +1,5 @@
# Agent Types
AGENT_TYPES = {
ASSET_TYPES = {
"DOCUMENT_TEMPLATE": {
"name": "Document Template",
"description": "Asset that defines a template in markdown a specialist can process",
@@ -8,4 +8,9 @@ AGENT_TYPES = {
"name": "Specialist Configuration",
"description": "Asset that defines a specialist configuration",
},
"TRAICIE_KO_CRITERIA_QUESTIONS": {
"name": "Traicie KO Criteria Questions",
"description": "Asset that defines KO Criteria Questions and Answers",
"partner": "traicie"
},
}

View File

@@ -36,4 +36,12 @@ PROMPT_TYPES = {
"name": "translation_without_context",
"description": "An assistant to translate text without context",
},
"check_affirmative_answer": {
"name": "check_affirmative_answer",
"description": "An assistant to check if the answer to a question is affirmative",
},
"check_additional_information": {
"name": "check_additional_information",
"description": "An assistant to check if the answer to a question includes additional information or questions",
},
}

View File

@@ -20,5 +20,9 @@ SPECIALIST_TYPES = {
"TRAICIE_SELECTION_SPECIALIST": {
"name": "Traicie Selection Specialist",
"description": "Recruitment Selection Assistant",
}
},
"TRAICIE_KO_INTERVIEW_DEFINITION_SPECIALIST": {
"name": "Traicie KO Interview Definition Specialist",
"description": "Specialist assisting in questions and answers definition for KO Criteria",
},
}

View File

@@ -0,0 +1,32 @@
# CrewAI Specialist Implementation Guide
## Name Sensitivity
A lot of the required functionality to implement specialists has been automated. This automation is based on naming
conventions. So ... names of variables, attributes, ... needs to be precise, or you'll get problems.
## Base Class: CrewAIBaseSpecialistExecutor
Inherit your SpecialistExecutor class from the base class CrewAIBaseSpecialistExecutor
### Conventions
- tasks are referenced by using the lower case name of the configured task
- agents idem dito
### Specialise the __init__ method
- Define the crews you want to use in your specialist implementation
- Do other initialisations you require
- Call super
### Type and Typeversion properties
- Adapt the type and type_version properties to define the correct specialist. This refers to the actual specialist configuration!
### Implement _config_task_agents
This method links the tasks to the agents that will perform LLM interactions. You can call the method _add_task_agent
to link both.
### Implement _config_pydantic_outputs
This method links the tasks to their pydantic outputs, and their name in the state class.

View File

@@ -102,35 +102,6 @@ class EditEveAIToolForm(BaseEditComponentForm):
pass
class AddEveAIAssetForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
description = TextAreaField('Description', validators=[Optional()])
type = SelectField('Type', validators=[DataRequired()])
valid_from = DateField('Valid From', id='form-control datepicker', validators=[Optional()])
valid_to = DateField('Valid To', id='form-control datepicker', validators=[Optional()])
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
types_dict = cache_manager.assets_types_cache.get_types()
self.type.choices = [(key, value['name']) for key, value in types_dict.items()]
class EditEveAIAssetForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
description = TextAreaField('Description', validators=[Optional()])
type = SelectField('Type', validators=[DataRequired()], render_kw={'readonly': True})
type_version = StringField('Type Version', validators=[DataRequired()], render_kw={'readonly': True})
valid_from = DateField('Valid From', id='form-control datepicker', validators=[Optional()])
valid_to = DateField('Valid To', id='form-control datepicker', validators=[Optional()])
class EditEveAIAssetVersionForm(DynamicFormBase):
asset_name = StringField('Asset Name', validators=[DataRequired()], render_kw={'readonly': True})
asset_type = StringField('Asset Type', validators=[DataRequired()], render_kw={'readonly': True})
asset_type_version = StringField('Asset Type Version', validators=[DataRequired()], render_kw={'readonly': True})
bucket_name = StringField('Bucket Name', validators=[DataRequired()], render_kw={'readonly': True})
class ExecuteSpecialistForm(DynamicFormBase):
id = IntegerField('Specialist ID', validators=[DataRequired()], render_kw={'readonly': True})
name = StringField('Specialist Name', validators=[DataRequired()], render_kw={'readonly': True})

View File

@@ -14,12 +14,11 @@ from werkzeug.utils import secure_filename
from common.models.document import Embedding, DocumentVersion, Retriever
from common.models.interaction import (ChatSession, Interaction, InteractionEmbedding, Specialist, SpecialistRetriever,
EveAIAgent, EveAITask, EveAITool, EveAIAssetVersion, SpecialistMagicLink)
EveAIAgent, EveAITask, EveAITool, EveAIAsset, SpecialistMagicLink)
from common.extensions import db, cache_manager
from common.models.user import SpecialistMagicLinkTenant
from common.services.interaction.specialist_services import SpecialistServices
from common.utils.asset_utils import create_asset_stack, add_asset_version_file
from common.utils.execution_progress import ExecutionProgressTracker
from common.utils.model_logging_utils import set_logging_information, update_logging_information
@@ -28,7 +27,7 @@ from common.utils.nginx_utils import prefixed_url_for
from common.utils.view_assistants import form_validation_failed, prepare_table_for_macro
from .interaction_forms import (SpecialistForm, EditSpecialistForm, EditEveAIAgentForm, EditEveAITaskForm,
EditEveAIToolForm, AddEveAIAssetForm, EditEveAIAssetVersionForm, ExecuteSpecialistForm,
EditEveAIToolForm, ExecuteSpecialistForm,
SpecialistMagicLinkForm, EditSpecialistMagicLinkForm)
interaction_bp = Blueprint('interaction_bp', __name__, url_prefix='/interaction')
@@ -486,92 +485,7 @@ def handle_tool_selection():
return redirect(prefixed_url_for('interaction_bp.edit_specialist'))
# Routes for Asset management ---------------------------------------------------------------------
@interaction_bp.route('/add_asset', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def add_asset():
form = AddEveAIAssetForm(request.form)
tenant_id = session.get('tenant').get('id')
if form.validate_on_submit():
try:
current_app.logger.info(f"Adding asset for tenant {tenant_id}")
api_input = {
'name': form.name.data,
'description': form.description.data,
'type': form.type.data,
'valid_from': form.valid_from.data,
'valid_to': form.valid_to.data,
}
new_asset, new_asset_version = create_asset_stack(api_input, tenant_id)
return redirect(prefixed_url_for('interaction_bp.edit_asset_version',
asset_version_id=new_asset_version.id))
except Exception as e:
current_app.logger.error(f'Failed to add asset for tenant {tenant_id}: {str(e)}')
flash('An error occurred while adding asset', 'error')
return render_template('interaction/add_asset.html')
@interaction_bp.route('/edit_asset_version/<int:asset_version_id>', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def edit_asset_version(asset_version_id):
asset_version = EveAIAssetVersion.query.get_or_404(asset_version_id)
form = EditEveAIAssetVersionForm(asset_version)
asset_config = cache_manager.assets_config_cache.get_config(asset_version.asset.type,
asset_version.asset.type_version)
form.add_dynamic_fields("configuration", asset_config, asset_version.configuration)
if form.validate_on_submit():
# Update the configuration dynamic fields
configuration = form.get_dynamic_data("configuration")
processed_configuration = {}
tenant_id = session.get('tenant').get('id')
# if files are returned, we will store the file_names in the configuration, and add the file to the appropriate
# bucket, in the appropriate location
for field_name, field_value in configuration.items():
# Handle file field - check if the value is a FileStorage instance
if isinstance(field_value, FileStorage) and field_value.filename:
try:
# Upload file and retrieve object_name for the file
object_name = add_asset_version_file(asset_version, field_name, field_value, tenant_id)
# Store object reference in configuration instead of file content
processed_configuration[field_name] = object_name
except Exception as e:
current_app.logger.error(f"Failed to upload file for asset version {asset_version.id}: {str(e)}")
flash(f"Failed to upload file '{field_value.filename}': {str(e)}", "danger")
return render_template('interaction/edit_asset_version.html', form=form,
asset_version=asset_version)
# Handle normal fields
else:
processed_configuration[field_name] = field_value
# Update the asset version with processed configuration
asset_version.configuration = processed_configuration
# Update logging information
update_logging_information(asset_version, dt.now(tz.utc))
try:
db.session.commit()
flash('Asset uploaded successfully!', 'success')
current_app.logger.info(f'Asset Version {asset_version.id} updated successfully')
return redirect(prefixed_url_for('interaction_bp.assets'))
except SQLAlchemyError as e:
db.session.rollback()
flash(f'Failed to upload asset. Error: {str(e)}', 'danger')
current_app.logger.error(f'Failed to update asset version {asset_version.id}. Error: {str(e)}')
return render_template('interaction/edit_asset_version.html', form=form)
else:
form_validation_failed(request, form)
return render_template('interaction/edit_asset_version.html', form=form)
# Specialist Execution ----------------------------------------------------------------------------
@interaction_bp.route('/execute_specialist/<int:specialist_id>', methods=['GET', 'POST'])
def execute_specialist(specialist_id):
specialist = Specialist.query.get_or_404(specialist_id)
@@ -603,6 +517,7 @@ def execute_specialist(specialist_id):
return render_template('interaction/execute_specialist.html', form=form)
# Interaction Mgmt --------------------------------------------------------------------------------
@interaction_bp.route('/session_interactions_by_session_id/<session_id>', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def session_interactions_by_session_id(session_id):

View File

@@ -121,6 +121,8 @@ export const ChatApp = {
// Load historical messages from server
this.loadHistoricalMessages();
console.log('Nr of messages:', this.allMessages.length);
// Add welcome message if no history
if (this.allMessages.length === 0) {
this.addWelcomeMessage();
@@ -157,12 +159,52 @@ export const ChatApp = {
}
},
addWelcomeMessage() {
async addWelcomeMessage() {
console.log('Sending initialize message to backend');
// Toon typing indicator
this.isTyping = true;
this.isLoading = true;
try {
// Verzamel gegevens voor de API call
const apiData = {
message: 'Initialize',
conversation_id: this.conversationId,
user_id: this.userId,
language: this.currentLanguage
};
const response = await this.callAPI('/api/send_message', apiData);
// Verberg typing indicator
this.isTyping = false;
// Voeg AI response toe met task_id voor tracking
const aiMessage = this.addMessage(
'',
'ai',
'text'
);
// Voeg task_id toe als beschikbaar
if (response.task_id) {
console.log('Monitoring Initialize Task ID: ', response.task_id);
aiMessage.taskId = response.task_id;
}
} catch (error) {
console.error('Error sending initialize message:', error);
this.isTyping = false;
// Voeg standaard welkomstbericht toe als fallback
this.addMessage(
'Hallo! Ik ben je AI assistant. Vraag gerust om een formulier zoals "contactformulier" of "bestelformulier"!',
'ai',
'text'
);
} finally {
this.isLoading = false;
}
},
setupEventListeners() {

View File

@@ -111,14 +111,26 @@ def chat(magic_link_code):
if isinstance(specialist_config, str):
specialist_config = json.loads(specialist_config)
welcome_message = specialist_config.get('welcome_message', 'Hello! How can I help you today?')
# # Send a first 'empty' message to the specialist, in order to receive a starting message
# Database(tenant_id).switch_schema()
# specialist_args = session['magic_link'].get('specialist_args', {})
# specialist_args['question'] = ''
# result = SpecialistServices.execute_specialist(
# tenant_id=tenant_id,
# specialist_id=specialist.id,
# specialist_arguments=specialist_args,
# session_id=session['chat_session_id'],
# user_timezone=specialist_config.get('timezone', 'UTC')
# )
#
# welcome_message = result.get('answer')
return render_template('chat.html',
tenant=tenant,
tenant_make=tenant_make,
specialist=specialist,
customisation=customisation,
messages=[welcome_message],
messages=[],
settings=settings,
config=current_app.config
)

View File

@@ -4,7 +4,7 @@ from flask import Flask
import os
from common.utils.celery_utils import make_celery, init_celery
from common.extensions import db, cache_manager
from common.extensions import db, cache_manager, minio_client
from config.logging_config import configure_logging
from config.config import get_config
@@ -43,6 +43,7 @@ def create_app(config_file=None):
def register_extensions(app):
db.init_app(app)
cache_manager.init_app(app)
minio_client.init_app(app)
def register_cache_handlers(app):

View File

@@ -1,5 +1,6 @@
from pydantic import BaseModel, Field
class ListItem(BaseModel):
title: str = Field(..., description="The title or name of the item")
description: str = Field(..., description="A descriptive explanation of the item")

View File

@@ -0,0 +1,7 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class QAOutput(BaseModel):
answer: bool = Field(None, description="True or False")

View File

@@ -1,6 +1,7 @@
from typing import List, Optional
from pydantic import BaseModel, Field
from eveai_chat_workers.outputs.globals.basic_types.list_item import ListItem
class KOQuestion(BaseModel):
title: str = Field(..., description="The title of the knockout criterium.")
@@ -8,8 +9,37 @@ class KOQuestion(BaseModel):
answer_positive: Optional[str] = Field(None, description="The answer to the question, resulting in a positive outcome.")
answer_negative: Optional[str] = Field(None, description="The answer to the question, resulting in a negative outcome.")
@classmethod
def from_json(cls, json_str: str) -> 'KOQuestion':
"""Deserialize from JSON string"""
return cls.model_validate_json(json_str)
def to_json(self, **kwargs) -> str:
"""Serialize to JSON string"""
return self.model_dump_json(**kwargs)
class KOQuestions(BaseModel):
ko_questions: List[KOQuestion] = Field(
default_factory=list,
description="KO Questions and answers."
)
@classmethod
def from_json(cls, json_str: str) -> 'KOQuestions':
"""Deserialize from JSON string"""
return cls.model_validate_json(json_str)
def to_json(self, **kwargs) -> str:
"""Serialize to JSON string"""
return self.model_dump_json(**kwargs)
@classmethod
def from_question_list(cls, questions: List[KOQuestion]) -> 'KOQuestions':
"""Create KOQuestions from a list of KOQuestion objects"""
return cls(ko_questions=questions)
def to_question_list(self) -> List[KOQuestion]:
"""Get the list of KOQuestion objects"""
return self.ko_questions

View File

@@ -0,0 +1,308 @@
from datetime import datetime as dt, timezone as tz
from typing import Optional, List, Dict
import json
import yaml
from crewai.flow.flow import start, listen
from flask import current_app
from pydantic import BaseModel, Field
from common.extensions import db, minio_client
from common.models.interaction import Specialist, EveAIAsset
from common.utils.eveai_exceptions import EveAISpecialistExecutionError
from common.utils.model_logging_utils import set_logging_information
from eveai_chat_workers.definitions.language_level.language_level_v1_0 import LANGUAGE_LEVEL
from eveai_chat_workers.definitions.tone_of_voice.tone_of_voice_v1_0 import TONE_OF_VOICE
from eveai_chat_workers.outputs.traicie.knockout_questions.knockout_questions_v1_0 import KOQuestions, KOQuestion
from eveai_chat_workers.specialists.crewai_base_classes import EveAICrewAICrew, EveAICrewAIFlow, EveAIFlowState
from eveai_chat_workers.specialists.crewai_base_specialist import CrewAIBaseSpecialistExecutor
from eveai_chat_workers.specialists.specialist_typing import SpecialistResult, SpecialistArguments
class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
"""
type: TRAICIE_KO_INTERVIEW_DEFINITION_SPECIALIST
type_version: 1.0
Traicie Selection Specialist Executor class
"""
def __init__(self, tenant_id, specialist_id, session_id, task_id, **kwargs):
self.ko_def_crew = None
super().__init__(tenant_id, specialist_id, session_id, task_id)
@property
def type(self) -> str:
return "TRAICIE_KO_INTERVIEW_DEFINITION_SPECIALIST"
@property
def type_version(self) -> str:
return "1.0"
def _config_task_agents(self):
self._add_task_agent("traicie_ko_criteria_interview_definition_task", "traicie_recruiter_agent")
def _config_pydantic_outputs(self):
self._add_pydantic_output("traicie_ko_criteria_interview_definition_task", KOQuestions, "ko_questions")
def _config_state_result_relations(self):
self._add_state_result_relation("ko_questions")
def _instantiate_specialist(self):
verbose = self.tuning
ko_def_agents = [self.traicie_recruiter_agent]
ko_def_tasks = [self.traicie_ko_criteria_interview_definition_task]
self.ko_def_crew = EveAICrewAICrew(
self,
"KO Criteria Interview Definition Crew",
agents=ko_def_agents,
tasks=ko_def_tasks,
verbose=verbose,
)
self.flow = KOFlow(
self,
self.ko_def_crew
)
def execute(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult:
self.log_tuning("Traicie KO Criteria Interview Definition Specialist execution started", {})
current_app.logger.debug(f"Arguments: {arguments.model_dump()}")
current_app.logger.debug(f"Formatted Context: {formatted_context}")
current_app.logger.debug(f"Formatted History: {self._formatted_history}")
current_app.logger.debug(f"Cached Chat Session: {self._cached_session}")
if not self._cached_session.interactions:
specialist_phase = "initial"
else:
specialist_phase = self._cached_session.interactions[-1].specialist_results.get('phase', 'initial')
results = None
match specialist_phase:
case "initial":
results = self.execute_initial_state(arguments, formatted_context, citations)
self.log_tuning(f"Traicie KO Criteria Interview Definition Specialist execution ended",
{"Results": results.model_dump() if results else "No info"})
return results
def execute_initial_state(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult:
self.log_tuning("Traicie KO Criteria Interview Definition Specialist initial_state_execution started", {})
selection_specialist = Specialist.query.get(arguments.specialist_id)
if not selection_specialist:
raise EveAISpecialistExecutionError(self.tenant_id, self.specialist_id, self.session_id,
"No selection specialist found")
if selection_specialist.type != "TRAICIE_SELECTION_SPECIALIST":
raise EveAISpecialistExecutionError(self.tenant_id, self.specialist_id, self.session_id,
"Specialist is no Selection Specialist")
current_app.logger.debug(f"Specialist Competencies:\n"
f"{selection_specialist.configuration.get("competencies", [])}")
ko_competencies = []
for competency in selection_specialist.configuration.get("competencies", []):
if competency["is_knockout"] is True and competency["assess"] is True:
current_app.logger.debug(f"Assessable Knockout competency: {competency}")
ko_competencies.append({"title": competency["title"], "description": competency["description"]})
tone_of_voice = selection_specialist.configuration.get('tone_of_voice', 'Professional & Neutral')
selected_tone_of_voice = next(
(item for item in TONE_OF_VOICE if item["name"] == tone_of_voice),
None # fallback indien niet gevonden
)
current_app.logger.debug(f"Selected tone of voice: {selected_tone_of_voice}")
tone_of_voice_context = f"{selected_tone_of_voice["description"]}"
language_level = selection_specialist.configuration.get('language_level', 'Standard')
selected_language_level = next(
(item for item in LANGUAGE_LEVEL if item["name"] == language_level),
None
)
current_app.logger.debug(f"Selected language level: {selected_language_level}")
language_level_context = (f"{selected_language_level['description']}, "
f"corresponding to CEFR level {selected_language_level['cefr_level']}")
flow_inputs = {
'tone_of_voice': tone_of_voice,
'tone_of_voice_context': tone_of_voice_context,
'language_level': language_level,
'language_level_context': language_level_context,
'ko_criteria': ko_competencies,
}
flow_results = self.flow.kickoff(inputs=flow_inputs)
current_app.logger.debug(f"Flow results: {flow_results}")
current_app.logger.debug(f"Flow state: {self.flow.state}")
new_type = "TRAICIE_KO_CRITERIA_QUESTIONS"
current_app.logger.debug(f"KO Criteria Questions:\n {self.flow.state.ko_questions}")
# Controleer of we een KOQuestions object hebben of een lijst van KOQuestion objecten
if hasattr(self.flow.state.ko_questions, 'to_json'):
# Het is een KOQuestions object
json_str = self.flow.state.ko_questions.to_json()
elif isinstance(self.flow.state.ko_questions, list):
# Het is een lijst van KOQuestion objecten
# Maak een KOQuestions object en gebruik to_json daarop
ko_questions_obj = KOQuestions.from_question_list(self.flow.state.ko_questions)
json_str = ko_questions_obj.to_json()
else:
# Fallback voor het geval het een onverwacht type is
current_app.logger.warning(f"Unexpected type for ko_questions: {type(self.flow.state.ko_questions)}")
ko_questions_data = [q.model_dump() for q in self.flow.state.ko_questions]
json_str = json.dumps(ko_questions_data, ensure_ascii=False, indent=2)
current_app.logger.debug(f"KO Criteria Questions json style:\n {json_str}")
try:
asset = db.session.query(EveAIAsset).filter(
EveAIAsset.type == new_type,
EveAIAsset.type_version == "1.0.0",
EveAIAsset.configuration.is_not(None),
EveAIAsset.configuration.has_key('specialist_id'),
EveAIAsset.configuration['specialist_id'].astext.cast(db.Integer) == selection_specialist.id
).first()
except (ValueError, TypeError) as e:
current_app.logger.warning(f"Error casting specialist_id in asset configuration: {str(e)}")
asset = None
if not asset:
asset = EveAIAsset(
name=f"KO Criteria Form for specialist {selection_specialist.id}",
type=new_type,
type_version="1.0.0",
system_metadata={
"Creator Specialist Type": self.type,
"Creator Specialist Type Version": self.type_version,
"Creator Specialist ID": self.specialist_id
},
configuration={
"specialist_id": selection_specialist.id,
},
)
set_logging_information(asset, dt.now(tz=tz.utc))
asset.last_used_at = asset.created_at
else:
asset.last_used_at = dt.now(tz=tz.utc)
try:
# Stap 1: Asset aanmaken maar nog niet committen
db.session.add(asset)
db.session.flush() # Geeft ons het ID zonder te committen
# Stap 2: Upload naar MinIO (kan falen zonder database impact)
bucket_name, object_name, file_size = minio_client.upload_asset_file(
tenant_id=self.tenant_id,
asset_id=asset.id,
asset_type=new_type,
file_type="json",
file_data=json_str
)
# Stap 3: Storage metadata toevoegen
asset.bucket_name = bucket_name
asset.object_name = object_name
asset.file_size = file_size
asset.file_type = "json"
# Stap 4: Token usage toevoegen
asset.prompt_tokens = self.ko_def_crew.usage_metrics.prompt_tokens
asset.completion_tokens = self.ko_def_crew.usage_metrics.completion_tokens
# Alles in één keer committen
db.session.commit()
except Exception as e:
current_app.logger.error(f"Error creating asset: {str(e)}")
db.session.rollback()
# Probeer MinIO cleanup als upload is gelukt maar database commit faalde
try:
if 'bucket_name' in locals() and 'object_name' in locals():
minio_client.delete_object(bucket_name, object_name)
except:
pass # Log maar ga door met originele exception
raise EveAISpecialistExecutionError(self.tenant_id, self.specialist_id, self.session_id,
f"Failed to create asset: {str(e)}")
results = SpecialistResult.create_for_type(self.type, self.type_version,
answer=f"asset {asset.id} created for specialist {selection_specialist.id}",
phase="finished",
asset_id=asset.id,
)
return results
class KODefInput(BaseModel):
tone_of_voice: Optional[str] = Field(None, alias="tone_of_voice")
tone_of_voice_context: Optional[str] = Field(None, alias="tone_of_voice_context")
language_level: Optional[str] = Field(None, alias="language_level")
language_level_context: Optional[str] = Field(None, alias="language_level_context")
ko_criteria: Optional[List[Dict[str, str]]] = Field(None, alias="ko_criteria")
class KODefResult(SpecialistResult):
asset_id: Optional[int] = Field(None, alias="asset_id")
class KOFlowState(EveAIFlowState):
"""Flow state for Traicie Role Definition specialist that automatically updates from task outputs"""
input: Optional[KODefInput] = None
ko_questions: Optional[List[KOQuestion]] = Field(None, alias="ko_questions")
phase: Optional[str] = Field(None, alias="phase")
class KOFlow(EveAICrewAIFlow[KOFlowState]):
def __init__(self,
specialist_executor: CrewAIBaseSpecialistExecutor,
ko_def_crew: EveAICrewAICrew,
**kwargs):
super().__init__(specialist_executor, "Traicie KO Interview Definiton Specialist Flow", **kwargs)
self.specialist_executor = specialist_executor
self.ko_def_crew = ko_def_crew
self.exception_raised = False
@start()
def process_inputs(self):
return ""
@listen(process_inputs)
async def execute_ko_def_definition(self):
inputs = self.state.input.model_dump()
try:
current_app.logger.debug("Run execute_ko_interview_definition")
crew_output = await self.ko_def_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.ko_def_crew.tasks:
current_app.logger.debug(f"Task {task.name} output:\n{task.output}")
if task.name == "traicie_ko_criteria_interview_definition_task":
# update["competencies"] = task.output.pydantic.competencies
self.state.ko_questions = task.output.pydantic.ko_questions
# crew_output.pydantic = crew_output.pydantic.model_copy(update=update)
self.state.phase = "personal_contact_data"
current_app.logger.debug(f"State after execute_ko_def_definition: {self.state}")
current_app.logger.debug(f"State dump after execute_ko_def_definition: {self.state.model_dump()}")
return crew_output
except Exception as e:
current_app.logger.error(f"CREW execute_ko_def 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 = KODefInput.model_validate(inputs)
current_app.logger.debug(f"State: {self.state}")
result = await super().kickoff_async(inputs)
return self.state

View File

@@ -181,8 +181,8 @@ class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
answer = f"Let's start our selection process by asking you a few important questions."
if arguments.language != 'en':
TranslationServices.translate_config(ko_form, "fields", arguments.language)
TranslationServices.translate(answer, arguments.language)
TranslationServices.translate_config(self.tenant_id, ko_form, "fields", arguments.language)
TranslationServices.translate(self.tenant_id, answer, arguments.language)
results = SpecialistResult.create_for_type(self.type, self.type_version,
@@ -234,7 +234,8 @@ class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
if arguments.language != 'nl':
answer = TranslationServices.translate(answer, arguments.language)
if arguments.language != 'en':
contact_form = TranslationServices.translate_config(contact_form, "fields", arguments.language)
contact_form = TranslationServices.translate_config(self.tenant_id, contact_form, "fields",
arguments.language)
results = SpecialistResult.create_for_type(self.type, self.type_version,
answer=answer,
form_request=contact_form,

View File

@@ -0,0 +1,370 @@
import asyncio
import json
from os import wait
from typing import Optional, List, Dict, Any
from datetime import date
from time import sleep
from crewai.flow.flow import start, listen, and_
from flask import current_app
from pydantic import BaseModel, Field, EmailStr
from sqlalchemy.exc import SQLAlchemyError
from common.extensions import db
from common.models.user import Tenant
from common.models.interaction import Specialist
from common.services.utils.translation_services import TranslationServices
from eveai_chat_workers.outputs.globals.basic_types.list_item import ListItem
from eveai_chat_workers.outputs.traicie.knockout_questions.knockout_questions_v1_0 import KOQuestions, KOQuestion
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
from common.extensions import cache_manager
from eveai_chat_workers.definitions.language_level.language_level_v1_0 import LANGUAGE_LEVEL
from eveai_chat_workers.definitions.tone_of_voice.tone_of_voice_v1_0 import TONE_OF_VOICE
from common.utils.eveai_exceptions import EveAISpecialistExecutionError
class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
"""
type: TRAICIE_SELECTION_SPECIALIST
type_version: 1.1
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.3"
def _config_task_agents(self):
self._add_task_agent("traicie_ko_criteria_interview_definition_task", "traicie_recruiter_agent")
def _config_pydantic_outputs(self):
self._add_pydantic_output("traicie_ko_criteria_interview_definition_task", KOQuestions, "ko_questions")
def _config_state_result_relations(self):
self._add_state_result_relation("ko_criteria_questions")
self._add_state_result_relation("ko_criteria_scores")
self._add_state_result_relation("competency_questions")
self._add_state_result_relation("competency_scores")
self._add_state_result_relation("personal_contact_data")
def _instantiate_specialist(self):
verbose = self.tuning
ko_def_agents = [self.traicie_recruiter_agent]
ko_def_tasks = [self.traicie_ko_criteria_interview_definition_task]
self.ko_def_crew = EveAICrewAICrew(
self,
"KO Criteria Interview Definition Crew",
agents=ko_def_agents,
tasks=ko_def_tasks,
verbose=verbose,
)
self.flow = SelectionFlow(
self,
self.ko_def_crew
)
def execute(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult:
self.log_tuning("Traicie Selection Specialist execution started", {})
current_app.logger.debug(f"Arguments: {arguments.model_dump()}")
current_app.logger.debug(f"Formatted Context: {formatted_context}")
current_app.logger.debug(f"Formatted History: {self._formatted_history}")
current_app.logger.debug(f"Cached Chat Session: {self._cached_session}")
if not self._cached_session.interactions:
specialist_phase = "initial"
else:
specialist_phase = self._cached_session.interactions[-1].specialist_results.get('phase', 'initial')
results = None
match specialist_phase:
case "initial":
results = self.execute_initial_state(arguments, formatted_context, citations)
case "ko_question_evaluation":
results = self.execute_ko_question_evaluation(arguments, formatted_context, citations)
case "personal_contact_data":
results = self.execute_personal_contact_data(arguments, formatted_context, citations)
case "no_valid_candidate":
results = self.execute_no_valid_candidate(arguments, formatted_context, citations)
case "candidate_selected":
results = self.execute_candidate_selected(arguments, formatted_context, citations)
self.log_tuning(f"Traicie Selection Specialist execution ended", {"Results": results.model_dump() if results else "No info"})
return results
def execute_initial_state(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult:
self.log_tuning("Traicie Selection Specialist initial_state execution started", {})
current_app.logger.debug(f"Specialist Competencies:\n{self.specialist.configuration.get("competencies", [])}")
ko_competencies = []
for competency in self.specialist.configuration.get("competencies", []):
if competency["is_knockout"] is True and competency["assess"] is True:
current_app.logger.debug(f"Assessable Knockout competency: {competency}")
ko_competencies.append({"title: ": competency["title"], "description": competency["description"]})
tone_of_voice = self.specialist.configuration.get('tone_of_voice', 'Professional & Neutral')
selected_tone_of_voice = next(
(item for item in TONE_OF_VOICE if item["name"] == tone_of_voice),
None # fallback indien niet gevonden
)
current_app.logger.debug(f"Selected tone of voice: {selected_tone_of_voice}")
tone_of_voice_context = f"{selected_tone_of_voice["description"]}"
language_level = self.specialist.configuration.get('language_level', 'Standard')
selected_language_level = next(
(item for item in LANGUAGE_LEVEL if item["name"] == language_level),
None
)
current_app.logger.debug(f"Selected language level: {selected_language_level}")
language_level_context = (f"{selected_language_level['description']}, "
f"corresponding to CEFR level {selected_language_level['cefr_level']}")
flow_inputs = {
"region": arguments.region,
"working_schedule": arguments.working_schedule,
"start_date": arguments.start_date,
"language": arguments.language,
"interaction_mode": arguments.interaction_mode,
'tone_of_voice': tone_of_voice,
'tone_of_voice_context': tone_of_voice_context,
'language_level': language_level,
'language_level_context': language_level_context,
'ko_criteria': ko_competencies,
}
flow_results = self.flow.kickoff(inputs=flow_inputs)
current_app.logger.debug(f"Flow results: {flow_results}")
current_app.logger.debug(f"Flow state: {self.flow.state}")
fields = {}
for ko_question in self.flow.state.ko_criteria_questions:
fields[ko_question.title] = {
"name": ko_question.title,
"description": ko_question.title,
"context": ko_question.question,
"type": "options",
"required": True,
"allowed_values": [ko_question.answer_positive, ko_question.answer_negative]
}
ko_form = {
"type": "KO_CRITERIA_FORM",
"version": "1.0.0",
"name": "Starter Questions",
"icon": "verified",
"fields": fields,
}
answer = f"Let's start our selection process by asking you a few important questions."
if arguments.language != 'en':
TranslationServices.translate_config(self.tenant_id, ko_form, "fields", arguments.language)
TranslationServices.translate(self.tenant_id, answer, arguments.language)
results = SpecialistResult.create_for_type(self.type, self.type_version,
answer=answer,
form_request=ko_form,
phase="ko_question_evaluation")
return results
def execute_ko_question_evaluation(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult:
self.log_tuning("Traicie Selection Specialist ko_question_evaluation started", {})
# Check if the form has been returned (it should)
if not arguments.form_values:
raise EveAISpecialistExecutionError(self.tenant_id, self.specialist_id, self.session_id, "No form values returned")
current_app.logger.debug(f"Form values: {arguments.form_values}")
# Load the previous KO Questions
previous_ko_questions = self.flow.state.ko_criteria_questions
current_app.logger.debug(f"Previous KO Questions: {previous_ko_questions}")
# Evaluate KO Criteria
evaluation = "positive"
for criterium, answer in arguments.form_values.items():
for qa in previous_ko_questions:
if qa.get("title") == criterium:
if qa.get("answer_positive") != answer:
evaluation = "negative"
break
if evaluation == "negative":
break
if evaluation == "negative":
answer = (f"We hebben de antwoorden op onze eerste vragen verwerkt. Je voldoet jammer genoeg niet aan de "
f"minimale vereisten voor deze job.")
if arguments.language != 'nl':
answer = TranslationServices.translate(answer, arguments.language)
results = SpecialistResult.create_for_type(self.type, self.type_version,
answer=answer,
form_request=None,
phase="no_valid_candidate")
else:
answer = (f"We hebben de antwoorden op de KO criteria verwerkt. Je bent een geschikte kandidaat. "
f"Ben je bereid je contactgegevens door te geven, zodat we je kunnen contacteren voor een verder "
f"gesprek?")
# Check if answers to questions are positive
contact_form = cache_manager.specialist_forms_config_cache.get_config("PERSONAL_CONTACT_FORM", "1.0")
if arguments.language != 'nl':
answer = TranslationServices.translate(answer, arguments.language)
if arguments.language != 'en':
contact_form = TranslationServices.translate_config(self.tenant_id, contact_form, "fields", arguments.language)
results = SpecialistResult.create_for_type(self.type, self.type_version,
answer=answer,
form_request=contact_form,
phase="personal_contact_data")
return results
def execute_personal_contact_data(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult:
self.log_tuning("Traicie Selection Specialist personal_contact_data started", {})
results = SpecialistResult.create_for_type(self.type, self.type_version,
answer=f"We hebben de contactgegevens verwerkt. We nemen zo snel mogelijk contact met je op.",
phase="candidate_selected")
return results
def execute_no_valid_candidate(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult:
self.log_tuning("Traicie Selection Specialist no_valid_candidate started", {})
results = SpecialistResult.create_for_type(self.type, self.type_version,
answer=f"Je voldoet jammer genoeg niet aan de minimale vereisten voor deze job. Maar solliciteer gerust voor één van onze andere jobs.",
phase="no_valid_candidate")
def execute_candidate_selected(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult:
self.log_tuning("Traicie Selection Specialist candidate_selected started", {})
results = SpecialistResult.create_for_type(self.type, self.type_version,
answer=f"We hebben je contactgegegevens verwerkt. We nemen zo snel mogelijk contact met je op.",
phase="candidate_selected")
return results
class SelectionInput(BaseModel):
region: str = Field(..., alias="region")
working_schedule: Optional[str] = Field(..., alias="working_schedule")
start_date: Optional[date] = Field(None, alias="vacancy_text")
language: Optional[str] = Field(None, alias="language")
interaction_mode: Optional[str] = Field(None, alias="interaction_mode")
tone_of_voice: Optional[str] = Field(None, alias="tone_of_voice")
tone_of_voice_context: Optional[str] = Field(None, alias="tone_of_voice_context")
language_level: Optional[str] = Field(None, alias="language_level")
language_level_context: Optional[str] = Field(None, alias="language_level_context")
ko_criteria: Optional[List[Dict[str, str]]] = Field(None, alias="ko_criteria")
question: Optional[str] = Field(None, alias="question")
field_values: Optional[Dict[str, Any]] = Field(None, alias="field_values")
class SelectionKOCriteriumScore(BaseModel):
criterium: Optional[str] = Field(None, alias="criterium")
answer: Optional[str] = Field(None, alias="answer")
score: Optional[int] = Field(None, alias="score")
class SelectionCompetencyScore(BaseModel):
competency: Optional[str] = Field(None, alias="competency")
answer: Optional[str] = Field(None, alias="answer")
score: Optional[int] = Field(None, alias="score")
class PersonalContactData(BaseModel):
name: str = Field(..., description="Your name", alias="name")
email: EmailStr = Field(..., description="Your Name", alias="email")
phone: str = Field(..., description="Your Phone Number", alias="phone")
address: Optional[str] = Field(None, description="Your Address", alias="address")
zip: Optional[str] = Field(None, description="Postal Code", alias="zip")
city: Optional[str] = Field(None, description="City", alias="city")
country: Optional[str] = Field(None, description="Country", alias="country")
consent: bool = Field(..., description="Consent", alias="consent")
class SelectionResult(SpecialistResult):
ko_criteria_questions: Optional[List[ListItem]] = Field(None, alias="ko_criteria_questions")
ko_criteria_scores: Optional[List[SelectionKOCriteriumScore]] = Field(None, alias="ko_criteria_scores")
competency_questions: Optional[List[ListItem]] = Field(None, alias="competency_questions")
competency_scores: Optional[List[SelectionCompetencyScore]] = Field(None, alias="competency_scores")
personal_contact_data: Optional[PersonalContactData] = Field(None, alias="personal_contact_data")
class SelectionFlowState(EveAIFlowState):
"""Flow state for Traicie Role Definition specialist that automatically updates from task outputs"""
input: Optional[SelectionInput] = None
ko_criteria_questions: Optional[List[KOQuestion]] = Field(None, alias="ko_criteria_questions")
ko_criteria_scores: Optional[List[SelectionKOCriteriumScore]] = Field(None, alias="ko_criteria_scores")
competency_questions: Optional[List[ListItem]] = Field(None, alias="competency_questions")
competency_scores: Optional[List[SelectionCompetencyScore]] = Field(None, alias="competency_scores")
personal_contact_data: Optional[PersonalContactData] = Field(None, alias="personal_contact_data")
phase: Optional[str] = Field(None, alias="phase")
interaction_mode: Optional[str] = Field(None, alias="mode")
class SelectionFlow(EveAICrewAIFlow[SelectionFlowState]):
def __init__(self,
specialist_executor: CrewAIBaseSpecialistExecutor,
ko_def_crew: EveAICrewAICrew,
**kwargs):
super().__init__(specialist_executor, "Traicie Role Definition Specialist Flow", **kwargs)
self.specialist_executor = specialist_executor
self.ko_def_crew = ko_def_crew
self.exception_raised = False
@start()
def process_inputs(self):
return ""
@listen(process_inputs)
async def execute_ko_def_definition(self):
inputs = self.state.input.model_dump()
try:
current_app.logger.debug("execute_ko_interview_definition")
crew_output = await self.ko_def_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.ko_def_crew.tasks:
current_app.logger.debug(f"Task {task.name} output:\n{task.output}")
if task.name == "traicie_ko_criteria_interview_definition_task":
# update["competencies"] = task.output.pydantic.competencies
self.state.ko_criteria_questions = task.output.pydantic.ko_questions
# crew_output.pydantic = crew_output.pydantic.model_copy(update=update)
self.state.phase = "personal_contact_data"
current_app.logger.debug(f"State after execute_ko_def_definition: {self.state}")
current_app.logger.debug(f"State dump after execute_ko_def_definition: {self.state.model_dump()}")
return crew_output
except Exception as e:
current_app.logger.error(f"CREW execute_ko_def 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 = SelectionInput.model_validate(inputs)
current_app.logger.debug(f"State: {self.state}")
result = await super().kickoff_async(inputs)
return self.state

View File

@@ -73,7 +73,7 @@ def get_public_table_names():
return ['role', 'roles_users', 'tenant', 'user', 'tenant_domain','license_tier', 'license', 'license_usage',
'business_event_log', 'tenant_project', 'partner', 'partner_service', 'invoice', 'license_period',
'license_change_log', 'partner_service_license_tier', 'payment', 'partner_tenant', 'tenant_make',
'specialist_magic_link_tenant']
'specialist_magic_link_tenant', 'translation_cache']
PUBLIC_TABLES = get_public_table_names()
logger.info(f"Public tables: {PUBLIC_TABLES}")

View File

@@ -0,0 +1,43 @@
"""EveAIAsset changes, removing EveAIAssetVersion and other models
Revision ID: af3d56001771
Revises: b1647f31339a
Create Date: 2025-07-02 07:58:10.689637
"""
from alembic import op
import sqlalchemy as sa
import pgvector
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'af3d56001771'
down_revision = 'b1647f31339a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('eve_ai_asset_instruction')
op.drop_table('eve_ai_processed_asset')
op.drop_table('eve_ai_asset_version')
op.add_column('eve_ai_asset', sa.Column('bucket_name', sa.String(length=255), nullable=True))
op.add_column('eve_ai_asset', sa.Column('object_name', sa.String(length=200), nullable=True))
op.add_column('eve_ai_asset', sa.Column('file_type', sa.String(length=20), nullable=True))
op.add_column('eve_ai_asset', sa.Column('file_size', sa.Float(), nullable=True))
op.add_column('eve_ai_asset', sa.Column('user_metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
op.add_column('eve_ai_asset', sa.Column('system_metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
op.add_column('eve_ai_asset', sa.Column('configuration', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
op.add_column('eve_ai_asset', sa.Column('prompt_tokens', sa.Integer(), nullable=True))
op.add_column('eve_ai_asset', sa.Column('completion_tokens', sa.Integer(), nullable=True))
op.add_column('eve_ai_asset', sa.Column('last_used_at', sa.DateTime(), nullable=True))
op.drop_column('eve_ai_asset', 'valid_to')
op.drop_column('eve_ai_asset', 'valid_from')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###