6 Commits

Author SHA1 Message Date
Josako
3f77871c4f - Add a default make to the tenant
- Add a make to the SpecialistMagicLink
2025-06-09 18:13:38 +02:00
Josako
199cf94cf2 - Changed label for specialist_name to chatbot name ==> more logical
- Bug in unique name for catalogs
2025-06-09 16:06:41 +02:00
Josako
c4dcd6a0d3 - 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
2025-06-09 11:06:36 +02:00
Josako
43ee9139d6 Changelog for version 2.3.3-alfa 2025-06-07 11:18:05 +02:00
Josako
8f45005713 - Bug fixes:
- Catalog Name Unique Constraint
  - Selection constraint to view processed document
  - remove tab from tenant overview
2025-06-07 11:14:23 +02:00
Josako
bc1626c4ff - Initialisation of the EveAI Chat Client.
- Introduction of Tenant Makes
2025-06-06 16:42:24 +02:00
60 changed files with 2672 additions and 228 deletions

View File

@@ -8,7 +8,7 @@ import sqlalchemy as sa
class Catalog(db.Model):
id = db.Column(db.Integer, primary_key=True)
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)
type = db.Column(db.String(50), nullable=False, default="STANDARD_CATALOG")

View File

@@ -1,7 +1,7 @@
from sqlalchemy.dialects.postgresql import JSONB
from ..extensions import db
from .user import User, Tenant
from .user import User, Tenant, TenantMake
from .document import Embedding, Retriever
@@ -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,
@@ -222,6 +223,7 @@ class SpecialistMagicLink(db.Model):
name = db.Column(db.String(50), nullable=False)
description = db.Column(db.Text, nullable=True)
specialist_id = db.Column(db.Integer, db.ForeignKey(Specialist.id, ondelete='CASCADE'), nullable=False)
tenant_make_id = db.Column(db.Integer, db.ForeignKey(TenantMake.id, ondelete='CASCADE'), nullable=True)
magic_link_code = db.Column(db.String(55), nullable=False, unique=True)
valid_from = db.Column(db.DateTime, nullable=True)

View File

@@ -2,7 +2,7 @@ from datetime import date
from common.extensions import db
from flask_security import UserMixin, RoleMixin
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
import sqlalchemy as sa
from common.models.entitlements import License
@@ -33,12 +33,15 @@ class Tenant(db.Model):
# Entitlements
currency = db.Column(db.String(20), nullable=True)
storage_dirty = db.Column(db.Boolean, nullable=True, default=False)
default_tenant_make_id = db.Column(db.Integer, db.ForeignKey('public.tenant_make.id'), nullable=True)
# Relations
users = db.relationship('User', backref='tenant')
domains = db.relationship('TenantDomain', backref='tenant')
licenses = db.relationship('License', back_populates='tenant')
license_usages = db.relationship('LicenseUsage', backref='tenant')
tenant_makes = db.relationship('TenantMake', backref='tenant', foreign_keys='TenantMake.tenant_id')
default_tenant_make = db.relationship('TenantMake', foreign_keys=[default_tenant_make_id], uselist=False)
@property
def current_license(self):
@@ -62,6 +65,7 @@ class Tenant(db.Model):
'default_language': self.default_language,
'allowed_languages': self.allowed_languages,
'currency': self.currency,
'default_tenant_make_id': self.default_tenant_make_id,
}
@@ -173,6 +177,28 @@ class TenantProject(db.Model):
return f"<TenantProject {self.id}: {self.name}>"
class TenantMake(db.Model):
__bind_key__ = 'public'
__table_args__ = {'schema': 'public'}
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, 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)
logo_url = db.Column(db.String(255), nullable=True)
# Chat customisation options
chat_customisation_options = db.Column(JSONB, 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('public.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('public.user.id'))
class Partner(db.Model):
__bind_key__ = 'public'
__table_args__ = {'schema': 'public'}
@@ -279,5 +305,3 @@ class SpecialistMagicLinkTenant(db.Model):
magic_link_code = db.Column(db.String(55), primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False)

View File

@@ -7,7 +7,7 @@ from flask import current_app
from common.utils.cache.base import CacheHandler, CacheKey
from config.type_defs import agent_types, task_types, tool_types, specialist_types, retriever_types, prompt_types, \
catalog_types, partner_service_types, processor_types
catalog_types, partner_service_types, processor_types, customisation_types
def is_major_minor(version: str) -> bool:
@@ -463,7 +463,6 @@ ProcessorConfigCacheHandler, ProcessorConfigVersionTreeCacheHandler, ProcessorCo
types_module=processor_types.PROCESSOR_TYPES
))
# Add to common/utils/cache/config_cache.py
PartnerServiceConfigCacheHandler, PartnerServiceConfigVersionTreeCacheHandler, PartnerServiceConfigTypesCacheHandler = (
create_config_cache_handlers(
config_type='partner_services',
@@ -471,6 +470,14 @@ PartnerServiceConfigCacheHandler, PartnerServiceConfigVersionTreeCacheHandler, P
types_module=partner_service_types.PARTNER_SERVICE_TYPES
))
CustomisationConfigCacheHandler, CustomisationConfigVersionTreeCacheHandler, CustomisationConfigTypesCacheHandler = (
create_config_cache_handlers(
config_type='customisations',
config_dir='config/customisations',
types_module=customisation_types.CUSTOMISATION_TYPES
)
)
def register_config_cache_handlers(cache_manager) -> None:
cache_manager.register_handler(AgentConfigCacheHandler, 'eveai_config')
@@ -503,6 +510,9 @@ def register_config_cache_handlers(cache_manager) -> None:
cache_manager.register_handler(PartnerServiceConfigCacheHandler, 'eveai_config')
cache_manager.register_handler(PartnerServiceConfigTypesCacheHandler, 'eveai_config')
cache_manager.register_handler(PartnerServiceConfigVersionTreeCacheHandler, 'eveai_config')
cache_manager.register_handler(CustomisationConfigCacheHandler, 'eveai_config')
cache_manager.register_handler(CustomisationConfigTypesCacheHandler, 'eveai_config')
cache_manager.register_handler(CustomisationConfigVersionTreeCacheHandler, 'eveai_config')
cache_manager.agents_config_cache.set_version_tree_cache(cache_manager.agents_version_tree_cache)
cache_manager.tasks_config_cache.set_version_tree_cache(cache_manager.tasks_version_tree_cache)
@@ -513,3 +523,4 @@ def register_config_cache_handlers(cache_manager) -> None:
cache_manager.catalogs_config_cache.set_version_tree_cache(cache_manager.catalogs_version_tree_cache)
cache_manager.processors_config_cache.set_version_tree_cache(cache_manager.processors_version_tree_cache)
cache_manager.partner_services_config_cache.set_version_tree_cache(cache_manager.partner_services_version_tree_cache)
cache_manager.customisations_config_cache.set_version_tree_cache(cache_manager.customisations_version_tree_cache)

View File

@@ -0,0 +1,42 @@
"""
Utility functions for chat customization.
"""
def get_default_chat_customisation(tenant_customisation=None):
"""
Get chat customization options with default values for missing options.
Args:
tenant_customization (dict, optional): The tenant's customization options.
Defaults to None.
Returns:
dict: A dictionary containing all customization options with default values
for any missing options.
"""
# Default customization options
default_customisation = {
'primary_color': '#007bff',
'secondary_color': '#6c757d',
'background_color': '#ffffff',
'text_color': '#212529',
'sidebar_color': '#f8f9fa',
'logo_url': None,
'sidebar_text': None,
'welcome_message': 'Hello! How can I help you today?',
'team_info': []
}
# If no tenant customization is provided, return the defaults
if tenant_customisation is None:
return default_customisation
# Start with the default customization
customisation = default_customisation.copy()
# Update with tenant customization
for key, value in tenant_customisation.items():
if key in customisation:
customisation[key] = value
return customisation

View File

@@ -21,7 +21,7 @@ class TaggingField(BaseModel):
@field_validator('type', mode='before')
@classmethod
def validate_type(cls, v: str) -> str:
valid_types = ['string', 'integer', 'float', 'date', 'enum']
valid_types = ['string', 'integer', 'float', 'date', 'enum', 'color']
if v not in valid_types:
raise ValueError(f'type must be one of {valid_types}')
return v
@@ -243,7 +243,7 @@ class ArgumentDefinition(BaseModel):
@field_validator('type')
@classmethod
def validate_type(cls, v: str) -> str:
valid_types = ['string', 'integer', 'float', 'date', 'enum']
valid_types = ['string', 'integer', 'float', 'date', 'enum', 'color']
if v not in valid_types:
raise ValueError(f'type must be one of {valid_types}')
return v
@@ -256,7 +256,8 @@ class ArgumentDefinition(BaseModel):
'integer': NumericConstraint,
'float': NumericConstraint,
'date': DateConstraint,
'enum': EnumConstraint
'enum': EnumConstraint,
'color': StringConstraint
}
expected_type = expected_constraint_types.get(self.type)

View File

@@ -38,6 +38,8 @@ def create_default_config_from_type_config(type_config):
default_config[field_name] = 0
elif field_type == "boolean":
default_config[field_name] = False
elif field_type == "color":
default_config[field_name] = "#000000"
else:
default_config[field_name] = ""

View File

@@ -0,0 +1,25 @@
version: "1.0.0"
name: "Traicie HR BP "
role: >
You are an Expert Recruiter working for {tenant_name}
{custom_role}
goal: >
As an expert recruiter, you identify, attract, and secure top talent by building genuine relationships, deeply
understanding business needs, and ensuring optimal alignment between candidate potential and organizational goals
, while championing diversity, culture fit, and long-term retention.
{custom_goal}
backstory: >
You started your career in a high-pressure agency setting, where you quickly learned the art of fast-paced hiring and
relationship building. Over the years, you moved in-house, partnering closely with business leaders to shape
recruitment strategies that go beyond filling roles—you focus on finding the right people to drive growth and culture.
With a strong grasp of both tech and non-tech profiles, youve adapted to changing trends, from remote work to
AI-driven sourcing. Youre more than a recruiter—youre a trusted advisor, a brand ambassador, and a connector of
people and purpose.
{custom_backstory}
full_model_name: "mistral.mistral-medium-latest"
temperature: 0.3
metadata:
author: "Josako"
date_added: "2025-05-21"
description: "HR BP Agent."
changes: "Initial version"

View File

@@ -0,0 +1,43 @@
version: "1.0.0"
name: "Chat Client Customisation"
configuration:
"primary_color":
name: "Primary Color"
description: "Primary Color"
type: "color"
required: false
"secondary_color":
name: "Secondary Color"
description: "Secondary Color"
type: "color"
required: false
"background_color":
name: "Background Color"
description: "Background Color"
type: "color"
required: false
"text_color":
name: "Text Color"
description: "Text Color"
type: "color"
required: false
"sidebar_color":
name: "Sidebar Color"
description: "Sidebar Color"
type: "color"
required: false
"sidebar_text":
name: "Sidebar Text"
description: "Text to be shown in the sidebar"
type: "text"
required: false
"welcome_message":
name: "Welcome Message"
description: "Text to be shown as Welcome"
type: "text"
required: false
metadata:
author: "Josako"
date_added: "2024-06-06"
changes: "Initial version"
description: "Parameters allowing to customise the chat client"

View File

@@ -303,10 +303,10 @@ LOGGING = {
'backupCount': 2,
'formatter': 'standard',
},
'file_chat': {
'file_chat_client': {
'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/eveai_chat.log',
'filename': 'logs/eveai_chat_client.log',
'maxBytes': 1024 * 1024 * 1, # 1MB
'backupCount': 2,
'formatter': 'standard',
@@ -432,8 +432,8 @@ LOGGING = {
'level': 'DEBUG',
'propagate': False
},
'eveai_chat': { # logger for the eveai_chat
'handlers': ['file_chat', 'graylog', ] if env == 'production' else ['file_chat', ],
'eveai_chat_client': { # logger for the eveai_chat
'handlers': ['file_chat_client', 'graylog', ] if env == 'production' else ['file_chat_client', ],
'level': 'DEBUG',
'propagate': False
},

View File

@@ -1,4 +1,4 @@
version: "1.1.0"
version: "1.2.0"
name: "Traicie Role Definition Specialist"
framework: "crewai"
partner: "traicie"
@@ -11,9 +11,9 @@ arguments:
type: "str"
required: true
specialist_name:
name: "Specialist Name"
description: "The name the specialist will be called upon"
type: str
name: "Chatbot Name"
description: "The name of the chatbot."
type: "str"
required: true
role_reference:
name: "Role Reference"

View File

@@ -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: "Chatbot Name"
description: "The name of the chatbot."
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"

View File

@@ -88,7 +88,13 @@ arguments:
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"

View File

@@ -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"

View File

@@ -0,0 +1,7 @@
# Catalog Types
CUSTOMISATION_TYPES = {
"CHAT_CLIENT_CUSTOMISATION": {
"name": "Chat Client Customisation",
"description": "Parameters allowing to customise the chat client",
},
}

View File

@@ -5,6 +5,19 @@ All notable changes to EveAI will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.3.3-alfa]
### Added
- Add Tenant Make
- Add Chat Client customisation options to Tenant Make
### Changed
- Catalog name must be unique to avoid mistakes
### Fixed
- Ensure document version is selected in UI before trying to view it.
- Remove obsolete tab from tenant overview
## [2.3.2-alfa]
### Added
@@ -29,18 +42,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Role Definition Specialist creates Selection Specialist from generated competencies
- Improvements to Selection Specialist (Agent definition to be started)
### Deprecated
- For soon-to-be removed features.
### Removed
- For now removed features.
### Fixed
- For any bug fixes.
### Security
- In case of vulnerabilities.
## [2.3.0-alfa]
### Added
@@ -60,7 +61,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Introduction of ChatSession (Specialist Execution) follow-up in administrative interface
- Introduce npm for javascript libraries usage and optimisations
- Introduction of new top bar in administrative interface to show session defaults (removing old navbar buttons)
-
### Changed
- Add 'Register'-button to list views, replacing register menu-items
@@ -118,9 +118,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Set default language when registering Documents or URLs.
### Security
- In case of vulnerabilities.
## [2.1.0-alfa]
### Added

View File

@@ -70,6 +70,7 @@ services:
depends_on:
- eveai_app
- eveai_api
- eveai_chat_client
networks:
- eveai-network
@@ -177,6 +178,44 @@ services:
# networks:
# - eveai-network
eveai_chat_client:
image: josakola/eveai_chat_client:latest
build:
context: ..
dockerfile: ./docker/eveai_chat_client/Dockerfile
platforms:
- linux/amd64
- linux/arm64
ports:
- 5004:5004
expose:
- 8000
environment:
<<: *common-variables
COMPONENT_NAME: eveai_chat_client
volumes:
- ../eveai_chat_client:/app/eveai_chat_client
- ../common:/app/common
- ../config:/app/config
- ../scripts:/app/scripts
- ../patched_packages:/app/patched_packages
- ./eveai_logs:/app/logs
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5004/healthz/ready"]
interval: 30s
timeout: 1s
retries: 3
start_period: 30s
networks:
- eveai-network
eveai_chat_workers:
image: josakola/eveai_chat_workers:latest
build:
@@ -441,4 +480,3 @@ volumes:
#secrets:
# db-password:
# file: ./db/password.txt

View File

@@ -56,6 +56,7 @@ services:
depends_on:
- eveai_app
- eveai_api
- eveai_chat_client
networks:
- eveai-network
restart: "no"
@@ -106,6 +107,33 @@ services:
- eveai-network
restart: "no"
eveai_chat_client:
image: josakola/eveai_chat_client:${EVEAI_VERSION:-latest}
ports:
- 5004:5004
expose:
- 8000
environment:
<<: *common-variables
COMPONENT_NAME: eveai_chat_client
volumes:
- eveai_logs:/app/logs
- crewai_storage:/app/crewai_storage
depends_on:
redis:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5004/healthz/ready"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- eveai-network
restart: "no"
eveai_chat_workers:
image: josakola/eveai_chat_workers:${EVEAI_VERSION:-latest}
expose:

View File

@@ -0,0 +1,72 @@
ARG PYTHON_VERSION=3.12.7
FROM python:${PYTHON_VERSION}-slim as base
# Prevents Python from writing pyc files.
ENV PYTHONDONTWRITEBYTECODE=1
# Keeps Python from buffering stdout and stderr to avoid situations where
# the application crashes without emitting any logs due to buffering.
ENV PYTHONUNBUFFERED=1
# Create directory for patched packages and set permissions
RUN mkdir -p /app/patched_packages && \
chmod 777 /app/patched_packages
# Ensure patches are applied to the application.
ENV PYTHONPATH=/app/patched_packages:$PYTHONPATH
WORKDIR /app
# Create a non-privileged user that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/bin/bash" \
--no-create-home \
--uid "${UID}" \
appuser
# Install necessary packages and build tools
RUN apt-get update && apt-get install -y \
build-essential \
gcc \
postgresql-client \
curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Create logs directory and set permissions
RUN mkdir -p /app/logs && chown -R appuser:appuser /app/logs
# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them into
# into this layer.
COPY requirements.txt /app/
RUN python -m pip install -r /app/requirements.txt
# Copy the source code into the container.
COPY eveai_chat_client /app/eveai_chat_client
COPY common /app/common
COPY config /app/config
COPY scripts /app/scripts
COPY patched_packages /app/patched_packages
COPY content /app/content
# Set permissions for scripts
RUN chmod 777 /app/scripts/entrypoint.sh && \
chmod 777 /app/scripts/start_eveai_chat_client.sh
# Set ownership of the application directory to the non-privileged user
RUN chown -R appuser:appuser /app
# Expose the port that the application listens on.
EXPOSE 5004
# Set entrypoint and command
ENTRYPOINT ["/app/scripts/entrypoint.sh"]
CMD ["/app/scripts/start_eveai_chat_client.sh"]

View File

@@ -5,16 +5,18 @@
{% block content_title %}Document Versions{% endblock %}
{% block content_description %}View Versions for {{ document }}{% endblock %}
{% block content_class %}<div class="col-xl-12 col-lg-5 col-md-7 mx-auto">{% endblock %}
{% block content_class %}<div class="col-xl-12 col-lg-5 col-md-7 mx-auto"></div>{% endblock %}
{% block content %}
<div class="container">
<form method="POST" action="{{ url_for('document_bp.handle_document_version_selection') }}">
<form method="POST" action="{{ url_for('document_bp.handle_document_version_selection') }}" id="documentVersionsForm">
{{ render_selectable_table(headers=["ID", "URL", "Object Name", "File Type", "Process.", "Proces. Start", "Proces. Finish", "Proces. Error"], rows=rows, selectable=True, id="versionsTable") }}
<div class="form-group mt-3">
<button type="submit" name="action" value="edit_document_version" class="btn btn-primary">Edit Document Version</button>
<button type="submit" name="action" value="view_document_version_markdown" class="btn btn-danger">View Processed Document</button>
<button type="submit" name="action" value="process_document_version" class="btn btn-danger">Process Document Version</button>
<div class="form-group mt-3 d-flex justify-content-between">
<div>
<button type="submit" name="action" value="edit_document_version" class="btn btn-primary" onclick="return validateTableSelection('documentVersionsForm')">Edit Document Version</button>
<button type="submit" name="action" value="view_document_version_markdown" class="btn btn-danger" onclick="return validateTableSelection('documentVersionsForm')">View Processed Document</button>
<button type="submit" name="action" value="process_document_version" class="btn btn-danger" onclick="return validateTableSelection('documentVersionsForm')">Process Document Version</button>
</div>
</div>
</form>
</div>

View File

@@ -37,7 +37,7 @@
<div class="form-group mt-3 d-flex justify-content-between">
<div>
<button type="submit" name="action" value="edit_document_version" class="btn btn-primary" onclick="return validateTableSelection('documentVersionsForm')">Edit Document Version</button>
<button type="submit" name="action" value="view_document_version_markdown" class="btn btn-danger">View Processed Document</button>
<button type="submit" name="action" value="view_document_version_markdown" class="btn btn-danger" onclick="return validateTableSelection('documentVersionsForm')">View Processed Document</button>
<button type="submit" name="action" value="process_document_version" class="btn btn-danger" onclick="return validateTableSelection('documentVersionsForm')">Process Document Version</button>
</div>
</div>

View File

@@ -10,7 +10,7 @@
{% block content %}
<div class="container">
<form method="POST" action="{{ url_for('interaction_bp.handle_specialist_selection') }}" id="specialistsForm">
{{ 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") }}
<div class="form-group mt-3 d-flex justify-content-between">
<div>
<button type="submit" name="action" value="edit_specialist" class="btn btn-primary" onclick="return validateTableSelection('specialistsForm')">Edit Specialist</button>

View File

@@ -73,6 +73,7 @@
{'name': 'Tenant Overview', 'url': '/user/tenant_overview', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
{'name': 'Edit Tenant', 'url': '/user/tenant/' ~ session['tenant'].get('id'), 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
{'name': 'Tenant Domains', 'url': '/user/view_tenant_domains', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
{'name': 'Tenant Makes', 'url': '/user/tenant_makes', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
{'name': 'Tenant Projects', 'url': '/user/tenant_projects', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
{'name': 'Users', 'url': '/user/view_users', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
]) }}

View File

@@ -0,0 +1,33 @@
{% extends 'base.html' %}
{% from "macros.html" import render_field %}
{% block title %}Edit Tenant Make{% endblock %}
{% block content_title %}Edit Tenant Make{% endblock %}
{% block content_description %}Edit a Tenant Make.{% endblock %}
{% block content %}
<form method="post">
{{ form.hidden_tag() }}
{% set disabled_fields = [] %}
{% set exclude_fields = [] %}
<!-- Render Static Fields -->
{% for field in form.get_static_fields() %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %}
<!-- Render Dynamic Fields -->
{% for collection_name, fields in form.get_dynamic_fields().items() %}
{% if fields|length > 0 %}
<h4 class="mt-4">{{ collection_name }}</h4>
{% endif %}
{% for field in fields %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %}
{% endfor %}
<button type="submit" class="btn btn-primary">Save Tenant Make</button>
</form>
{% endblock %}
{% block content_footer %}
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends 'base.html' %}
{% from "macros.html" import render_field %}
{% block title %}Tenant Make Registration{% endblock %}
{% block content_title %}Register Tenant Make{% endblock %}
{% block content_description %}Define a new tenant make{% endblock %}
{% block content %}
<form method="post">
{{ form.hidden_tag() }}
{% set disabled_fields = [] %}
{% set exclude_fields = [] %}
{% for field in form.get_static_fields() %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %}
<!-- Render Dynamic Fields -->
{% for collection_name, fields in form.get_dynamic_fields().items() %}
{% if fields|length > 0 %}
<h4 class="mt-4">{{ collection_name }}</h4>
{% endif %}
{% for field in fields %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %}
{% endfor %}
<button type="submit" class="btn btn-primary">Register Tenant Make</button>
</form>
{% endblock %}
{% block content_footer %}
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% from 'macros.html' import render_selectable_table, render_pagination %}
{% block title %}Tenant Makes{% endblock %}
{% block content_title %}Tenant Makes{% endblock %}
{% block content_description %}View Tenant Makes for Tenant{% endblock %}
{% block content_class %}<div class="col-xl-12 col-lg-5 col-md-7 mx-auto"></div>{% endblock %}
{% block content %}
<div class="container">
<form method="POST" action="{{ url_for('user_bp.handle_tenant_make_selection') }}" id="tenantMakesForm">
{{ render_selectable_table(headers=["Tenant Make ID", "Name", "Website", "Active"], rows=rows, selectable=True, id="tenantMakesTable") }}
<div class="form-group mt-3 d-flex justify-content-between">
<div>
<button type="submit" name="action" value="edit_tenant_make" class="btn btn-primary" onclick="return validateTableSelection('tenantMakesForm')">Edit Tenant Make</button>
</div>
<button type="submit" name="action" value="create_tenant_make" class="btn btn-success">Register Tenant Make</button>
</div>
</form>
</div>
{% endblock %}
{% block content_footer %}
{{ render_pagination(pagination, "user_bp.tenant_makes") }}
{% endblock %}

View File

@@ -1,5 +1,5 @@
{% extends 'base.html' %}
{% from "macros.html" import render_field, render_included_field %}
{% from "macros.html" import render_field, render_included_field, debug_to_console %}
{% block title %}Tenant Overview{% endblock %}
@@ -9,162 +9,23 @@
{% block content %}
<form method="post">
{{ form.hidden_tag() }}
<!-- Main Tenant Information -->
{% set main_fields = ['name', 'code', 'website', 'default_language', 'allowed_languages', 'type'] %}
{% set disabled_fields = [] %}
{% for field in form %}
{{ render_included_field(field, disabled_fields=main_fields, include_fields=main_fields) }}
{{ debug_to_console('field to disable', field.name) }}
{{ debug_to_console('field type to disable', field.type) }}
{% if field.name != 'csrf_token' and field.type != 'HiddenField' %}
{% set disabled_fields = disabled_fields + [field.name] %}
{{ debug_to_console('disable', '!') }}
{% endif %}
{% endfor %}
{{ debug_to_console('disabled_fields', disabled_fields) }}
{% set exclude_fields = [] %}
{% for field in form %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %}
<!-- Nav Tabs -->
<div class="row mt-5">
<div class="col-lg-12">
<div class="nav-wrapper position-relative end-0">
<ul class="nav nav-pills nav-fill p-1" role="tablist">
<li class="nav-item">
<a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#license-info-tab" role="tab" aria-controls="license-info" aria-selected="false">
License Information
</a>
</li>
</ul>
</div>
<div class="tab-content tab-space">
<!-- License Information Tab -->
<div class="tab-pane fade" id="license-info-tab" role="tabpanel">
{% set license_fields = ['currency', 'usage_email', ] %}
{% for field in form %}
{{ render_included_field(field, disabled_fields=license_fields, include_fields=license_fields) }}
{% endfor %}
<!-- Register API Key Button -->
<button type="button" class="btn btn-primary" onclick="generateNewChatApiKey()">Register Chat API Key</button>
<button type="button" class="btn btn-primary" onclick="generateNewApiKey()">Register API Key</button>
<!-- API Key Display Field -->
<div id="chat-api-key-field" style="display:none;">
<label for="chat-api-key">Chat API Key:</label>
<input type="text" id="chat-api-key" class="form-control" readonly>
<button type="button" id="copy-chat-button" class="btn btn-primary">Copy to Clipboard</button>
<p id="copy-chat-message" style="display:none;color:green;">Chat API key copied to clipboard</p>
</div>
<div id="api-key-field" style="display:none;">
<label for="api-key">API Key:</label>
<input type="text" id="api-key" class="form-control" readonly>
<button type="button" id="copy-api-button" class="btn btn-primary">Copy to Clipboard</button>
<p id="copy-message" style="display:none;color:green;">API key copied to clipboard</p>
</div>
</div>
</div>
</div>
</div>
</form>
{% endblock %}
{% block content_footer %}
{% endblock %}
{% block scripts %}
<script>
// Function to generate a new Chat API Key
function generateNewChatApiKey() {
generateApiKey('/admin/user/generate_chat_api_key', '#chat-api-key', '#chat-api-key-field');
}
// Function to generate a new general API Key
function generateNewApiKey() {
generateApiKey('/admin/user/generate_api_api_key', '#api-key', '#api-key-field');
}
// Reusable function to handle API key generation
function generateApiKey(url, inputSelector, fieldSelector) {
$.ajax({
url: url,
type: 'POST',
contentType: 'application/json',
success: function(response) {
$(inputSelector).val(response.api_key);
$(fieldSelector).show();
},
error: function(error) {
alert('Error generating new API key: ' + error.responseText);
}
});
}
// Function to copy text to clipboard
function copyToClipboard(selector, messageSelector) {
const element = document.querySelector(selector);
if (element) {
const text = element.value;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
showCopyMessage(messageSelector);
}).catch(function(error) {
alert('Failed to copy text: ' + error);
});
} else {
fallbackCopyToClipboard(text, messageSelector);
}
} else {
console.error('Element not found for selector:', selector);
}
}
// Fallback method for copying text to clipboard
function fallbackCopyToClipboard(text, messageSelector) {
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
showCopyMessage(messageSelector);
} catch (err) {
alert('Fallback: Oops, unable to copy', err);
}
document.body.removeChild(textArea);
}
// Function to show copy confirmation message
function showCopyMessage(messageSelector) {
const message = document.querySelector(messageSelector);
if (message) {
message.style.display = 'block';
setTimeout(function() {
message.style.display = 'none';
}, 2000);
}
}
// Event listeners for copy buttons
document.getElementById('copy-chat-button').addEventListener('click', function() {
copyToClipboard('#chat-api-key', '#copy-chat-message');
});
document.getElementById('copy-api-button').addEventListener('click', function() {
copyToClipboard('#api-key', '#copy-message');
});
</script>
<script>
// JavaScript to detect user's timezone
document.addEventListener('DOMContentLoaded', (event) => {
// Detect timezone
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Send timezone to the server via a POST request
fetch('/set_user_timezone', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ timezone: userTimezone })
}).then(response => {
if (response.ok) {
console.log('Timezone sent to server successfully');
} else {
console.error('Failed to send timezone to server');
}
});
});
</script>
{% endblock %}

View File

@@ -6,6 +6,7 @@ from wtforms.validators import DataRequired, Length, Optional, URL, ValidationEr
from flask_wtf.file import FileField, FileRequired
import json
from wtforms.widgets.core import HiddenInput
from wtforms_sqlalchemy.fields import QuerySelectField
from common.extensions import cache_manager
@@ -17,8 +18,16 @@ from config.type_defs.processor_types import PROCESSOR_TYPES
from .dynamic_form_base import DynamicFormBase
def validate_catalog_name(form, field):
# Controleer of een catalog met deze naam al bestaat
existing_catalog = Catalog.query.filter_by(name=field.data).first()
if existing_catalog and (not hasattr(form, 'id') or form.id.data != existing_catalog.id):
raise ValidationError(f'A Catalog with name "{field.data}" already exists. Choose another name.')
class CatalogForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
id = IntegerField('ID', widget=HiddenInput())
name = StringField('Name', validators=[DataRequired(), Length(max=50), validate_catalog_name])
description = TextAreaField('Description', validators=[Optional()])
# Select Field for Catalog Type (Uses the CATALOG_TYPES defined in config)
@@ -41,7 +50,8 @@ class CatalogForm(FlaskForm):
class EditCatalogForm(DynamicFormBase):
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
id = IntegerField('ID', widget=HiddenInput())
name = StringField('Name', validators=[DataRequired(), Length(max=50), validate_catalog_name])
description = TextAreaField('Description', validators=[Optional()])
# Select Field for Catalog Type (Uses the CATALOG_TYPES defined in config)

View File

@@ -3,11 +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
@@ -299,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.
@@ -356,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 = {
@@ -372,6 +392,7 @@ class DynamicFormBase(FlaskForm):
'text': TextAreaField,
'date': DateField,
'file': FileField,
'color': ColorField,
}.get(field_type, StringField)
field_kwargs = {}
@@ -414,6 +435,14 @@ class DynamicFormBase(FlaskForm):
render_kw['data-bs-toggle'] = 'tooltip'
render_kw['data-bs-placement'] = 'right'
# Add special styling for color fields to make them more compact and visible
if field_type == 'color':
render_kw['style'] = 'width: 100px; height: 40px;'
if 'class' in render_kw:
render_kw['class'] = f"{render_kw['class']} color-field"
else:
render_kw['class'] = 'color-field'
current_app.logger.debug(f"render_kw for {full_field_name}: {render_kw}")
@@ -603,7 +632,7 @@ def validate_tagging_fields(form, field):
raise ValidationError(f"Field {field_name} missing required 'type' property")
# Validate type
if field_def['type'] not in ['string', 'integer', 'float', 'date', 'enum']:
if field_def['type'] not in ['string', 'integer', 'float', 'date', 'enum', 'color']:
raise ValidationError(f"Field {field_name} has invalid type: {field_def['type']}")
# Validate enum fields have allowed_values

View File

@@ -8,6 +8,7 @@ from wtforms_sqlalchemy.fields import QuerySelectMultipleField
from common.models.document import Retriever
from common.models.interaction import EveAITool, Specialist
from common.models.user import TenantMake
from common.extensions import cache_manager
from common.utils.form_assistants import validate_json
@@ -24,6 +25,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 +36,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 +49,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',
@@ -138,6 +141,7 @@ class SpecialistMagicLinkForm(FlaskForm):
description = TextAreaField('Description', validators=[Optional()])
magic_link_code = StringField('Magic Link Code', validators=[DataRequired(), Length(max=55)], render_kw={'readonly': True})
specialist_id = SelectField('Specialist', validators=[DataRequired()])
tenant_make_id = SelectField('Tenant Make', validators=[Optional()], coerce=int)
valid_from = DateField('Valid From', id='form-control datepicker', validators=[Optional()])
valid_to = DateField('Valid To', id='form-control datepicker', validators=[Optional()])
@@ -148,9 +152,13 @@ class SpecialistMagicLinkForm(FlaskForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
specialists = Specialist.query.all()
# Dynamically populate the 'type' field using the constructor
# Dynamically populate the specialist field
self.specialist_id.choices = [(specialist.id, specialist.name) for specialist in specialists]
# Dynamically populate the tenant_make field with None as first option
tenant_makes = TenantMake.query.all()
self.tenant_make_id.choices = [(0, 'None')] + [(make.id, make.name) for make in tenant_makes]
class EditSpecialistMagicLinkForm(DynamicFormBase):
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
@@ -159,6 +167,8 @@ class EditSpecialistMagicLinkForm(DynamicFormBase):
render_kw={'readonly': True})
specialist_id = IntegerField('Specialist', validators=[DataRequired()], render_kw={'readonly': True})
specialist_name = StringField('Specialist Name', validators=[DataRequired()], render_kw={'readonly': True})
tenant_make_id = SelectField('Tenant Make', validators=[Optional()], coerce=int)
tenant_make_name = StringField('Tenant Make Name', validators=[Optional()], 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()])
@@ -174,5 +184,15 @@ class EditSpecialistMagicLinkForm(DynamicFormBase):
else:
self.specialist_name.data = ''
# Dynamically populate the tenant_make field with None as first option
tenant_makes = TenantMake.query.all()
self.tenant_make_id.choices = [(0, 'None')] + [(make.id, make.name) for make in tenant_makes]
# If the form has a tenant_make_id that's not zero, set the tenant_make_name
if hasattr(self, 'tenant_make_id') and self.tenant_make_id.data and self.tenant_make_id.data > 0:
tenant_make = TenantMake.query.get(self.tenant_make_id.data)
if tenant_make:
self.tenant_make_name.data = tenant_make.name

View File

@@ -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)
@@ -689,9 +691,13 @@ def specialist_magic_link():
try:
new_specialist_magic_link = SpecialistMagicLink()
# Populate fields individually instead of using populate_obj (gives problem with QueryMultipleSelectField)
# Populate fields individually instead of using populate_obj
form.populate_obj(new_specialist_magic_link)
# Handle the tenant_make_id special case (0 = None)
if form.tenant_make_id.data == 0:
new_specialist_magic_link.tenant_make_id = None
set_logging_information(new_specialist_magic_link, dt.now(tz.utc))
# Create 'public' SpecialistMagicLinkTenant
@@ -731,12 +737,23 @@ def edit_specialist_magic_link(specialist_magic_link_id):
form.add_dynamic_fields("arguments", specialist_config, specialist_ml.specialist_args)
# Set the tenant_make_id default value
if request.method == 'GET':
if specialist_ml.tenant_make_id is None:
form.tenant_make_id.data = 0
else:
form.tenant_make_id.data = specialist_ml.tenant_make_id
if form.validate_on_submit():
# Update the basic fields
form.populate_obj(specialist_ml)
# Update the arguments dynamic fields
specialist_ml.specialist_args = form.get_dynamic_data("arguments")
# Handle the tenant_make_id special case (0 = None)
if form.tenant_make_id.data == 0:
specialist_ml.tenant_make_id = None
# Update logging information
update_logging_information(specialist_ml, dt.now(tz.utc))

View File

@@ -2,12 +2,15 @@ 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
class TenantForm(FlaskForm):
@@ -22,6 +25,8 @@ class TenantForm(FlaskForm):
currency = SelectField('Currency', choices=[], validators=[DataRequired()])
# Timezone
timezone = SelectField('Timezone', choices=[], validators=[DataRequired()])
# Default tenant make
default_tenant_make_id = SelectField('Default Tenant Make', choices=[], validators=[Optional()])
# For Super Users only - Allow to assign the tenant to the partner
assign_to_partner = BooleanField('Assign to Partner', default=False)
@@ -39,6 +44,13 @@ class TenantForm(FlaskForm):
self.timezone.choices = [(tz, tz) for tz in pytz.common_timezones]
# Initialize fallback algorithms
self.type.choices = [(t, t) for t in current_app.config['TENANT_TYPES']]
# Initialize default tenant make choices
tenant_id = session.get('tenant', {}).get('id') if 'tenant' in session else None
if tenant_id:
tenant_makes = TenantMake.query.filter_by(tenant_id=tenant_id, active=True).all()
self.default_tenant_make_id.choices = [(str(make.id), make.name) for make in tenant_makes]
# Add empty choice
self.default_tenant_make_id.choices.insert(0, ('', 'Geen'))
# Show field only for Super Users with partner in session
if not current_user.has_roles('Super User') or 'partner' not in session:
self._fields.pop('assign_to_partner', None)
@@ -131,4 +143,24 @@ 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):
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)])
logo_url = StringField('Logo URL', validators=[Optional(), Length(max=255)])

View File

@@ -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
@@ -5,12 +6,13 @@ from flask_security import roles_accepted, current_user
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
import ast
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant
from common.extensions import db, security, minio_client, simple_encryption
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant, TenantMake
from common.extensions import db, security, minio_client, simple_encryption, cache_manager
from common.utils.dynamic_field_utils import create_default_config_from_type_config
from common.utils.security_utils import send_confirmation_email, send_reset_email
from config.type_defs.service_types import SERVICE_TYPES
from .user_forms import TenantForm, CreateUserForm, EditUserForm, TenantDomainForm, TenantSelectionForm, \
TenantProjectForm, EditTenantProjectForm
TenantProjectForm, EditTenantProjectForm, TenantMakeForm
from common.utils.database import Database
from common.utils.view_assistants import prepare_table_for_macro, form_validation_failed
from common.utils.simple_encryption import generate_api_key
@@ -51,6 +53,10 @@ def tenant():
new_tenant = Tenant()
form.populate_obj(new_tenant)
# Convert default_tenant_make_id to integer if not empty
if form.default_tenant_make_id.data:
new_tenant.default_tenant_make_id = int(form.default_tenant_make_id.data)
timestamp = dt.now(tz.utc)
new_tenant.created_at = timestamp
new_tenant.updated_at = timestamp
@@ -116,6 +122,12 @@ def edit_tenant(tenant_id):
# Populate the tenant with form data
form.populate_obj(tenant)
# Convert default_tenant_make_id to integer if not empty
if form.default_tenant_make_id.data:
tenant.default_tenant_make_id = int(form.default_tenant_make_id.data)
else:
tenant.default_tenant_make_id = None
db.session.commit()
flash('Tenant updated successfully.', 'success')
if session.get('tenant'):
@@ -460,7 +472,17 @@ def tenant_overview():
tenant_id = session['tenant']['id']
tenant = Tenant.query.get_or_404(tenant_id)
form = TenantForm(obj=tenant)
return render_template('user/tenant_overview.html', form=form)
# Zet de waarde van default_tenant_make_id
if tenant.default_tenant_make_id:
form.default_tenant_make_id.data = str(tenant.default_tenant_make_id)
# Haal de naam van de default make op als deze bestaat
default_make_name = None
if tenant.default_tenant_make:
default_make_name = tenant.default_tenant_make.name
return render_template('user/tenant_overview.html', form=form, default_make_name=default_make_name)
@user_bp.route('/tenant_project', methods=['GET', 'POST'])
@@ -622,6 +644,127 @@ def delete_tenant_project(tenant_project_id):
return redirect(prefixed_url_for('user_bp.tenant_projects'))
@user_bp.route('/tenant_make', methods=['GET', 'POST'])
@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_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:
db.session.add(new_tenant_make)
db.session.commit()
flash('Tenant Make successfully added!', 'success')
current_app.logger.info(f'Tenant Make {new_tenant_make.name} successfully added for tenant {tenant_id}!')
# Enable step 2 of creation of retriever - add configuration of the retriever (dependent on type)
return redirect(prefixed_url_for('user_bp.tenant_makes', tenant_make_id=new_tenant_make.id))
except SQLAlchemyError as e:
db.session.rollback()
flash(f'Failed to add Tenant Make. Error: {e}', 'danger')
current_app.logger.error(f'Failed to add Tenant Make {new_tenant_make.name}'
f'for tenant {tenant_id}. Error: {str(e)}')
return render_template('user/tenant_make.html', form=form)
@user_bp.route('/tenant_makes', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def tenant_makes():
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
query = TenantMake.query.order_by(TenantMake.id)
pagination = query.paginate(page=page, per_page=per_page)
tenant_makes = pagination.items
# prepare table data
rows = prepare_table_for_macro(tenant_makes,
[('id', ''), ('name', ''), ('website', ''), ('active', '')])
# Render the tenant makes in a template
return render_template('user/tenant_makes.html', rows=rows, pagination=pagination)
@user_bp.route('/tenant_make/<int:tenant_make_id>', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def edit_tenant_make(tenant_make_id):
"""Edit an existing tenant make configuration."""
# Get the tenant make or return 404
tenant_make = TenantMake.query.get_or_404(tenant_make_id)
# Create form instance with the tenant make
form = TenantMakeForm(request.form, obj=tenant_make)
customisation_config = cache_manager.customisations_config_cache.get_config("CHAT_CLIENT_CUSTOMISATION")
form.add_dynamic_fields("configuration", customisation_config, tenant_make.chat_customisation_options)
if form.validate_on_submit():
# Update basic fields
form.populate_obj(tenant_make)
tenant_make.chat_customisation_options = form.get_dynamic_data("configuration")
# Update logging information
update_logging_information(tenant_make, dt.now(tz.utc))
# Save changes to database
try:
db.session.add(tenant_make)
db.session.commit()
flash('Tenant Make updated successfully!', 'success')
current_app.logger.info(f'Tenant Make {tenant_make.id} updated successfully')
except SQLAlchemyError as e:
db.session.rollback()
flash(f'Failed to update tenant make. Error: {str(e)}', 'danger')
current_app.logger.error(f'Failed to update tenant make {tenant_make_id}. Error: {str(e)}')
return render_template('user/edit_tenant_make.html', form=form, tenant_make_id=tenant_make_id)
return redirect(prefixed_url_for('user_bp.tenant_makes'))
else:
form_validation_failed(request, form)
return render_template('user/edit_tenant_make.html', form=form, tenant_make_id=tenant_make_id)
@user_bp.route('/handle_tenant_make_selection', methods=['POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def handle_tenant_make_selection():
action = request.form['action']
if action == 'create_tenant_make':
return redirect(prefixed_url_for('user_bp.tenant_make'))
tenant_make_identification = request.form.get('selected_row')
tenant_make_id = ast.literal_eval(tenant_make_identification).get('value')
if action == 'edit_tenant_make':
return redirect(prefixed_url_for('user_bp.edit_tenant_make', tenant_make_id=tenant_make_id))
elif action == 'set_as_default':
# Set this make as the default for the tenant
tenant_id = session['tenant']['id']
tenant = Tenant.query.get(tenant_id)
tenant.default_tenant_make_id = tenant_make_id
try:
db.session.commit()
flash(f'Default tenant make updated successfully.', 'success')
# Update session data if necessary
if 'tenant' in session:
session['tenant'] = tenant.to_dict()
except SQLAlchemyError as e:
db.session.rollback()
flash(f'Failed to update default tenant make. Error: {str(e)}', 'danger')
current_app.logger.error(f'Failed to update default tenant make. Error: {str(e)}')
return redirect(prefixed_url_for('user_bp.tenant_makes'))
def reset_uniquifier(user):
security.datastore.set_uniquifier(user)
db.session.add(user)

View File

@@ -0,0 +1,106 @@
import logging
import os
from flask import Flask, jsonify
from werkzeug.middleware.proxy_fix import ProxyFix
import logging.config
from common.extensions import (db, bootstrap, cors, csrf, session,
minio_client, simple_encryption, metrics, cache_manager, content_manager)
from common.models.user import Tenant, SpecialistMagicLinkTenant
from common.utils.startup_eveai import perform_startup_actions
from config.logging_config import LOGGING
from eveai_chat_client.utils.errors import register_error_handlers
from common.utils.celery_utils import make_celery, init_celery
from common.utils.template_filters import register_filters
from config.config import get_config
def create_app(config_file=None):
app = Flask(__name__, static_url_path='/static')
# Ensure all necessary headers are handled
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
environment = os.getenv('FLASK_ENV', 'development')
match environment:
case 'development':
app.config.from_object(get_config('dev'))
case 'production':
app.config.from_object(get_config('prod'))
case _:
app.config.from_object(get_config('dev'))
app.config['SESSION_KEY_PREFIX'] = 'eveai_chat_client_'
try:
os.makedirs(app.instance_path)
except OSError:
pass
logging.config.dictConfig(LOGGING)
logger = logging.getLogger(__name__)
logger.info("eveai_chat_client starting up")
# Register extensions
register_extensions(app)
# Configure CSRF protection
app.config['WTF_CSRF_CHECK_DEFAULT'] = False # Disable global CSRF protection
app.config['WTF_CSRF_TIME_LIMIT'] = None # Remove time limit for CSRF tokens
app.celery = make_celery(app.name, app.config)
init_celery(app.celery, app)
# Register Blueprints
register_blueprints(app)
# Register Error Handlers
register_error_handlers(app)
# Register Cache Handlers
register_cache_handlers(app)
# Debugging settings
if app.config['DEBUG'] is True:
app.logger.setLevel(logging.DEBUG)
# Register template filters
register_filters(app)
# Perform startup actions such as cache invalidation
perform_startup_actions(app)
app.logger.info(f"EveAI Chat Client Started Successfully (PID: {os.getpid()})")
app.logger.info("-------------------------------------------------------------------------------------------------")
return app
def register_extensions(app):
db.init_app(app)
bootstrap.init_app(app)
csrf.init_app(app)
cors.init_app(app)
simple_encryption.init_app(app)
session.init_app(app)
minio_client.init_app(app)
cache_manager.init_app(app)
metrics.init_app(app)
content_manager.init_app(app)
def register_blueprints(app):
from .views.chat_views import chat_bp
app.register_blueprint(chat_bp)
from .views.error_views import error_bp
app.register_blueprint(error_bp)
from .views.healthz_views import healthz_bp
app.register_blueprint(healthz_bp)
def register_cache_handlers(app):
from common.utils.cache.config_cache import register_config_cache_handlers
register_config_cache_handlers(cache_manager)
from common.utils.cache.crewai_processed_config_cache import register_specialist_cache_handlers
register_specialist_cache_handlers(cache_manager)

View File

@@ -0,0 +1,244 @@
/* Base styles */
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--background-color: #ffffff;
--text-color: #212529;
--sidebar-color: #f8f9fa;
--message-user-bg: #e9f5ff;
--message-bot-bg: #f8f9fa;
--border-radius: 8px;
--spacing: 16px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: var(--background-color);
height: 100vh;
overflow: hidden;
}
.container {
height: 100vh;
width: 100%;
}
/* Chat layout */
.chat-container {
display: flex;
height: 100%;
}
.sidebar {
width: 280px;
background-color: var(--sidebar-color);
border-right: 1px solid rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
padding: var(--spacing);
overflow-y: auto;
}
.logo {
margin-bottom: var(--spacing);
text-align: center;
}
.logo img {
max-width: 100%;
max-height: 60px;
}
.sidebar-content {
flex: 1;
display: flex;
flex-direction: column;
}
.sidebar-text {
margin-bottom: var(--spacing);
}
.team-info {
margin-top: auto;
padding-top: var(--spacing);
border-top: 1px solid rgba(0,0,0,0.1);
}
.team-member {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.team-member img {
width: 32px;
height: 32px;
border-radius: 50%;
margin-right: 8px;
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
}
.chat-header {
padding: var(--spacing);
border-bottom: 1px solid rgba(0,0,0,0.1);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: var(--spacing);
}
.message {
margin-bottom: var(--spacing);
max-width: 80%;
clear: both;
}
.user-message {
float: right;
}
.bot-message {
float: left;
}
.message-content {
padding: 12px 16px;
border-radius: var(--border-radius);
display: inline-block;
}
.user-message .message-content {
background-color: var(--message-user-bg);
color: var(--text-color);
}
.bot-message .message-content {
background-color: var(--message-bot-bg);
color: var(--text-color);
}
.chat-input-container {
padding: var(--spacing);
border-top: 1px solid rgba(0,0,0,0.1);
display: flex;
}
#chat-input {
flex: 1;
padding: 12px;
border: 1px solid rgba(0,0,0,0.2);
border-radius: var(--border-radius);
resize: none;
height: 60px;
margin-right: 8px;
}
#send-button {
padding: 0 24px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
}
/* Loading indicator */
.typing-indicator {
display: flex;
align-items: center;
}
.typing-indicator span {
height: 8px;
width: 8px;
background-color: rgba(0,0,0,0.3);
border-radius: 50%;
display: inline-block;
margin-right: 4px;
animation: typing 1.5s infinite ease-in-out;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0% { transform: scale(1); }
50% { transform: scale(1.5); }
100% { transform: scale(1); }
}
/* Error page styles */
.error-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.error-box {
background-color: white;
border-radius: var(--border-radius);
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
padding: 2rem;
text-align: center;
max-width: 500px;
}
.error-message {
margin: 1rem 0;
color: #dc3545;
}
.error-actions {
margin-top: 1.5rem;
}
.btn-primary {
display: inline-block;
background-color: var(--primary-color);
color: white;
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
text-decoration: none;
}
/* Responsive design */
@media (max-width: 768px) {
.chat-container {
flex-direction: column;
}
.sidebar {
width: 100%;
height: auto;
max-height: 30%;
border-right: none;
border-bottom: 1px solid rgba(0,0,0,0.1);
}
.message {
max-width: 90%;
}
}

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}EveAI Chat{% endblock %}</title>
<!-- CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/chat.css') }}">
<!-- Custom theme colors from tenant settings -->
<style>
:root {
--primary-color: {{ customization.primary_color|default('#007bff') }};
--secondary-color: {{ customization.secondary_color|default('#6c757d') }};
--background-color: {{ customization.background_color|default('#ffffff') }};
--text-color: {{ customization.text_color|default('#212529') }};
--sidebar-color: {{ customization.sidebar_color|default('#f8f9fa') }};
}
</style>
{% block head %}{% endblock %}
</head>
<body>
<div class="container">
{% block content %}{% endblock %}
</div>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,214 @@
{% extends "base.html" %}
{% block title %}Chat{% endblock %}
{% block content %}
<div class="chat-container">
<!-- Left sidebar with customizable content -->
<div class="sidebar">
{% if customisation.logo_url %}
<div class="logo">
<img src="{{ customisation.logo_url }}" alt="{{ tenant.name }} Logo">
</div>
{% endif %}
<div class="sidebar-content">
{% if customisation.sidebar_text %}
<div class="sidebar-text">
{{ customisation.sidebar_text|safe }}
</div>
{% endif %}
{% if customisation.team_info %}
<div class="team-info">
<h3>Team</h3>
<div class="team-members">
{% for member in customisation.team_info %}
<div class="team-member">
{% if member.avatar %}
<img src="{{ member.avatar }}" alt="{{ member.name }}">
{% endif %}
<div class="member-info">
<h4>{{ member.name }}</h4>
<p>{{ member.role }}</p>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
<!-- Main chat area -->
<div class="chat-main">
<div class="chat-header">
<h1>{{ specialist.name }}</h1>
</div>
<div class="chat-messages" id="chat-messages">
<!-- Messages will be added here dynamically -->
{% if customisation.welcome_message %}
<div class="message bot-message">
<div class="message-content">{{ customisation.welcome_message|safe }}</div>
</div>
{% else %}
<div class="message bot-message">
<div class="message-content">Hello! How can I help you today?</div>
</div>
{% endif %}
</div>
<div class="chat-input-container">
<textarea id="chat-input" placeholder="Type your message here..."></textarea>
<button id="send-button">Send</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Store session information
const sessionInfo = {
tenantId: {{ tenant.id }},
specialistId: {{ specialist.id }},
chatSessionId: "{{ session.chat_session_id }}"
};
// Chat functionality
document.addEventListener('DOMContentLoaded', function() {
const chatInput = document.getElementById('chat-input');
const sendButton = document.getElementById('send-button');
const chatMessages = document.getElementById('chat-messages');
let currentTaskId = null;
let pollingInterval = null;
// Function to add a message to the chat
function addMessage(message, isUser = false) {
const messageDiv = document.createElement('div');
messageDiv.className = isUser ? 'message user-message' : 'message bot-message';
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
contentDiv.innerHTML = message;
messageDiv.appendChild(contentDiv);
chatMessages.appendChild(messageDiv);
// Scroll to bottom
chatMessages.scrollTop = chatMessages.scrollHeight;
}
// Function to send a message
function sendMessage() {
const message = chatInput.value.trim();
if (!message) return;
// Add user message to chat
addMessage(message, true);
// Clear input
chatInput.value = '';
// Add loading indicator
const loadingDiv = document.createElement('div');
loadingDiv.className = 'message bot-message loading';
loadingDiv.innerHTML = '<div class="message-content"><div class="typing-indicator"><span></span><span></span><span></span></div></div>';
chatMessages.appendChild(loadingDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
// Send message to server
fetch('/api/send_message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: message,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'processing') {
currentTaskId = data.task_id;
// Start polling for results
if (pollingInterval) clearInterval(pollingInterval);
pollingInterval = setInterval(checkTaskStatus, 1000);
} else {
// Remove loading indicator
chatMessages.removeChild(loadingDiv);
// Show error if any
if (data.error) {
addMessage(`Error: ${data.error}`);
}
}
})
.catch(error => {
// Remove loading indicator
chatMessages.removeChild(loadingDiv);
addMessage(`Error: ${error.message}`);
});
}
// Function to check task status
function checkTaskStatus() {
if (!currentTaskId) return;
fetch(`/api/check_status?task_id=${currentTaskId}`)
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
// Remove loading indicator
const loadingDiv = document.querySelector('.loading');
if (loadingDiv) chatMessages.removeChild(loadingDiv);
// Add bot response
addMessage(data.answer);
// Clear polling
clearInterval(pollingInterval);
currentTaskId = null;
} else if (data.status === 'error') {
// Remove loading indicator
const loadingDiv = document.querySelector('.loading');
if (loadingDiv) chatMessages.removeChild(loadingDiv);
// Show error
addMessage(`Error: ${data.message}`);
// Clear polling
clearInterval(pollingInterval);
currentTaskId = null;
}
// If status is 'pending', continue polling
})
.catch(error => {
// Remove loading indicator
const loadingDiv = document.querySelector('.loading');
if (loadingDiv) chatMessages.removeChild(loadingDiv);
addMessage(`Error checking status: ${error.message}`);
// Clear polling
clearInterval(pollingInterval);
currentTaskId = null;
});
}
// Event listeners
sendButton.addEventListener('click', sendMessage);
chatInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Error{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-box">
<h1>Oops! Something went wrong</h1>
<p class="error-message">{{ message }}</p>
<div class="error-actions">
<a href="/" class="btn-primary">Go to Home</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1 @@
# Utils package for eveai_chat_client

View File

@@ -0,0 +1,85 @@
import traceback
import jinja2
from flask import render_template, request, jsonify, redirect, current_app, flash
from common.utils.eveai_exceptions import EveAINoSessionTenant
def not_found_error(error):
current_app.logger.error(f"Not Found Error: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error.html', message="Page not found."), 404
def internal_server_error(error):
current_app.logger.error(f"Internal Server Error: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error.html', message="Internal server error."), 500
def not_authorised_error(error):
current_app.logger.error(f"Not Authorised Error: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error.html', message="Not authorized."), 401
def access_forbidden(error):
current_app.logger.error(f"Access Forbidden: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error.html', message="Access forbidden."), 403
def key_error_handler(error):
current_app.logger.error(f"Key Error: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error.html', message="An unexpected error occurred."), 500
def attribute_error_handler(error):
"""Handle AttributeError exceptions."""
error_msg = str(error)
current_app.logger.error(f"AttributeError: {error_msg}")
current_app.logger.error(traceback.format_exc())
return render_template('error.html', message="An application error occurred."), 500
def no_tenant_selected_error(error):
"""Handle errors when no tenant is selected in the current session."""
current_app.logger.error(f"No Session Tenant Error: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error.html', message="Session expired. Please use a valid magic link."), 401
def general_exception(e):
current_app.logger.error(f"Unhandled Exception: {e}", exc_info=True)
return render_template('error.html', message="An application error occurred."), 500
def template_not_found_error(error):
"""Handle Jinja2 TemplateNotFound exceptions."""
current_app.logger.error(f'Template not found: {error.name}')
current_app.logger.error(f'Search Paths: {current_app.jinja_loader.list_templates()}')
current_app.logger.error(traceback.format_exc())
return render_template('error.html', message="Template not found."), 404
def template_syntax_error(error):
"""Handle Jinja2 TemplateSyntaxError exceptions."""
current_app.logger.error(f'Template syntax error: {error.message}')
current_app.logger.error(f'In template {error.filename}, line {error.lineno}')
current_app.logger.error(traceback.format_exc())
return render_template('error.html', message="Template syntax error."), 500
def register_error_handlers(app):
app.register_error_handler(404, not_found_error)
app.register_error_handler(500, internal_server_error)
app.register_error_handler(401, not_authorised_error)
app.register_error_handler(403, not_authorised_error)
app.register_error_handler(EveAINoSessionTenant, no_tenant_selected_error)
app.register_error_handler(KeyError, key_error_handler)
app.register_error_handler(AttributeError, attribute_error_handler)
app.register_error_handler(jinja2.TemplateNotFound, template_not_found_error)
app.register_error_handler(jinja2.TemplateSyntaxError, template_syntax_error)
app.register_error_handler(Exception, general_exception)

View File

@@ -0,0 +1 @@
# Views package for eveai_chat_client

View File

@@ -0,0 +1,170 @@
import uuid
from flask import Blueprint, render_template, request, session, current_app, jsonify, abort
from sqlalchemy.exc import SQLAlchemyError
from common.extensions import db
from common.models.user import Tenant, SpecialistMagicLinkTenant
from common.models.interaction import SpecialistMagicLink, Specialist, ChatSession, Interaction
from common.services.interaction.specialist_services import SpecialistServices
from common.utils.database import Database
from common.utils.chat_utils import get_default_chat_customisation
chat_bp = Blueprint('chat', __name__)
@chat_bp.route('/')
def index():
customisation = get_default_chat_customisation()
return render_template('error.html', message="Please use a valid magic link to access the chat.",
customisation=customisation)
@chat_bp.route('/<magic_link_code>')
def chat(magic_link_code):
"""
Main chat interface accessed via magic link
"""
try:
# Find the tenant using the magic link code
magic_link_tenant = SpecialistMagicLinkTenant.query.filter_by(magic_link_code=magic_link_code).first()
if not magic_link_tenant:
current_app.logger.error(f"Invalid magic link code: {magic_link_code}")
return render_template('error.html', message="Invalid magic link code.")
tenant_id = magic_link_tenant.tenant_id
# Get tenant information
tenant = Tenant.query.get(tenant_id)
if not tenant:
current_app.logger.error(f"Tenant not found for ID: {tenant_id}")
return render_template('error.html', message="Tenant not found.")
# Switch to tenant schema
Database(tenant_id).switch_schema()
# Get specialist magic link details from tenant schema
specialist_ml = SpecialistMagicLink.query.filter_by(magic_link_code=magic_link_code).first()
if not specialist_ml:
current_app.logger.error(f"Specialist magic link not found in tenant schema: {tenant_id}")
return render_template('error.html', message="Specialist configuration not found.")
# Get specialist details
specialist = Specialist.query.get(specialist_ml.specialist_id)
if not specialist:
current_app.logger.error(f"Specialist not found: {specialist_ml.specialist_id}")
return render_template('error.html', message="Specialist not found.")
# Store necessary information in session
session['tenant_id'] = tenant_id
session['specialist_id'] = specialist_ml.specialist_id
session['specialist_args'] = specialist_ml.specialist_args or {}
session['magic_link_code'] = magic_link_code
# Get customisation options with defaults
customisation = get_default_chat_customisation(tenant.chat_customisation_options)
# Start a new chat session
session['chat_session_id'] = SpecialistServices.start_session()
return render_template('chat.html',
tenant=tenant,
specialist=specialist,
customisation=customisation)
except Exception as e:
current_app.logger.error(f"Error in chat view: {str(e)}", exc_info=True)
return render_template('error.html', message="An error occurred while setting up the chat.")
@chat_bp.route('/api/send_message', methods=['POST'])
def send_message():
"""
API endpoint to send a message to the specialist
"""
try:
data = request.json
message = data.get('message')
if not message:
return jsonify({'error': 'No message provided'}), 400
tenant_id = session.get('tenant_id')
specialist_id = session.get('specialist_id')
chat_session_id = session.get('chat_session_id')
specialist_args = session.get('specialist_args', {})
if not all([tenant_id, specialist_id, chat_session_id]):
return jsonify({'error': 'Session expired or invalid'}), 400
# Switch to tenant schema
Database(tenant_id).switch_schema()
# Add user message to specialist arguments
specialist_args['user_message'] = message
# Execute specialist
result = SpecialistServices.execute_specialist(
tenant_id=tenant_id,
specialist_id=specialist_id,
specialist_arguments=specialist_args,
session_id=chat_session_id,
user_timezone=data.get('timezone', 'UTC')
)
# Store the task ID for polling
session['current_task_id'] = result['task_id']
return jsonify({
'status': 'processing',
'task_id': result['task_id']
})
except Exception as e:
current_app.logger.error(f"Error sending message: {str(e)}", exc_info=True)
return jsonify({'error': str(e)}), 500
@chat_bp.route('/api/check_status', methods=['GET'])
def check_status():
"""
API endpoint to check the status of a task
"""
try:
task_id = request.args.get('task_id') or session.get('current_task_id')
if not task_id:
return jsonify({'error': 'No task ID provided'}), 400
tenant_id = session.get('tenant_id')
if not tenant_id:
return jsonify({'error': 'Session expired or invalid'}), 400
# Switch to tenant schema
Database(tenant_id).switch_schema()
# Check task status using Celery
task_result = current_app.celery.AsyncResult(task_id)
if task_result.state == 'PENDING':
return jsonify({'status': 'pending'})
elif task_result.state == 'SUCCESS':
result = task_result.result
# Format the response
specialist_result = result.get('result', {})
response = {
'status': 'success',
'answer': specialist_result.get('answer', ''),
'citations': specialist_result.get('citations', []),
'insufficient_info': specialist_result.get('insufficient_info', False),
'interaction_id': result.get('interaction_id')
}
return jsonify(response)
else:
return jsonify({
'status': 'error',
'message': str(task_result.info)
})
except Exception as e:
current_app.logger.error(f"Error checking status: {str(e)}", exc_info=True)
return jsonify({'error': str(e)}), 500

View File

@@ -0,0 +1,24 @@
from flask import Blueprint, render_template
error_bp = Blueprint('error', __name__)
@error_bp.route('/error')
def error_page():
"""
Generic error page
"""
return render_template('error.html', message="An error occurred.")
@error_bp.app_errorhandler(404)
def page_not_found(e):
"""
Handle 404 errors
"""
return render_template('error.html', message="Page not found."), 404
@error_bp.app_errorhandler(500)
def internal_server_error(e):
"""
Handle 500 errors
"""
return render_template('error.html', message="Internal server error."), 500

View File

@@ -0,0 +1,17 @@
from flask import Blueprint, jsonify
healthz_bp = Blueprint('healthz', __name__)
@healthz_bp.route('/healthz/ready')
def ready():
"""
Health check endpoint for readiness probe
"""
return jsonify({"status": "ok"})
@healthz_bp.route('/healthz/live')
def live():
"""
Health check endpoint for liveness probe
"""
return jsonify({"status": "ok"})

View File

@@ -40,7 +40,7 @@ class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
@property
def type_version(self) -> str:
return "1.1"
return "1.2"
def _config_task_agents(self):
self._add_task_agent("traicie_get_competencies_task", "traicie_hr_bp_agent")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,52 @@
"""Add TenantMake model
Revision ID: 200bda7f5251
Revises: b6146237f298
Create Date: 2025-06-06 13:48:40.208711
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '200bda7f5251'
down_revision = 'b6146237f298'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tenant_make',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('active', sa.Boolean(), nullable=False),
sa.Column('website', sa.String(length=255), nullable=True),
sa.Column('logo_url', sa.String(length=255), nullable=True),
sa.Column('chat_customisation_options', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_by', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['public.user.id'], ),
sa.ForeignKeyConstraint(['tenant_id'], ['public.tenant.id'], ),
sa.ForeignKeyConstraint(['updated_by'], ['public.user.id'], ),
sa.PrimaryKeyConstraint('id'),
schema='public'
)
with op.batch_alter_table('tenant', schema=None) as batch_op:
batch_op.drop_column('chat_customisation_options')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tenant', schema=None) as batch_op:
batch_op.add_column(sa.Column('chat_customisation_options', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True))
op.drop_table('tenant_make', schema='public')
# ### end Alembic commands ###

View File

@@ -0,0 +1,35 @@
"""Add default TenantMake to Tenant model
Revision ID: 83d4e90f87c6
Revises: f40d16a0965a
Create Date: 2025-06-09 15:42:51.503696
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '83d4e90f87c6'
down_revision = 'f40d16a0965a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tenant', schema=None) as batch_op:
batch_op.add_column(sa.Column('default_tenant_make_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key(None, 'tenant_make', ['default_tenant_make_id'], ['id'], referent_schema='public')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tenant', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_column('default_tenant_make_id')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""Add Chat Configuration Options to Tenant model
Revision ID: b6146237f298
Revises: 2b4cb553530e
Create Date: 2025-06-06 03:45:24.264045
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'b6146237f298'
down_revision = '2b4cb553530e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tenant', schema=None) as batch_op:
batch_op.add_column(sa.Column('chat_customisation_options', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tenant', schema=None) as batch_op:
batch_op.drop_column('chat_customisation_options')
# ### end Alembic commands ###

View File

@@ -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 ###

View File

@@ -0,0 +1,30 @@
"""Add tenant_make reference to SpecialistMagicLink
Revision ID: 2b6ae6cc923e
Revises: a179785e5362
Create Date: 2025-06-09 15:59:39.157066
"""
from alembic import op
import sqlalchemy as sa
import pgvector
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '2b6ae6cc923e'
down_revision = 'a179785e5362'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('specialist_magic_link', sa.Column('tenant_make_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'specialist_magic_link', 'tenant_make', ['tenant_make_id'], ['id'], referent_schema='public', ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@@ -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 ###

View File

@@ -0,0 +1,29 @@
"""Make Catalog Name Unique
Revision ID: c71facc0ce7e
Revises: d69520ec540d
Create Date: 2025-06-07 08:38:23.759681
"""
from alembic import op
import sqlalchemy as sa
import pgvector
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'c71facc0ce7e'
down_revision = 'd69520ec540d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint(None, 'catalog', ['name'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@@ -18,6 +18,11 @@ http {
include mime.types;
default_type application/octet-stream;
# Define upstream servers
upstream eveai_chat_client {
server eveai_chat_client:5004;
}
log_format custom_log_format '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
@@ -93,6 +98,26 @@ http {
# add_header 'Access-Control-Allow-Credentials' 'true' always;
# }
location /chat-client/ {
proxy_pass http://eveai_chat_client/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /chat-client;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
# Add CORS headers
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
}
location /admin/ {
# include uwsgi_params;
# uwsgi_pass 127.0.0.1:5001;

View File

View File

@@ -1,7 +1,7 @@
from gevent import monkey
monkey.patch_all()
from eveai_chat import create_app
from eveai_chat_client import create_app
app = create_app()

View File

@@ -1,16 +0,0 @@
#!/bin/bash
cd "/app/" || exit 1
export PROJECT_DIR="/app"
export PYTHONPATH="$PROJECT_DIR/patched_packages:$PYTHONPATH:$PROJECT_DIR" # Include the app directory in the Python path & patched packages
# Ensure we can write the logs
chown -R appuser:appuser /app/logs
# Set flask environment variables
#export FLASK_ENV=development # Use 'production' as appropriate
#export FLASK_DEBUG=1 # Use 0 for production
echo "Starting EveAI Chat"
# Start Flask app
gunicorn -w 1 -k gevent -b 0.0.0.0:5002 --worker-connections 100 scripts.run_eveai_chat:app

View File

@@ -0,0 +1,23 @@
#!/bin/bash
cd "/app" || exit 1
export PYTHONPATH="$PYTHONPATH:/app/"
# Ensure we can write the logs
chown -R appuser:appuser /app/logs
# Wait for the database to be ready
echo "Waiting for database to be ready"
until pg_isready -h $DB_HOST -p $DB_PORT; do
echo "Postgres is unavailable - sleeping"
sleep 2
done
echo "Postgres is up - executing commands"
# Set FLASK_APP environment variables
PROJECT_DIR="/app"
export FLASK_APP=${PROJECT_DIR}/scripts/run_eveai_chat_client.py
export PYTHONPATH="$PROJECT_DIR/patched_packages:$PYTHONPATH:$PROJECT_DIR"
# Start Flask app with Gunicorn
gunicorn -w 1 -k gevent -b 0.0.0.0:5004 --worker-connections 100 scripts.run_eveai_chat_client:app