diff --git a/common/models/user.py b/common/models/user.py index bedc4e4..d4fb683 100644 --- a/common/models/user.py +++ b/common/models/user.py @@ -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 @@ -173,6 +173,28 @@ class TenantProject(db.Model): return f"" +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) + 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 +301,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) - - diff --git a/common/utils/cache/config_cache.py b/common/utils/cache/config_cache.py index a1c959f..dd7c267 100644 --- a/common/utils/cache/config_cache.py +++ b/common/utils/cache/config_cache.py @@ -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) diff --git a/common/utils/chat_utils.py b/common/utils/chat_utils.py new file mode 100644 index 0000000..65912aa --- /dev/null +++ b/common/utils/chat_utils.py @@ -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 \ No newline at end of file diff --git a/common/utils/config_field_types.py b/common/utils/config_field_types.py index 5bf8fc4..60af655 100644 --- a/common/utils/config_field_types.py +++ b/common/utils/config_field_types.py @@ -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) diff --git a/common/utils/dynamic_field_utils.py b/common/utils/dynamic_field_utils.py index fd10d70..41b23ed 100644 --- a/common/utils/dynamic_field_utils.py +++ b/common/utils/dynamic_field_utils.py @@ -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] = "" diff --git a/config/agents/traicie/TRAICIE_RECRUITER/1.0.0.yaml b/config/agents/traicie/TRAICIE_RECRUITER/1.0.0.yaml new file mode 100644 index 0000000..1dce0bf --- /dev/null +++ b/config/agents/traicie/TRAICIE_RECRUITER/1.0.0.yaml @@ -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, you’ve adapted to changing trends, from remote work to + AI-driven sourcing. You’re more than a recruiter—you’re 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" diff --git a/config/customisations/globals/CHAT_CLIENT_CUSTOMISATION/1.0.0.yaml b/config/customisations/globals/CHAT_CLIENT_CUSTOMISATION/1.0.0.yaml new file mode 100644 index 0000000..9ee6310 --- /dev/null +++ b/config/customisations/globals/CHAT_CLIENT_CUSTOMISATION/1.0.0.yaml @@ -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" \ No newline at end of file diff --git a/config/logging_config.py b/config/logging_config.py index 3b74424..e1d5750 100644 --- a/config/logging_config.py +++ b/config/logging_config.py @@ -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 }, diff --git a/config/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1.0.0.yaml b/config/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1.0.0.yaml index 75fecf8..81822e7 100644 --- a/config/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1.0.0.yaml +++ b/config/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1.0.0.yaml @@ -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" diff --git a/config/type_defs/customisation_types.py b/config/type_defs/customisation_types.py new file mode 100644 index 0000000..bc58441 --- /dev/null +++ b/config/type_defs/customisation_types.py @@ -0,0 +1,7 @@ +# Catalog Types +CUSTOMISATION_TYPES = { + "CHAT_CLIENT_CUSTOMISATION": { + "name": "Chat Client Customisation", + "description": "Parameters allowing to customise the chat client", + }, +} \ No newline at end of file diff --git a/docker/compose_dev.yaml b/docker/compose_dev.yaml index 4bc4ac8..289bf3c 100644 --- a/docker/compose_dev.yaml +++ b/docker/compose_dev.yaml @@ -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 - diff --git a/docker/compose_test.yaml b/docker/compose_test.yaml index 483edce..03a5f80 100644 --- a/docker/compose_test.yaml +++ b/docker/compose_test.yaml @@ -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: diff --git a/docker/eveai_chat_client/Dockerfile b/docker/eveai_chat_client/Dockerfile new file mode 100644 index 0000000..b5d0eb0 --- /dev/null +++ b/docker/eveai_chat_client/Dockerfile @@ -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"] diff --git a/eveai_app/templates/navbar.html b/eveai_app/templates/navbar.html index 2422b1f..4f7a657 100644 --- a/eveai_app/templates/navbar.html +++ b/eveai_app/templates/navbar.html @@ -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']}, ]) }} diff --git a/eveai_app/templates/user/edit_tenant_make.html b/eveai_app/templates/user/edit_tenant_make.html new file mode 100644 index 0000000..685df0f --- /dev/null +++ b/eveai_app/templates/user/edit_tenant_make.html @@ -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.hidden_tag() }} + {% set disabled_fields = [] %} + {% set exclude_fields = [] %} + + {% for field in form.get_static_fields() %} + {{ render_field(field, disabled_fields, exclude_fields) }} + {% endfor %} + + {% for collection_name, fields in form.get_dynamic_fields().items() %} + {% if fields|length > 0 %} +

{{ collection_name }}

+ {% endif %} + {% for field in fields %} + {{ render_field(field, disabled_fields, exclude_fields) }} + {% endfor %} + {% endfor %} + +
+{% endblock %} + +{% block content_footer %} + +{% endblock %} \ No newline at end of file diff --git a/eveai_app/templates/user/tenant_make.html b/eveai_app/templates/user/tenant_make.html new file mode 100644 index 0000000..b5c1418 --- /dev/null +++ b/eveai_app/templates/user/tenant_make.html @@ -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.hidden_tag() }} + {% set disabled_fields = [] %} + {% set exclude_fields = [] %} + {% for field in form.get_static_fields() %} + {{ render_field(field, disabled_fields, exclude_fields) }} + {% endfor %} + + {% for collection_name, fields in form.get_dynamic_fields().items() %} + {% if fields|length > 0 %} +

{{ collection_name }}

+ {% endif %} + {% for field in fields %} + {{ render_field(field, disabled_fields, exclude_fields) }} + {% endfor %} + {% endfor %} + +
+{% endblock %} + +{% block content_footer %} + +{% endblock %} \ No newline at end of file diff --git a/eveai_app/templates/user/tenant_makes.html b/eveai_app/templates/user/tenant_makes.html new file mode 100644 index 0000000..d00489e --- /dev/null +++ b/eveai_app/templates/user/tenant_makes.html @@ -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 %}
{% endblock %} + +{% block content %} +
+
+ {{ render_selectable_table(headers=["Tenant Make ID", "Name", "Website", "Active"], rows=rows, selectable=True, id="tenantMakesTable") }} +
+
+ +
+ +
+
+
+{% endblock %} + +{% block content_footer %} + {{ render_pagination(pagination, "user_bp.tenant_makes") }} +{% endblock %} \ No newline at end of file diff --git a/eveai_app/views/dynamic_form_base.py b/eveai_app/views/dynamic_form_base.py index 5d2d4eb..404ef52 100644 --- a/eveai_app/views/dynamic_form_base.py +++ b/eveai_app/views/dynamic_form_base.py @@ -8,6 +8,7 @@ import json from wtforms.fields.choices import SelectField from wtforms.fields.datetime import DateField +from wtforms.fields.simple import ColorField from common.utils.config_field_types import TaggingFields, json_to_patterns, patterns_to_json @@ -372,6 +373,7 @@ class DynamicFormBase(FlaskForm): 'text': TextAreaField, 'date': DateField, 'file': FileField, + 'color': ColorField, }.get(field_type, StringField) field_kwargs = {} @@ -414,6 +416,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 +613,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 diff --git a/eveai_app/views/user_forms.py b/eveai_app/views/user_forms.py index 02e0eeb..d7b5548 100644 --- a/eveai_app/views/user_forms.py +++ b/eveai_app/views/user_forms.py @@ -8,6 +8,7 @@ from flask_security import current_user 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): @@ -131,4 +132,13 @@ class EditTenantProjectForm(FlaskForm): self.services.choices = [(key, value['description']) for key, value in SERVICE_TYPES.items()] +class TenantMakeForm(DynamicFormBase): + name = StringField('Name', validators=[DataRequired(), Length(max=50)]) + 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)]) + + + diff --git a/eveai_app/views/user_views.py b/eveai_app/views/user_views.py index 8f08c61..8192d2b 100644 --- a/eveai_app/views/user_views.py +++ b/eveai_app/views/user_views.py @@ -5,12 +5,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 @@ -622,6 +623,108 @@ 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() + if form.validate_on_submit(): + tenant_id = session['tenant']['id'] + new_tenant_make = TenantMake() + form.populate_obj(new_tenant_make) + new_tenant_make.tenant_id = tenant_id + customisation_config = cache_manager.customisations_config_cache.get_config("CHAT_CLIENT_CUSTOMISATION") + new_tenant_make.chat_customisation_options = create_default_config_from_type_config( + customisation_config["configuration"]) + form.add_dynamic_fields("configuration", customisation_config, new_tenant_make.chat_customisation_options) + 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/', 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)) + def reset_uniquifier(user): security.datastore.set_uniquifier(user) db.session.add(user) diff --git a/eveai_chat_client/__init__.py b/eveai_chat_client/__init__.py new file mode 100644 index 0000000..c1d9ca5 --- /dev/null +++ b/eveai_chat_client/__init__.py @@ -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) diff --git a/eveai_chat_client/static/css/chat.css b/eveai_chat_client/static/css/chat.css new file mode 100644 index 0000000..45ba6ff --- /dev/null +++ b/eveai_chat_client/static/css/chat.css @@ -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%; + } +} \ No newline at end of file diff --git a/eveai_chat_client/templates/base.html b/eveai_chat_client/templates/base.html new file mode 100644 index 0000000..16c1c22 --- /dev/null +++ b/eveai_chat_client/templates/base.html @@ -0,0 +1,31 @@ + + + + + + {% block title %}EveAI Chat{% endblock %} + + + + + + + + {% block head %}{% endblock %} + + +
+ {% block content %}{% endblock %} +
+ + {% block scripts %}{% endblock %} + + \ No newline at end of file diff --git a/eveai_chat_client/templates/chat.html b/eveai_chat_client/templates/chat.html new file mode 100644 index 0000000..848d101 --- /dev/null +++ b/eveai_chat_client/templates/chat.html @@ -0,0 +1,214 @@ +{% extends "base.html" %} + +{% block title %}Chat{% endblock %} + +{% block content %} +
+ + + + +
+
+

{{ specialist.name }}

+
+ +
+ + {% if customisation.welcome_message %} +
+
{{ customisation.welcome_message|safe }}
+
+ {% else %} +
+
Hello! How can I help you today?
+
+ {% endif %} +
+ +
+ + +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/eveai_chat_client/templates/error.html b/eveai_chat_client/templates/error.html new file mode 100644 index 0000000..f77af69 --- /dev/null +++ b/eveai_chat_client/templates/error.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Error{% endblock %} + +{% block content %} +
+
+

Oops! Something went wrong

+

{{ message }}

+ +
+
+{% endblock %} \ No newline at end of file diff --git a/eveai_chat_client/utils/__init__.py b/eveai_chat_client/utils/__init__.py new file mode 100644 index 0000000..c80f619 --- /dev/null +++ b/eveai_chat_client/utils/__init__.py @@ -0,0 +1 @@ +# Utils package for eveai_chat_client \ No newline at end of file diff --git a/eveai_chat_client/utils/errors.py b/eveai_chat_client/utils/errors.py new file mode 100644 index 0000000..73035dc --- /dev/null +++ b/eveai_chat_client/utils/errors.py @@ -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) \ No newline at end of file diff --git a/eveai_chat_client/views/__init__.py b/eveai_chat_client/views/__init__.py new file mode 100644 index 0000000..e1ba2aa --- /dev/null +++ b/eveai_chat_client/views/__init__.py @@ -0,0 +1 @@ +# Views package for eveai_chat_client \ No newline at end of file diff --git a/eveai_chat_client/views/chat_views.py b/eveai_chat_client/views/chat_views.py new file mode 100644 index 0000000..1b7f369 --- /dev/null +++ b/eveai_chat_client/views/chat_views.py @@ -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('/') +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 diff --git a/eveai_chat_client/views/error_views.py b/eveai_chat_client/views/error_views.py new file mode 100644 index 0000000..b6f5004 --- /dev/null +++ b/eveai_chat_client/views/error_views.py @@ -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 \ No newline at end of file diff --git a/eveai_chat_client/views/healthz_views.py b/eveai_chat_client/views/healthz_views.py new file mode 100644 index 0000000..6091dc2 --- /dev/null +++ b/eveai_chat_client/views/healthz_views.py @@ -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"}) \ No newline at end of file diff --git a/eveai_chat_workers/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1_2.py b/eveai_chat_workers/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1_2.py index 4f2a8e6..7497ccd 100644 --- a/eveai_chat_workers/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1_2.py +++ b/eveai_chat_workers/specialists/traicie/TRAICIE_ROLE_DEFINITION_SPECIALIST/1_2.py @@ -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") diff --git a/eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_0.py b/eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_0.py new file mode 100644 index 0000000..b7b8076 --- /dev/null +++ b/eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_0.py @@ -0,0 +1,197 @@ +import asyncio +import json +from os import wait +from typing import Optional, List + +from crewai.flow.flow import start, listen, and_ +from flask import current_app +from pydantic import BaseModel, Field +from sqlalchemy.exc import SQLAlchemyError + +from common.extensions import db +from common.models.user import Tenant +from common.models.interaction import Specialist +from eveai_chat_workers.outputs.globals.basic_types.list_item import ListItem +from eveai_chat_workers.specialists.crewai_base_specialist import CrewAIBaseSpecialistExecutor +from eveai_chat_workers.specialists.specialist_typing import SpecialistResult, SpecialistArguments +from eveai_chat_workers.outputs.traicie.competencies.competencies_v1_1 import Competencies +from eveai_chat_workers.specialists.crewai_base_classes import EveAICrewAICrew, EveAICrewAIFlow, EveAIFlowState +from common.services.interaction.specialist_services import SpecialistServices + + +class SpecialistExecutor(CrewAIBaseSpecialistExecutor): + """ + type: TRAICIE_SELECTION_SPECIALIST + type_version: 1.0 + Traicie Selection Specialist Executor class + """ + + def __init__(self, tenant_id, specialist_id, session_id, task_id, **kwargs): + self.role_definition_crew = None + + super().__init__(tenant_id, specialist_id, session_id, task_id) + + # Load the Tenant & set language + self.tenant = Tenant.query.get_or_404(tenant_id) + + @property + def type(self) -> str: + return "TRAICIE_SELECTION_SPECIALIST" + + @property + def type_version(self) -> str: + return "1.0" + + def _config_task_agents(self): + self._add_task_agent("traicie_get_competencies_task", "traicie_hr_bp_agent") + + def _config_pydantic_outputs(self): + self._add_pydantic_output("traicie_get_competencies_task", Competencies, "competencies") + + def _instantiate_specialist(self): + verbose = self.tuning + + role_definition_agents = [self.traicie_hr_bp_agent] + role_definition_tasks = [self.traicie_get_competencies_task] + self.role_definition_crew = EveAICrewAICrew( + self, + "Role Definition Crew", + agents=role_definition_agents, + tasks=role_definition_tasks, + verbose=verbose, + ) + + self.flow = RoleDefinitionFlow( + self, + self.role_definition_crew + ) + + def execute(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult: + self.log_tuning("Traicie Role Definition Specialist execution started", {}) + + flow_inputs = { + "vacancy_text": arguments.vacancy_text, + "role_name": arguments.role_name, + 'role_reference': arguments.role_reference, + } + + flow_results = self.flow.kickoff(inputs=flow_inputs) + + flow_state = self.flow.state + + results = RoleDefinitionSpecialistResult.create_for_type(self.type, self.type_version) + if flow_state.competencies: + results.competencies = flow_state.competencies + + self.create_selection_specialist(arguments, flow_state.competencies) + + self.log_tuning(f"Traicie Role Definition Specialist execution ended", {"Results": results.model_dump()}) + + return results + + def create_selection_specialist(self, arguments: SpecialistArguments, competencies: List[ListItem]): + """This method creates a new TRAICIE_SELECTION_SPECIALIST specialist with the given competencies.""" + current_app.logger.info(f"Creating selection with arguments: {arguments.model_dump()}") + selection_comptencies = [] + for competency in competencies: + selection_competency = { + "title": competency.title, + "description": competency.description, + "assess": True, + "is_knockout": False, + } + selection_comptencies.append(selection_competency) + + selection_config = { + "name": arguments.specialist_name, + "competencies": selection_comptencies, + "tone_of_voice": "Professional & Neutral", + "language_level": "Standard", + "role_reference": arguments.role_reference, + } + name = arguments.role_name + if len(name) > 50: + name = name[:47] + "..." + + new_specialist = Specialist( + name=name, + description=f"Specialist for {arguments.role_name} role", + type="TRAICIE_SELECTION_SPECIALIST", + type_version="1.0", + tuning=False, + configuration=selection_config, + ) + try: + db.session.add(new_specialist) + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + current_app.logger.error(f"Error creating selection specialist: {str(e)}") + raise e + + SpecialistServices.initialize_specialist(new_specialist.id, "TRAICIE_SELECTION_SPECIALIST", "1.0") + + + + +class RoleDefinitionSpecialistInput(BaseModel): + role_name: str = Field(..., alias="role_name") + role_reference: Optional[str] = Field(..., alias="role_reference") + vacancy_text: Optional[str] = Field(None, alias="vacancy_text") + + +class RoleDefinitionSpecialistResult(SpecialistResult): + competencies: Optional[List[ListItem]] = None + + +class RoleDefFlowState(EveAIFlowState): + """Flow state for Traicie Role Definition specialist that automatically updates from task outputs""" + input: Optional[RoleDefinitionSpecialistInput] = None + competencies: Optional[List[ListItem]] = None + + +class RoleDefinitionFlow(EveAICrewAIFlow[RoleDefFlowState]): + def __init__(self, + specialist_executor: CrewAIBaseSpecialistExecutor, + role_definitiion_crew: EveAICrewAICrew, + **kwargs): + super().__init__(specialist_executor, "Traicie Role Definition Specialist Flow", **kwargs) + self.specialist_executor = specialist_executor + self.role_definition_crew = role_definitiion_crew + self.exception_raised = False + + @start() + def process_inputs(self): + return "" + + @listen(process_inputs) + async def execute_role_definition (self): + inputs = self.state.input.model_dump() + try: + current_app.logger.debug("In execute_role_definition") + crew_output = await self.role_definition_crew.kickoff_async(inputs=inputs) + # Unfortunately, crew_output will only contain the output of the latest task. + # As we will only take into account the flow state, we need to ensure both competencies and criteria + # are copies to the flow state. + update = {} + for task in self.role_definition_crew.tasks: + current_app.logger.debug(f"Task {task.name} output:\n{task.output}") + if task.name == "traicie_get_competencies_task": + # update["competencies"] = task.output.pydantic.competencies + self.state.competencies = task.output.pydantic.competencies + # crew_output.pydantic = crew_output.pydantic.model_copy(update=update) + current_app.logger.debug(f"State after execute_role_definition: {self.state}") + current_app.logger.debug(f"State dump after execute_role_definition: {self.state.model_dump()}") + return crew_output + except Exception as e: + current_app.logger.error(f"CREW execute_role_definition Kickoff Error: {str(e)}") + self.exception_raised = True + raise e + + async def kickoff_async(self, inputs=None): + current_app.logger.debug(f"Async kickoff {self.name}") + current_app.logger.debug(f"Inputs: {inputs}") + self.state.input = RoleDefinitionSpecialistInput.model_validate(inputs) + current_app.logger.debug(f"State: {self.state}") + result = await super().kickoff_async(inputs) + return self.state diff --git a/migrations/public/versions/200bda7f5251_add_tenantmake_model.py b/migrations/public/versions/200bda7f5251_add_tenantmake_model.py new file mode 100644 index 0000000..1abf3fd --- /dev/null +++ b/migrations/public/versions/200bda7f5251_add_tenantmake_model.py @@ -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 ### diff --git a/migrations/public/versions/b6146237f298_add_chat_configuration_options_to_.py b/migrations/public/versions/b6146237f298_add_chat_configuration_options_to_.py new file mode 100644 index 0000000..4846bf3 --- /dev/null +++ b/migrations/public/versions/b6146237f298_add_chat_configuration_options_to_.py @@ -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 ### diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 1766f96..353c6d8 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -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; diff --git a/nginx/static/css/eveai-chat-style.css b/nginx/static/css/eveai-chat-style.css new file mode 100644 index 0000000..e69de29 diff --git a/scripts/run_eveai_chat.py b/scripts/run_eveai_chat_client.py similarity index 74% rename from scripts/run_eveai_chat.py rename to scripts/run_eveai_chat_client.py index 5f6261f..81c312f 100644 --- a/scripts/run_eveai_chat.py +++ b/scripts/run_eveai_chat_client.py @@ -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() diff --git a/scripts/start_eveai_chat.sh b/scripts/start_eveai_chat.sh deleted file mode 100755 index 06d7a48..0000000 --- a/scripts/start_eveai_chat.sh +++ /dev/null @@ -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 diff --git a/scripts/start_eveai_chat_client.sh b/scripts/start_eveai_chat_client.sh new file mode 100755 index 0000000..84b4271 --- /dev/null +++ b/scripts/start_eveai_chat_client.sh @@ -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