33 Commits

Author SHA1 Message Date
Josako
043cea45f2 Changelog update for 2.3.7 2025-06-23 11:51:52 +02:00
Josako
7b87880045 - Full Traicie Selection Specialist Flow implemented
- Added Specialist basics for handling phases and automatically transferring data between state and output
- Added QR-code generation for Magic Links
2025-06-23 11:46:56 +02:00
Josako
5b2c04501c - logging improvement and simplification (no more graylog)
- Traicie Selection Specialist Round Trip
- Session improvements + debugging enabled
- Tone of Voice & Langauge Level definitions introduced
2025-06-20 07:58:06 +02:00
Josako
babcd6ec04 Changelog update for 2.3.6-alfa 2025-06-16 11:10:59 +02:00
Josako
71adf64668 - Verbeterde versie Selectie Specialist - voor demo (1.2) 2025-06-16 11:06:20 +02:00
Josako
dbea41451a - Aanpassingen aan opbouw specialist historiek
- Nieuwe versie van de selectie specialist "Fake it till you Make it" ;-)
2025-06-15 18:31:13 +02:00
Josako
82e25b356c Chat client changes
- Form values shown correct in MessageHistory of Chat client
- Improements to CSS
- Move css en js to assets directory
- Introduce better Personal Contact Form & Professional Contact Form
- Start working on actual Selection Specialist
2025-06-15 05:25:00 +02:00
Josako
3c7460f741 Form in ChatInput are displayed correctly! 2025-06-13 20:30:56 +02:00
Josako
2835486599 Eerste goed werkende versie van een formulier in de chat input. 2025-06-13 17:27:49 +02:00
Josako
f1c60f9574 tussentijdse status voor significante wijzigingen. Bezig aan creatie Dynamic Form in de chat client. 2025-06-13 14:19:05 +02:00
Josako
b326c0c6f2 Chat Client changes:
- maximum width for input and message history
- ensure good display for sidebar explanation
2025-06-13 00:56:22 +02:00
Josako
5f1a5711f6 - Build of the Chat Client using Vue.js
- Accompanying css
- Views to serve the Chat Client
- first test version of the TRACIE_SELECTION_SPECIALIST
- ESS Implemented.
2025-06-12 18:21:51 +02:00
Josako
67ceb57b79 - Changelog to 2.3.5-alfa 2025-06-10 20:57:07 +02:00
Josako
23b49516cb - Create framework for chat-client, including logo, explanatory text, color settings, ...
- remove allowed_langages from tenant
- Correct bugs in Tenant, TenantMake, SpecialistMagicLink
- Change chat client customisation elements
2025-06-10 20:52:01 +02:00
Josako
9cc266b97f - Corrections to tenant, catalog, and tenant_make
- Clean-up of tenant elements
- ensure the chat_client get's it's initial call rifht.
2025-06-10 16:10:08 +02:00
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
Josako
57c0e7a1ba Update changelog 2025-06-04 13:35:27 +02:00
Josako
0d05499d2b - Add Specialist Magic Links
- correction of some bugs:
  - dynamic fields for adding documents / urls to dossier catalog
  - tabs in latest bootstrap version no longer functional
  - partner association of license tier not working when no partner selected
  - data-type dynamic field needs conversion to isoformat
  - Add public tables to env.py of tenant schema
2025-06-04 11:53:35 +02:00
Josako
b4e58659a8 - Allow and improve viewing of different content types. First type implemented: changelog 2025-06-03 09:48:50 +02:00
Josako
67078ce925 - Add view to show release notes
- Update release notes for 2.3.1-alfa
2025-06-01 22:03:32 +02:00
Josako
ebdb836448 - Add view to show release notes
- Update release notes for 2.3.1-alfa
2025-06-01 22:03:15 +02:00
Josako
81e754317a - smaller changes to eveai.css to ensure background of selected buttons do not get all white and to ensure that the background of fiels in editable cells do not become white in a tabulator.
- The Role Definition Specialist now creates a new selection specialist upon completion
2025-06-01 10:09:34 +02:00
Josako
578981c745 Present the Specialist Editor in 1 screen 2025-05-30 12:51:34 +02:00
Josako
8fb2ad43c5 Moved styling elements in eveai_ordered_list_editor.html to eveai.css for consistency 2025-05-30 10:04:39 +02:00
Josako
49f9077a7b Improvement of the color scheme of the table editor. 2025-05-30 09:48:42 +02:00
Josako
d290b46a0c Improvement of the color scheme of the table editor. 2025-05-30 05:20:25 +02:00
Josako
73647e4795 We have a reasonable layout for the table-editor in the specialist. To be further refined. 2025-05-30 05:05:13 +02:00
Josako
25e169dbea - Replace old implementation of PROCESSOR_TYPES and CATALOG_TYPES with the new cached approach
- Add an ordered_list dynamic field type (to be refined)
- Add tabulator javascript library to project
2025-05-29 16:00:25 +02:00
170 changed files with 12351 additions and 908 deletions

19
.aiignore Normal file
View File

@@ -0,0 +1,19 @@
# An .aiignore file follows the same syntax as a .gitignore file.
# .gitignore documentation: https://git-scm.com/docs/gitignore
# you can ignore files
.DS_Store
*.log
*.tmp
# or folders
dist/
build/
out/
nginx/node_modules/
nginx/static/
db_backups/
docker/eveai_logs/
docker/logs/
docker/minio/

4
.gitignore vendored
View File

@@ -53,3 +53,7 @@ scripts/__pycache__/run_eveai_app.cpython-312.pyc
/docker/grafana/data/ /docker/grafana/data/
/temp_requirements/ /temp_requirements/
/nginx/node_modules/ /nginx/node_modules/
/nginx/static/assets/css/chat.css
/nginx/static/assets/css/chat-components.css
/nginx/static/assets/js/components/
/nginx/static/assets/js/chat-app.js

67
README.md.k8s-logging Normal file
View File

@@ -0,0 +1,67 @@
# Kubernetes Logging Upgrade
## Overzicht
Deze instructies beschrijven hoe je alle services moet bijwerken om de nieuwe logging configuratie te gebruiken die zowel compatibel is met traditionele bestandsgebaseerde logging (voor ontwikkeling/test) als met Kubernetes (voor productie).
## Stappen voor elke service
Pas de volgende wijzigingen toe in elk van de volgende services:
- eveai_app
- eveai_workers
- eveai_api
- eveai_chat_client
- eveai_chat_workers
- eveai_beat
- eveai_entitlements
### 1. Update de imports
Verander:
```python
from config.logging_config import LOGGING
```
Naar:
```python
from config.logging_config import configure_logging
```
### 2. Update de logging configuratie
Verander:
```python
logging.config.dictConfig(LOGGING)
```
Naar:
```python
configure_logging()
```
## Dockerfile Aanpassingen
Voeg de volgende regels toe aan je Dockerfile voor elke service om de Kubernetes-specifieke logging afhankelijkheden te installeren (alleen voor productie):
```dockerfile
# Alleen voor productie (Kubernetes) builds
COPY requirements-k8s.txt /app/
RUN if [ "$ENVIRONMENT" = "production" ]; then pip install -r requirements-k8s.txt; fi
```
## Kubernetes Deployment
Zorg ervoor dat je Kubernetes deployment manifests de volgende omgevingsvariabele bevatten:
```yaml
env:
- name: FLASK_ENV
value: "production"
```
## Voordelen
1. De code detecteert automatisch of deze in Kubernetes draait
2. In ontwikkeling/test omgevingen blijft alles naar bestanden schrijven
3. In Kubernetes gaan logs naar stdout/stderr in JSON-formaat
4. Geen wijzigingen nodig in bestaande logger code in de applicatie

View File

@@ -0,0 +1,6 @@
<!-- chat.html -->
{% extends "base.html" %}
{% block title %}{{ tenant_make.name|default('EveAI') }} - AI Chat{% endblock %}
{% block head %}

View File

@@ -11,6 +11,7 @@ from flask_restx import Api
from prometheus_flask_exporter import PrometheusMetrics from prometheus_flask_exporter import PrometheusMetrics
from .utils.cache.eveai_cache_manager import EveAICacheManager from .utils.cache.eveai_cache_manager import EveAICacheManager
from .utils.content_utils import ContentManager
from .utils.simple_encryption import SimpleEncryption from .utils.simple_encryption import SimpleEncryption
from .utils.minio_utils import MinioClient from .utils.minio_utils import MinioClient
@@ -30,4 +31,5 @@ simple_encryption = SimpleEncryption()
minio_client = MinioClient() minio_client = MinioClient()
metrics = PrometheusMetrics.for_app_factory() metrics = PrometheusMetrics.for_app_factory()
cache_manager = EveAICacheManager() cache_manager = EveAICacheManager()
content_manager = ContentManager()

View File

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

View File

@@ -1,7 +1,7 @@
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from ..extensions import db from ..extensions import db
from .user import User, Tenant from .user import User, Tenant, TenantMake
from .document import Embedding, Retriever from .document import Embedding, Retriever
@@ -29,6 +29,7 @@ class Specialist(db.Model):
tuning = db.Column(db.Boolean, nullable=True, default=False) tuning = db.Column(db.Boolean, nullable=True, default=False)
configuration = db.Column(JSONB, nullable=True) configuration = db.Column(JSONB, nullable=True)
arguments = 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 # Relationship to retrievers through the association table
retrievers = db.relationship('SpecialistRetriever', backref='specialist', lazy=True, retrievers = db.relationship('SpecialistRetriever', backref='specialist', lazy=True,
@@ -44,6 +45,21 @@ class Specialist(db.Model):
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now()) updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now())
updated_by = db.Column(db.Integer, db.ForeignKey(User.id)) updated_by = db.Column(db.Integer, db.ForeignKey(User.id))
def __repr__(self):
return f"<Specialist {self.id}: {self.name}>"
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'description': self.description,
'type': self.type,
'type_version': self.type_version,
'configuration': self.configuration,
'arguments': self.arguments,
'active': self.active,
}
class EveAIAsset(db.Model): class EveAIAsset(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@@ -215,3 +231,36 @@ class SpecialistDispatcher(db.Model):
dispatcher_id = db.Column(db.Integer, db.ForeignKey(Dispatcher.id, ondelete='CASCADE'), primary_key=True) dispatcher_id = db.Column(db.Integer, db.ForeignKey(Dispatcher.id, ondelete='CASCADE'), primary_key=True)
dispatcher = db.relationship("Dispatcher", backref="specialist_dispatchers") dispatcher = db.relationship("Dispatcher", backref="specialist_dispatchers")
class SpecialistMagicLink(db.Model):
id = db.Column(db.Integer, primary_key=True)
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)
valid_to = db.Column(db.DateTime, nullable=True)
specialist_args = db.Column(JSONB, nullable=True)
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
created_by = db.Column(db.Integer, db.ForeignKey(User.id), nullable=True)
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now())
updated_by = db.Column(db.Integer, db.ForeignKey(User.id))
def __repr__(self):
return f"<SpecialistMagicLink {self.specialist_id} {self.magic_link_code}>"
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'description': self.description,
'magic_link_code': self.magic_link_code,
'valid_from': self.valid_from,
'valid_to': self.valid_to,
'specialist_args': self.specialist_args,
}

View File

@@ -2,7 +2,7 @@ from datetime import date
from common.extensions import db from common.extensions import db
from flask_security import UserMixin, RoleMixin from flask_security import UserMixin, RoleMixin
from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.dialects.postgresql import ARRAY, JSONB
import sqlalchemy as sa import sqlalchemy as sa
from common.models.entitlements import License from common.models.entitlements import License
@@ -28,17 +28,19 @@ class Tenant(db.Model):
# language information # language information
default_language = db.Column(db.String(2), nullable=True) default_language = db.Column(db.String(2), nullable=True)
allowed_languages = db.Column(ARRAY(sa.String(2)), nullable=True)
# Entitlements # Entitlements
currency = db.Column(db.String(20), nullable=True) currency = db.Column(db.String(20), nullable=True)
storage_dirty = db.Column(db.Boolean, nullable=True, default=False) 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 # Relations
users = db.relationship('User', backref='tenant') users = db.relationship('User', backref='tenant')
domains = db.relationship('TenantDomain', backref='tenant') domains = db.relationship('TenantDomain', backref='tenant')
licenses = db.relationship('License', back_populates='tenant') licenses = db.relationship('License', back_populates='tenant')
license_usages = db.relationship('LicenseUsage', backref='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 @property
def current_license(self): def current_license(self):
@@ -60,8 +62,8 @@ class Tenant(db.Model):
'timezone': self.timezone, 'timezone': self.timezone,
'type': self.type, 'type': self.type,
'default_language': self.default_language, 'default_language': self.default_language,
'allowed_languages': self.allowed_languages,
'currency': self.currency, 'currency': self.currency,
'default_tenant_make_id': self.default_tenant_make_id,
} }
@@ -173,6 +175,42 @@ class TenantProject(db.Model):
return f"<TenantProject {self.id}: {self.name}>" 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'))
def __repr__(self):
return f"<TenantMake {self.id} for tenant {self.tenant_id}: {self.name}>"
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'description': self.description,
'active': self.active,
'website': self.website,
'logo_url': self.logo_url,
'chat_customisation_options': self.chat_customisation_options,
}
class Partner(db.Model): class Partner(db.Model):
__bind_key__ = 'public' __bind_key__ = 'public'
__table_args__ = {'schema': 'public'} __table_args__ = {'schema': 'public'}
@@ -271,3 +309,11 @@ class PartnerTenant(db.Model):
created_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True) 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_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'), nullable=True) updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
class SpecialistMagicLinkTenant(db.Model):
__bind_key__ = 'public'
__table_args__ = {'schema': 'public'}
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

@@ -6,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
from common.extensions import db from common.extensions import db
from common.models.entitlements import PartnerServiceLicenseTier from common.models.entitlements import PartnerServiceLicenseTier
from common.models.user import Partner from common.models.user import Partner
from common.utils.eveai_exceptions import EveAINoManagementPartnerService from common.utils.eveai_exceptions import EveAINoManagementPartnerService, EveAINoSessionPartner
from common.utils.model_logging_utils import set_logging_information from common.utils.model_logging_utils import set_logging_information
@@ -19,7 +19,7 @@ class LicenseTierServices:
# Get partner service (MANAGEMENT_SERVICE type) # Get partner service (MANAGEMENT_SERVICE type)
partner = Partner.query.get(partner_id) partner = Partner.query.get(partner_id)
if not partner: if not partner:
return raise EveAINoSessionPartner()
# Find a management service for this partner # Find a management service for this partner
management_service = next((service for service in session['partner']['services'] management_service = next((service for service in session['partner']['services']

View File

@@ -1,7 +1,15 @@
import uuid import uuid
from typing import Dict, Any, Tuple from datetime import datetime as dt, timezone as tz
from typing import Dict, Any, Tuple, Optional
from flask import current_app
from sqlalchemy.exc import SQLAlchemyError
from common.extensions import db, cache_manager
from common.models.interaction import (
Specialist, EveAIAgent, EveAITask, EveAITool
)
from common.utils.celery_utils import current_celery from common.utils.celery_utils import current_celery
from common.utils.model_logging_utils import set_logging_information, update_logging_information
class SpecialistServices: class SpecialistServices:
@@ -27,4 +35,203 @@ class SpecialistServices:
'status': 'queued', 'status': 'queued',
} }
@staticmethod
def initialize_specialist(specialist_id: int, specialist_type: str, specialist_version: str):
"""
Initialize an agentic specialist by creating all its components based on configuration.
Args:
specialist_id: ID of the specialist to initialize
specialist_type: Type of the specialist
specialist_version: Version of the specialist type to use
Raises:
ValueError: If specialist not found or invalid configuration
SQLAlchemyError: If database operations fail
"""
config = cache_manager.specialists_config_cache.get_config(specialist_type, specialist_version)
if not config:
raise ValueError(f"No configuration found for {specialist_type} version {specialist_version}")
if config['framework'] == 'langchain':
pass # Langchain does not require additional items to be initialized. All configuration is in the specialist.
specialist = Specialist.query.get(specialist_id)
if not specialist:
raise ValueError(f"Specialist with ID {specialist_id} not found")
if config['framework'] == 'crewai':
SpecialistServices.initialize_crewai_specialist(specialist, config)
@staticmethod
def initialize_crewai_specialist(specialist: Specialist, config: Dict[str, Any]):
timestamp = dt.now(tz=tz.utc)
try:
# Initialize agents
if 'agents' in config:
for agent_config in config['agents']:
SpecialistServices._create_agent(
specialist_id=specialist.id,
agent_type=agent_config['type'],
agent_version=agent_config['version'],
name=agent_config.get('name'),
description=agent_config.get('description'),
timestamp=timestamp
)
# Initialize tasks
if 'tasks' in config:
for task_config in config['tasks']:
SpecialistServices._create_task(
specialist_id=specialist.id,
task_type=task_config['type'],
task_version=task_config['version'],
name=task_config.get('name'),
description=task_config.get('description'),
timestamp=timestamp
)
# Initialize tools
if 'tools' in config:
for tool_config in config['tools']:
SpecialistServices._create_tool(
specialist_id=specialist.id,
tool_type=tool_config['type'],
tool_version=tool_config['version'],
name=tool_config.get('name'),
description=tool_config.get('description'),
timestamp=timestamp
)
db.session.commit()
current_app.logger.info(f"Successfully initialized crewai specialist {specialist.id}")
except SQLAlchemyError as e:
db.session.rollback()
current_app.logger.error(f"Database error initializing crewai specialist {specialist.id}: {str(e)}")
raise
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error initializing crewai specialist {specialist.id}: {str(e)}")
raise
@staticmethod
def _create_agent(
specialist_id: int,
agent_type: str,
agent_version: str,
name: Optional[str] = None,
description: Optional[str] = None,
timestamp: Optional[dt] = None
) -> EveAIAgent:
"""Create an agent with the given configuration."""
if timestamp is None:
timestamp = dt.now(tz=tz.utc)
# Get agent configuration from cache
agent_config = cache_manager.agents_config_cache.get_config(agent_type, agent_version)
agent = EveAIAgent(
specialist_id=specialist_id,
name=name or agent_config.get('name', agent_type),
description=description or agent_config.get('metadata').get('description', ''),
type=agent_type,
type_version=agent_version,
role=None,
goal=None,
backstory=None,
tuning=False,
configuration=None,
arguments=None
)
set_logging_information(agent, timestamp)
db.session.add(agent)
current_app.logger.info(f"Created agent {agent.id} of type {agent_type}")
return agent
@staticmethod
def _create_task(
specialist_id: int,
task_type: str,
task_version: str,
name: Optional[str] = None,
description: Optional[str] = None,
timestamp: Optional[dt] = None
) -> EveAITask:
"""Create a task with the given configuration."""
if timestamp is None:
timestamp = dt.now(tz=tz.utc)
# Get task configuration from cache
task_config = cache_manager.tasks_config_cache.get_config(task_type, task_version)
task = EveAITask(
specialist_id=specialist_id,
name=name or task_config.get('name', task_type),
description=description or task_config.get('metadata').get('description', ''),
type=task_type,
type_version=task_version,
task_description=None,
expected_output=None,
tuning=False,
configuration=None,
arguments=None,
context=None,
asynchronous=False,
)
set_logging_information(task, timestamp)
db.session.add(task)
current_app.logger.info(f"Created task {task.id} of type {task_type}")
return task
@staticmethod
def _create_tool(
specialist_id: int,
tool_type: str,
tool_version: str,
name: Optional[str] = None,
description: Optional[str] = None,
timestamp: Optional[dt] = None
) -> EveAITool:
"""Create a tool with the given configuration."""
if timestamp is None:
timestamp = dt.now(tz=tz.utc)
# Get tool configuration from cache
tool_config = cache_manager.tools_config_cache.get_config(tool_type, tool_version)
tool = EveAITool(
specialist_id=specialist_id,
name=name or tool_config.get('name', tool_type),
description=description or tool_config.get('metadata').get('description', ''),
type=tool_type,
type_version=tool_version,
tuning=False,
configuration=None,
arguments=None,
)
set_logging_information(tool, timestamp)
db.session.add(tool)
current_app.logger.info(f"Created tool {tool.id} of type {tool_type}")
return tool
@staticmethod
def get_specialist_system_field(specialist_id, config_name, system_name):
"""Get the value of a system field in a specialist's configuration. Returns the actual value, or None."""
specialist = Specialist.query.get(specialist_id)
if not specialist:
raise ValueError(f"Specialist with ID {specialist_id} not found")
config = cache_manager.specialists_config_cache.get_config(specialist.type, specialist.type_version)
if not config:
raise ValueError(f"No configuration found for {specialist.type} version {specialist.version}")
potential_field = config.get(config_name, None)
if potential_field:
if potential_field.type == 'system' and potential_field.system_name == system_name:
return specialist.configuration.get(config_name, None)
return None

View File

@@ -28,7 +28,7 @@ class TenantServices:
if service.get('type') == 'MANAGEMENT_SERVICE'), None) if service.get('type') == 'MANAGEMENT_SERVICE'), None)
if not management_service: if not management_service:
current_app.logger.error(f"No Management Service defined for partner {partner_id}" current_app.logger.error(f"No Management Service defined for partner {partner_id} "
f"while associating tenant {tenant_id} with partner.") f"while associating tenant {tenant_id} with partner.")
raise EveAINoManagementPartnerService() raise EveAINoManagementPartnerService()

View File

@@ -7,7 +7,7 @@ from flask import current_app
from common.utils.cache.base import CacheHandler, CacheKey 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, \ from config.type_defs import agent_types, task_types, tool_types, specialist_types, retriever_types, prompt_types, \
catalog_types, partner_service_types catalog_types, partner_service_types, processor_types, customisation_types, specialist_form_types
def is_major_minor(version: str) -> bool: def is_major_minor(version: str) -> bool:
@@ -59,7 +59,7 @@ class BaseConfigCacheHandler(CacheHandler[Dict[str, Any]]):
"""Set the version tree cache dependency.""" """Set the version tree cache dependency."""
self.version_tree_cache = cache self.version_tree_cache = cache
def _load_specific_config(self, type_name: str, version_str: str) -> Dict[str, Any]: def _load_specific_config(self, type_name: str, version_str: str = 'latest') -> Dict[str, Any]:
""" """
Load a specific configuration version Load a specific configuration version
Automatically handles global vs partner-specific configs Automatically handles global vs partner-specific configs
@@ -456,7 +456,13 @@ CatalogConfigCacheHandler, CatalogConfigVersionTreeCacheHandler, CatalogConfigTy
types_module=catalog_types.CATALOG_TYPES types_module=catalog_types.CATALOG_TYPES
)) ))
# Add to common/utils/cache/config_cache.py ProcessorConfigCacheHandler, ProcessorConfigVersionTreeCacheHandler, ProcessorConfigTypesCacheHandler = (
create_config_cache_handlers(
config_type='processors',
config_dir='config/processors',
types_module=processor_types.PROCESSOR_TYPES
))
PartnerServiceConfigCacheHandler, PartnerServiceConfigVersionTreeCacheHandler, PartnerServiceConfigTypesCacheHandler = ( PartnerServiceConfigCacheHandler, PartnerServiceConfigVersionTreeCacheHandler, PartnerServiceConfigTypesCacheHandler = (
create_config_cache_handlers( create_config_cache_handlers(
config_type='partner_services', config_type='partner_services',
@@ -464,6 +470,22 @@ PartnerServiceConfigCacheHandler, PartnerServiceConfigVersionTreeCacheHandler, P
types_module=partner_service_types.PARTNER_SERVICE_TYPES 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
)
)
SpecialistFormConfigCacheHandler, SpecialistFormConfigVersionTreeCacheHandler, SpecialistFormConfigTypesCacheHandler = (
create_config_cache_handlers(
config_type='specialist_forms',
config_dir='config/specialist_forms',
types_module=specialist_form_types.SPECIALIST_FORM_TYPES
)
)
def register_config_cache_handlers(cache_manager) -> None: def register_config_cache_handlers(cache_manager) -> None:
cache_manager.register_handler(AgentConfigCacheHandler, 'eveai_config') cache_manager.register_handler(AgentConfigCacheHandler, 'eveai_config')
@@ -487,12 +509,21 @@ def register_config_cache_handlers(cache_manager) -> None:
cache_manager.register_handler(CatalogConfigCacheHandler, 'eveai_config') cache_manager.register_handler(CatalogConfigCacheHandler, 'eveai_config')
cache_manager.register_handler(CatalogConfigTypesCacheHandler, 'eveai_config') cache_manager.register_handler(CatalogConfigTypesCacheHandler, 'eveai_config')
cache_manager.register_handler(CatalogConfigVersionTreeCacheHandler, 'eveai_config') cache_manager.register_handler(CatalogConfigVersionTreeCacheHandler, 'eveai_config')
cache_manager.register_handler(ProcessorConfigCacheHandler, 'eveai_config')
cache_manager.register_handler(ProcessorConfigTypesCacheHandler, 'eveai_config')
cache_manager.register_handler(ProcessorConfigVersionTreeCacheHandler, 'eveai_config')
cache_manager.register_handler(AgentConfigCacheHandler, 'eveai_config') cache_manager.register_handler(AgentConfigCacheHandler, 'eveai_config')
cache_manager.register_handler(AgentConfigTypesCacheHandler, 'eveai_config') cache_manager.register_handler(AgentConfigTypesCacheHandler, 'eveai_config')
cache_manager.register_handler(AgentConfigVersionTreeCacheHandler, 'eveai_config') cache_manager.register_handler(AgentConfigVersionTreeCacheHandler, 'eveai_config')
cache_manager.register_handler(PartnerServiceConfigCacheHandler, 'eveai_config') cache_manager.register_handler(PartnerServiceConfigCacheHandler, 'eveai_config')
cache_manager.register_handler(PartnerServiceConfigTypesCacheHandler, 'eveai_config') cache_manager.register_handler(PartnerServiceConfigTypesCacheHandler, 'eveai_config')
cache_manager.register_handler(PartnerServiceConfigVersionTreeCacheHandler, '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.register_handler(SpecialistFormConfigCacheHandler, 'eveai_config')
cache_manager.register_handler(SpecialistFormConfigTypesCacheHandler, 'eveai_config')
cache_manager.register_handler(SpecialistFormConfigVersionTreeCacheHandler, 'eveai_config')
cache_manager.agents_config_cache.set_version_tree_cache(cache_manager.agents_version_tree_cache) 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) cache_manager.tasks_config_cache.set_version_tree_cache(cache_manager.tasks_version_tree_cache)
@@ -500,4 +531,8 @@ def register_config_cache_handlers(cache_manager) -> None:
cache_manager.specialists_config_cache.set_version_tree_cache(cache_manager.specialists_version_tree_cache) cache_manager.specialists_config_cache.set_version_tree_cache(cache_manager.specialists_version_tree_cache)
cache_manager.retrievers_config_cache.set_version_tree_cache(cache_manager.retrievers_version_tree_cache) cache_manager.retrievers_config_cache.set_version_tree_cache(cache_manager.retrievers_version_tree_cache)
cache_manager.prompts_config_cache.set_version_tree_cache(cache_manager.prompts_version_tree_cache) cache_manager.prompts_config_cache.set_version_tree_cache(cache_manager.prompts_version_tree_cache)
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.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)
cache_manager.specialist_forms_config_cache.set_version_tree_cache(cache_manager.specialist_forms_version_tree_cache)

View File

@@ -0,0 +1,45 @@
"""
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',
'sidebar_background': '#2c3e50',
'gradient_start_color': '#f5f7fa',
'gradient_end_color': '#c3cfe2',
'markdown_background_color': 'transparent',
'markdown_text_color': '#ffffff',
'sidebar_markdown': '',
'welcome_message': 'Hello! How can I help you today?',
}
# 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') @field_validator('type', mode='before')
@classmethod @classmethod
def validate_type(cls, v: str) -> str: 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: if v not in valid_types:
raise ValueError(f'type must be one of {valid_types}') raise ValueError(f'type must be one of {valid_types}')
return v return v
@@ -243,7 +243,7 @@ class ArgumentDefinition(BaseModel):
@field_validator('type') @field_validator('type')
@classmethod @classmethod
def validate_type(cls, v: str) -> str: 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: if v not in valid_types:
raise ValueError(f'type must be one of {valid_types}') raise ValueError(f'type must be one of {valid_types}')
return v return v
@@ -256,7 +256,8 @@ class ArgumentDefinition(BaseModel):
'integer': NumericConstraint, 'integer': NumericConstraint,
'float': NumericConstraint, 'float': NumericConstraint,
'date': DateConstraint, 'date': DateConstraint,
'enum': EnumConstraint 'enum': EnumConstraint,
'color': StringConstraint
} }
expected_type = expected_constraint_types.get(self.type) expected_type = expected_constraint_types.get(self.type)

View File

@@ -0,0 +1,215 @@
import os
import re
import logging
from packaging import version
from flask import current_app
logger = logging.getLogger(__name__)
class ContentManager:
def __init__(self, app=None):
self.app = app
if app:
self.init_app(app)
def init_app(self, app):
self.app = app
# Controleer of het pad bestaat
if not os.path.exists(app.config['CONTENT_DIR']):
logger.warning(f"Content directory not found at: {app.config['CONTENT_DIR']}")
else:
logger.info(f"Content directory configured at: {app.config['CONTENT_DIR']}")
def get_content_path(self, content_type, major_minor=None, patch=None):
"""
Geef het volledige pad naar een contentbestand
Args:
content_type (str): Type content (bv. 'changelog', 'terms')
major_minor (str, optional): Major.Minor versie (bv. '1.0')
patch (str, optional): Patchnummer (bv. '5')
Returns:
str: Volledige pad naar de content map of bestand
"""
content_path = os.path.join(self.app.config['CONTENT_DIR'], content_type)
if major_minor:
content_path = os.path.join(content_path, major_minor)
if patch:
content_path = os.path.join(content_path, f"{major_minor}.{patch}.md")
return content_path
def _parse_version(self, filename):
"""Parse een versienummer uit een bestandsnaam"""
match = re.match(r'(\d+\.\d+)\.(\d+)\.md', filename)
if match:
return match.group(1), match.group(2)
return None, None
def get_latest_version(self, content_type, major_minor=None):
"""
Verkrijg de laatste versie van een bepaald contenttype
Args:
content_type (str): Type content (bv. 'changelog', 'terms')
major_minor (str, optional): Specifieke major.minor versie, anders de hoogste
Returns:
tuple: (major_minor, patch, full_version) of None als niet gevonden
"""
try:
# Basispad voor dit contenttype
content_path = os.path.join(self.app.config['CONTENT_DIR'], content_type)
if not os.path.exists(content_path):
logger.error(f"Content path does not exist: {content_path}")
return None
# Als geen major_minor opgegeven, vind de hoogste
if not major_minor:
available_versions = os.listdir(content_path)
if not available_versions:
return None
# Sorteer op versienummer (major.minor)
available_versions.sort(key=lambda v: version.parse(v))
major_minor = available_versions[-1]
# Nu we major_minor hebben, zoek de hoogste patch
major_minor_path = os.path.join(content_path, major_minor)
if not os.path.exists(major_minor_path):
logger.error(f"Version path does not exist: {major_minor_path}")
return None
files = os.listdir(major_minor_path)
version_files = []
for file in files:
mm, p = self._parse_version(file)
if mm == major_minor and p:
version_files.append((mm, p, f"{mm}.{p}"))
if not version_files:
return None
# Sorteer op patch nummer
version_files.sort(key=lambda v: int(v[1]))
return version_files[-1]
except Exception as e:
logger.error(f"Error finding latest version for {content_type}: {str(e)}")
return None
def read_content(self, content_type, major_minor=None, patch=None):
"""
Lees content met versieondersteuning
Als major_minor en patch niet zijn opgegeven, wordt de laatste versie gebruikt.
Als alleen major_minor is opgegeven, wordt de laatste patch van die versie gebruikt.
Args:
content_type (str): Type content (bv. 'changelog', 'terms')
major_minor (str, optional): Major.Minor versie (bv. '1.0')
patch (str, optional): Patchnummer (bv. '5')
Returns:
dict: {
'content': str,
'version': str,
'content_type': str
} of None bij fout
"""
try:
# Als geen versie opgegeven, vind de laatste
if not major_minor:
version_info = self.get_latest_version(content_type)
if not version_info:
logger.error(f"No versions found for {content_type}")
return None
major_minor, patch, full_version = version_info
# Als geen patch opgegeven, vind de laatste patch voor deze major_minor
elif not patch:
version_info = self.get_latest_version(content_type, major_minor)
if not version_info:
logger.error(f"No versions found for {content_type} {major_minor}")
return None
major_minor, patch, full_version = version_info
else:
full_version = f"{major_minor}.{patch}"
# Nu hebben we major_minor en patch, lees het bestand
file_path = self.get_content_path(content_type, major_minor, patch)
if not os.path.exists(file_path):
logger.error(f"Content file does not exist: {file_path}")
return None
with open(file_path, 'r', encoding='utf-8') as file:
content = file.read()
return {
'content': content,
'version': full_version,
'content_type': content_type
}
except Exception as e:
logger.error(f"Error reading content {content_type} {major_minor}.{patch}: {str(e)}")
return None
def list_content_types(self):
"""Lijst alle beschikbare contenttypes op"""
try:
return [d for d in os.listdir(self.app.config['CONTENT_DIR'])
if os.path.isdir(os.path.join(self.app.config['CONTENT_DIR'], d))]
except Exception as e:
logger.error(f"Error listing content types: {str(e)}")
return []
def list_versions(self, content_type):
"""
Lijst alle beschikbare versies voor een contenttype
Returns:
list: Lijst van dicts met versie-informatie
[{'version': '1.0.0', 'path': '/path/to/file', 'date_modified': datetime}]
"""
versions = []
try:
content_path = os.path.join(self.app.config['CONTENT_DIR'], content_type)
if not os.path.exists(content_path):
return []
for major_minor in os.listdir(content_path):
major_minor_path = os.path.join(content_path, major_minor)
if not os.path.isdir(major_minor_path):
continue
for file in os.listdir(major_minor_path):
mm, p = self._parse_version(file)
if mm and p:
file_path = os.path.join(major_minor_path, file)
mod_time = os.path.getmtime(file_path)
versions.append({
'version': f"{mm}.{p}",
'path': file_path,
'date_modified': mod_time
})
# Sorteer op versienummer
versions.sort(key=lambda v: version.parse(v['version']))
return versions
except Exception as e:
logger.error(f"Error listing versions for {content_type}: {str(e)}")
return []

View File

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

View File

@@ -1,3 +1,4 @@
import traceback import traceback
import jinja2 import jinja2
@@ -12,6 +13,7 @@ def not_found_error(error):
if not current_user.is_authenticated: if not current_user.is_authenticated:
return redirect(prefixed_url_for('security.login')) return redirect(prefixed_url_for('security.login'))
current_app.logger.error(f"Not Found Error: {error}") current_app.logger.error(f"Not Found Error: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error/404.html'), 404 return render_template('error/404.html'), 404
@@ -19,6 +21,7 @@ def internal_server_error(error):
if not current_user.is_authenticated: if not current_user.is_authenticated:
return redirect(prefixed_url_for('security.login')) return redirect(prefixed_url_for('security.login'))
current_app.logger.error(f"Internal Server Error: {error}") current_app.logger.error(f"Internal Server Error: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error/500.html'), 500 return render_template('error/500.html'), 500
@@ -26,6 +29,7 @@ def not_authorised_error(error):
if not current_user.is_authenticated: if not current_user.is_authenticated:
return redirect(prefixed_url_for('security.login')) return redirect(prefixed_url_for('security.login'))
current_app.logger.error(f"Not Authorised Error: {error}") current_app.logger.error(f"Not Authorised Error: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error/401.html') return render_template('error/401.html')
@@ -33,6 +37,7 @@ def access_forbidden(error):
if not current_user.is_authenticated: if not current_user.is_authenticated:
return redirect(prefixed_url_for('security.login')) return redirect(prefixed_url_for('security.login'))
current_app.logger.error(f"Access Forbidden: {error}") current_app.logger.error(f"Access Forbidden: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error/403.html') return render_template('error/403.html')
@@ -42,6 +47,7 @@ def key_error_handler(error):
return redirect(prefixed_url_for('security.login')) return redirect(prefixed_url_for('security.login'))
# For other KeyErrors, you might want to log the error and return a generic error page # For other KeyErrors, you might want to log the error and return a generic error page
current_app.logger.error(f"Key Error: {error}") current_app.logger.error(f"Key Error: {error}")
current_app.logger.error(traceback.format_exc())
return render_template('error/generic.html', error_message="An unexpected error occurred"), 500 return render_template('error/generic.html', error_message="An unexpected error occurred"), 500
@@ -76,6 +82,7 @@ def no_tenant_selected_error(error):
a long period of inactivity. The user will be redirected to the login page. a long period of inactivity. The user will be redirected to the login page.
""" """
current_app.logger.error(f"No Session Tenant Error: {error}") current_app.logger.error(f"No Session Tenant Error: {error}")
current_app.logger.error(traceback.format_exc())
flash('Your session expired. You will have to re-enter your credentials', 'warning') flash('Your session expired. You will have to re-enter your credentials', 'warning')
# Perform logout if user is authenticated # Perform logout if user is authenticated
@@ -95,6 +102,26 @@ def general_exception(e):
error_details=str(e)), 500 error_details=str(e)), 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/500.html',
error_type="Template Not Found",
error_details=f"Template '{error.name}' could not be 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/500.html',
error_type="Template Syntax Error",
error_details=f"Error in template '{error.filename}' at line {error.lineno}: {error.message}"), 500
def register_error_handlers(app): def register_error_handlers(app):
app.register_error_handler(404, not_found_error) app.register_error_handler(404, not_found_error)
app.register_error_handler(500, internal_server_error) app.register_error_handler(500, internal_server_error)
@@ -103,17 +130,6 @@ def register_error_handlers(app):
app.register_error_handler(EveAINoSessionTenant, no_tenant_selected_error) app.register_error_handler(EveAINoSessionTenant, no_tenant_selected_error)
app.register_error_handler(KeyError, key_error_handler) app.register_error_handler(KeyError, key_error_handler)
app.register_error_handler(AttributeError, attribute_error_handler) app.register_error_handler(AttributeError, attribute_error_handler)
app.register_error_handler(Exception, general_exception) app.register_error_handler(jinja2.TemplateNotFound, template_not_found_error)
app.register_error_handler(jinja2.TemplateSyntaxError, template_syntax_error)
@app.errorhandler(jinja2.TemplateNotFound) app.register_error_handler(Exception, general_exception)
def template_not_found(error):
app.logger.error(f'Template not found: {error.name}')
app.logger.error(f'Search Paths: {app.jinja_loader.list_templates()}')
return f'Template not found: {error.name}. Check logs for details.', 404
@app.errorhandler(jinja2.TemplateSyntaxError)
def template_syntax_error(error):
app.logger.error(f'Template syntax error: {error.message}')
app.logger.error(f'In template {error.filename}, line {error.lineno}')
return f'Template syntax error: {error.message}', 500

View File

@@ -248,3 +248,14 @@ class EveAIPendingLicensePeriod(EveAIException):
message = f"Basic Fee Payment has not been received yet. Please ensure payment has been made, and please wait for payment to be processed." message = f"Basic Fee Payment has not been received yet. Please ensure payment has been made, and please wait for payment to be processed."
super().__init__(message, status_code, payload) super().__init__(message, status_code, payload)
class EveAISpecialistExecutionError(EveAIException):
"""Raised when an error occurs during specialist execution"""
def __init__(self, tenant_id, specialist_id, session_id, details, status_code=400, payload=None):
message = (f"Error during specialist {specialist_id} execution \n"
f"with Session ID {session_id} \n"
f"for Tenant {tenant_id}. \n"
f"Details: {details} \n"
f"The System Administrator has been notified. Please try again later.")
super().__init__(message, status_code, payload)

View File

@@ -1,196 +0,0 @@
from datetime import datetime as dt, timezone as tz
from typing import Optional, Dict, Any
from flask import current_app
from sqlalchemy.exc import SQLAlchemyError
from common.extensions import db, cache_manager
from common.models.interaction import (
Specialist, EveAIAgent, EveAITask, EveAITool
)
from common.utils.model_logging_utils import set_logging_information, update_logging_information
def initialize_specialist(specialist_id: int, specialist_type: str, specialist_version: str):
"""
Initialize an agentic specialist by creating all its components based on configuration.
Args:
specialist_id: ID of the specialist to initialize
specialist_type: Type of the specialist
specialist_version: Version of the specialist type to use
Raises:
ValueError: If specialist not found or invalid configuration
SQLAlchemyError: If database operations fail
"""
config = cache_manager.specialists_config_cache.get_config(specialist_type, specialist_version)
if not config:
raise ValueError(f"No configuration found for {specialist_type} version {specialist_version}")
if config['framework'] == 'langchain':
pass # Langchain does not require additional items to be initialized. All configuration is in the specialist.
specialist = Specialist.query.get(specialist_id)
if not specialist:
raise ValueError(f"Specialist with ID {specialist_id} not found")
if config['framework'] == 'crewai':
initialize_crewai_specialist(specialist, config)
def initialize_crewai_specialist(specialist: Specialist, config: Dict[str, Any]):
timestamp = dt.now(tz=tz.utc)
try:
# Initialize agents
if 'agents' in config:
for agent_config in config['agents']:
_create_agent(
specialist_id=specialist.id,
agent_type=agent_config['type'],
agent_version=agent_config['version'],
name=agent_config.get('name'),
description=agent_config.get('description'),
timestamp=timestamp
)
# Initialize tasks
if 'tasks' in config:
for task_config in config['tasks']:
_create_task(
specialist_id=specialist.id,
task_type=task_config['type'],
task_version=task_config['version'],
name=task_config.get('name'),
description=task_config.get('description'),
timestamp=timestamp
)
# Initialize tools
if 'tools' in config:
for tool_config in config['tools']:
_create_tool(
specialist_id=specialist.id,
tool_type=tool_config['type'],
tool_version=tool_config['version'],
name=tool_config.get('name'),
description=tool_config.get('description'),
timestamp=timestamp
)
db.session.commit()
current_app.logger.info(f"Successfully initialized crewai specialist {specialist.id}")
except SQLAlchemyError as e:
db.session.rollback()
current_app.logger.error(f"Database error initializing crewai specialist {specialist.id}: {str(e)}")
raise
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error initializing crewai specialist {specialist.id}: {str(e)}")
raise
def _create_agent(
specialist_id: int,
agent_type: str,
agent_version: str,
name: Optional[str] = None,
description: Optional[str] = None,
timestamp: Optional[dt] = None
) -> EveAIAgent:
"""Create an agent with the given configuration."""
if timestamp is None:
timestamp = dt.now(tz=tz.utc)
# Get agent configuration from cache
agent_config = cache_manager.agents_config_cache.get_config(agent_type, agent_version)
agent = EveAIAgent(
specialist_id=specialist_id,
name=name or agent_config.get('name', agent_type),
description=description or agent_config.get('metadata').get('description', ''),
type=agent_type,
type_version=agent_version,
role=None,
goal=None,
backstory=None,
tuning=False,
configuration=None,
arguments=None
)
set_logging_information(agent, timestamp)
db.session.add(agent)
current_app.logger.info(f"Created agent {agent.id} of type {agent_type}")
return agent
def _create_task(
specialist_id: int,
task_type: str,
task_version: str,
name: Optional[str] = None,
description: Optional[str] = None,
timestamp: Optional[dt] = None
) -> EveAITask:
"""Create a task with the given configuration."""
if timestamp is None:
timestamp = dt.now(tz=tz.utc)
# Get task configuration from cache
task_config = cache_manager.tasks_config_cache.get_config(task_type, task_version)
task = EveAITask(
specialist_id=specialist_id,
name=name or task_config.get('name', task_type),
description=description or task_config.get('metadata').get('description', ''),
type=task_type,
type_version=task_version,
task_description=None,
expected_output=None,
tuning=False,
configuration=None,
arguments=None,
context=None,
asynchronous=False,
)
set_logging_information(task, timestamp)
db.session.add(task)
current_app.logger.info(f"Created task {task.id} of type {task_type}")
return task
def _create_tool(
specialist_id: int,
tool_type: str,
tool_version: str,
name: Optional[str] = None,
description: Optional[str] = None,
timestamp: Optional[dt] = None
) -> EveAITool:
"""Create a tool with the given configuration."""
if timestamp is None:
timestamp = dt.now(tz=tz.utc)
# Get tool configuration from cache
tool_config = cache_manager.tools_config_cache.get_config(tool_type, tool_version)
tool = EveAITool(
specialist_id=specialist_id,
name=name or tool_config.get('name', tool_type),
description=description or tool_config.get('metadata').get('description', ''),
type=tool_type,
type_version=tool_version,
tuning=False,
configuration=None,
arguments=None,
)
set_logging_information(tool, timestamp)
db.session.add(tool)
current_app.logger.info(f"Created tool {tool.id} of type {tool_type}")
return tool

View File

@@ -0,0 +1,25 @@
version: "1.0.0"
name: "Traicie Recruiter"
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.magistral-medium-latest"
temperature: 0.3
metadata:
author: "Josako"
date_added: "2025-06-18"
description: "Traicie Recruiter Agent"
changes: "Initial version"

View File

@@ -0,0 +1,21 @@
version: "1.0.0"
name: "Dossier Catalog"
description: "A Catalog with information in Evie's Library in which several Dossiers can be stored"
configuration:
tagging_fields:
name: "Tagging Fields"
type: "tagging_fields"
description: "Define the metadata fields that will be used for tagging documents.
Each field must have:
- type: one of 'string', 'integer', 'float', 'date', 'enum'
- required: boolean indicating if the field is mandatory
- description: field description
- allowed_values: list of values (for enum type only)
- min_value/max_value: range limits (for numeric types only)"
required: true
default: {}
document_version_configurations: ["tagging_fields"]
metadata:
author: "System"
date_added: "2023-01-01"
description: "A Catalog with information in Evie's Library in which several Dossiers can be stored"

View File

@@ -0,0 +1,9 @@
version: "1.0.0"
name: "Standard Catalog"
description: "A Catalog with information in Evie's Library, to be considered as a whole"
configuration: {}
document_version_configurations: []
metadata:
author: "System"
date_added: "2023-01-01"
description: "A Catalog with information in Evie's Library, to be considered as a whole"

View File

@@ -12,10 +12,7 @@ class Config(object):
DEBUG = False DEBUG = False
DEVELOPMENT = False DEVELOPMENT = False
SECRET_KEY = environ.get('SECRET_KEY') SECRET_KEY = environ.get('SECRET_KEY')
SESSION_COOKIE_SECURE = False
SESSION_COOKIE_HTTPONLY = True
COMPONENT_NAME = environ.get('COMPONENT_NAME') COMPONENT_NAME = environ.get('COMPONENT_NAME')
SESSION_KEY_PREFIX = f'{COMPONENT_NAME}_'
# Database Settings # Database Settings
DB_HOST = environ.get('DB_HOST') DB_HOST = environ.get('DB_HOST')
@@ -44,8 +41,6 @@ class Config(object):
# SECURITY_POST_CHANGE_VIEW = '/admin/login' # SECURITY_POST_CHANGE_VIEW = '/admin/login'
# SECURITY_BLUEPRINT_NAME = 'security_bp' # SECURITY_BLUEPRINT_NAME = 'security_bp'
SECURITY_PASSWORD_SALT = environ.get('SECURITY_PASSWORD_SALT') SECURITY_PASSWORD_SALT = environ.get('SECURITY_PASSWORD_SALT')
REMEMBER_COOKIE_SAMESITE = 'strict'
SESSION_COOKIE_SAMESITE = 'Lax'
SECURITY_CONFIRMABLE = True SECURITY_CONFIRMABLE = True
SECURITY_TRACKABLE = True SECURITY_TRACKABLE = True
SECURITY_PASSWORD_COMPLEXITY_CHECKER = 'zxcvbn' SECURITY_PASSWORD_COMPLEXITY_CHECKER = 'zxcvbn'
@@ -56,6 +51,10 @@ class Config(object):
SECURITY_EMAIL_SUBJECT_PASSWORD_NOTICE = 'Your Password Has Been Reset' SECURITY_EMAIL_SUBJECT_PASSWORD_NOTICE = 'Your Password Has Been Reset'
SECURITY_EMAIL_PLAINTEXT = False SECURITY_EMAIL_PLAINTEXT = False
SECURITY_EMAIL_HTML = True SECURITY_EMAIL_HTML = True
SECURITY_SESSION_PROTECTION = 'basic' # of 'basic' als 'strong' problemen geeft
SECURITY_REMEMBER_TOKEN_VALIDITY = timedelta(minutes=60) # Zelfde als session lifetime
SECURITY_AUTO_LOGIN_AFTER_CONFIRM = True
SECURITY_AUTO_LOGIN_AFTER_RESET = True
# Ensure Flask-Security-Too is handling CSRF tokens when behind a proxy # Ensure Flask-Security-Too is handling CSRF tokens when behind a proxy
SECURITY_CSRF_PROTECT_MECHANISMS = ['session'] SECURITY_CSRF_PROTECT_MECHANISMS = ['session']
@@ -67,7 +66,89 @@ class Config(object):
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 MAX_CONTENT_LENGTH = 50 * 1024 * 1024
# supported languages # supported languages
SUPPORTED_LANGUAGES = ['en', 'fr', 'nl', 'de', 'es'] SUPPORTED_LANGUAGES = ['en', 'fr', 'nl', 'de', 'es', 'it', 'pt', 'ru', 'zh', 'ja', 'ko', 'ar', 'hi']
SUPPORTED_LANGUAGE_DETAILS = {
"English": {
"iso 639-1": "en",
"iso 639-2": "eng",
"iso 639-3": "eng",
"flag": "🇬🇧"
},
"French": {
"iso 639-1": "fr",
"iso 639-2": "fre", # of 'fra'
"iso 639-3": "fra",
"flag": "🇫🇷"
},
"German": {
"iso 639-1": "de",
"iso 639-2": "ger", # of 'deu'
"iso 639-3": "deu",
"flag": "🇩🇪"
},
"Spanish": {
"iso 639-1": "es",
"iso 639-2": "spa",
"iso 639-3": "spa",
"flag": "🇪🇸"
},
"Italian": {
"iso 639-1": "it",
"iso 639-2": "ita",
"iso 639-3": "ita",
"flag": "🇮🇹"
},
"Portuguese": {
"iso 639-1": "pt",
"iso 639-2": "por",
"iso 639-3": "por",
"flag": "🇵🇹"
},
"Dutch": {
"iso 639-1": "nl",
"iso 639-2": "dut", # of 'nld'
"iso 639-3": "nld",
"flag": "🇳🇱"
},
"Russian": {
"iso 639-1": "ru",
"iso 639-2": "rus",
"iso 639-3": "rus",
"flag": "🇷🇺"
},
"Chinese": {
"iso 639-1": "zh",
"iso 639-2": "chi", # of 'zho'
"iso 639-3": "zho",
"flag": "🇨🇳"
},
"Japanese": {
"iso 639-1": "ja",
"iso 639-2": "jpn",
"iso 639-3": "jpn",
"flag": "🇯🇵"
},
"Korean": {
"iso 639-1": "ko",
"iso 639-2": "kor",
"iso 639-3": "kor",
"flag": "🇰🇷"
},
"Arabic": {
"iso 639-1": "ar",
"iso 639-2": "ara",
"iso 639-3": "ara",
"flag": "🇸🇦"
},
"Hindi": {
"iso 639-1": "hi",
"iso 639-2": "hin",
"iso 639-3": "hin",
"flag": "🇮🇳"
},
}
SUPPORTED_LANGUAGES_Full = list(SUPPORTED_LANGUAGE_DETAILS.keys())
# supported currencies # supported currencies
SUPPORTED_CURRENCIES = ['', '$'] SUPPORTED_CURRENCIES = ['', '$']
@@ -107,6 +188,15 @@ class Config(object):
PERMANENT_SESSION_LIFETIME = timedelta(minutes=60) PERMANENT_SESSION_LIFETIME = timedelta(minutes=60)
SESSION_REFRESH_EACH_REQUEST = True SESSION_REFRESH_EACH_REQUEST = True
SESSION_COOKIE_NAME = f'{COMPONENT_NAME}_session'
SESSION_COOKIE_DOMAIN = None # Laat Flask dit automatisch bepalen
SESSION_COOKIE_PATH = '/'
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = False # True voor production met HTTPS
SESSION_COOKIE_SAMESITE = 'Lax'
REMEMBER_COOKIE_SAMESITE = 'strict'
SESSION_KEY_PREFIX = f'{COMPONENT_NAME}_'
# JWT settings # JWT settings
JWT_SECRET_KEY = environ.get('JWT_SECRET_KEY') JWT_SECRET_KEY = environ.get('JWT_SECRET_KEY')
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1) # Set token expiry to 1 hour JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1) # Set token expiry to 1 hour
@@ -172,6 +262,9 @@ class Config(object):
# Entitlement Constants # Entitlement Constants
ENTITLEMENTS_MAX_PENDING_DAYS = 5 # Defines the maximum number of days a pending entitlement can be active ENTITLEMENTS_MAX_PENDING_DAYS = 5 # Defines the maximum number of days a pending entitlement can be active
# Content Directory for static content like the changelog, terms & conditions, privacy statement, ...
CONTENT_DIR = '/app/content'
class DevConfig(Config): class DevConfig(Config):
DEVELOPMENT = True DEVELOPMENT = True
@@ -182,6 +275,7 @@ class DevConfig(Config):
# Define the nginx prefix used for the specific apps # Define the nginx prefix used for the specific apps
EVEAI_APP_LOCATION_PREFIX = '/admin' EVEAI_APP_LOCATION_PREFIX = '/admin'
EVEAI_CHAT_LOCATION_PREFIX = '/chat' EVEAI_CHAT_LOCATION_PREFIX = '/chat'
CHAT_CLIENT_PREFIX = 'chat-client/chat/'
# file upload settings # file upload settings
# UPLOAD_FOLDER = '/app/tenant_files' # UPLOAD_FOLDER = '/app/tenant_files'

View File

@@ -0,0 +1,68 @@
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_background":
name: "Sidebar Background"
description: "Sidebar Background Color"
type: "color"
required: false
"markdown_background_color":
name: "Markdown Background"
description: "Markdown Background Color"
type: "color"
required: false
"markdown_text_color":
name: "Markdown Text"
description: "Markdown Text Color"
type: "color"
required: false
"gradient_start_color":
name: "Gradient Start Color"
description: "Start Color for the gradient in the Chat Area"
type: "color"
required: false
"gradient_end_color":
name: "Gradient End Color"
description: "End Color for the gradient in the Chat Area"
type: "color"
required: false
"sidebar_markdown":
name: "Sidebar Markdown"
description: "Sidebar Markdown-formatted Text"
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

@@ -1,15 +1,13 @@
import json import json
import os import os
import sys
from datetime import datetime as dt, timezone as tz from datetime import datetime as dt, timezone as tz
from flask import current_app from flask import current_app
from graypy import GELFUDPHandler
import logging import logging
import logging.config import logging.config
# Graylog configuration
GRAYLOG_HOST = os.environ.get('GRAYLOG_HOST', 'localhost')
GRAYLOG_PORT = int(os.environ.get('GRAYLOG_PORT', 12201))
env = os.environ.get('FLASK_ENV', 'development') env = os.environ.get('FLASK_ENV', 'development')
@@ -144,23 +142,6 @@ class TuningFormatter(logging.Formatter):
return formatted_msg return formatted_msg
class GraylogFormatter(logging.Formatter):
"""Maintains existing Graylog formatting while adding tuning fields"""
def format(self, record):
if getattr(record, 'is_tuning_log', False):
# Add tuning-specific fields to Graylog
record.tuning_fields = {
'is_tuning_log': True,
'tuning_type': record.tuning_type,
'tenant_id': record.tenant_id,
'catalog_id': record.catalog_id,
'specialist_id': record.specialist_id,
'retriever_id': record.retriever_id,
'processor_id': record.processor_id,
'session_id': record.session_id,
}
return super().format(record)
class TuningLogger: class TuningLogger:
"""Helper class to manage tuning logs with consistent structure""" """Helper class to manage tuning logs with consistent structure"""
@@ -177,10 +158,10 @@ class TuningLogger:
specialist_id: Optional specialist ID for context specialist_id: Optional specialist ID for context
retriever_id: Optional retriever ID for context retriever_id: Optional retriever ID for context
processor_id: Optional processor ID for context processor_id: Optional processor ID for context
session_id: Optional session ID for context and log file naming session_id: Optional session ID for context
log_file: Optional custom log file name to use log_file: Optional custom log file name (ignored - all logs go to tuning.log)
""" """
# Always use the standard tuning logger
self.logger = logging.getLogger(logger_name) self.logger = logging.getLogger(logger_name)
self.tenant_id = tenant_id self.tenant_id = tenant_id
self.catalog_id = catalog_id self.catalog_id = catalog_id
@@ -188,63 +169,8 @@ class TuningLogger:
self.retriever_id = retriever_id self.retriever_id = retriever_id
self.processor_id = processor_id self.processor_id = processor_id
self.session_id = session_id self.session_id = session_id
self.log_file = log_file
# Determine whether to use a session-specific logger
if session_id:
# Create a unique logger name for this session
session_logger_name = f"{logger_name}_{session_id}"
self.logger = logging.getLogger(session_logger_name)
# If this logger doesn't have handlers yet, configure it def log_tuning(self, tuning_type: str, message: str, data=None, level=logging.DEBUG):
if not self.logger.handlers:
# Determine log file path
if not log_file and session_id:
log_file = f"logs/tuning_{session_id}.log"
elif not log_file:
log_file = "logs/tuning.log"
# Configure the logger
self._configure_session_logger(log_file)
else:
# Use the standard tuning logger
self.logger = logging.getLogger(logger_name)
def _configure_session_logger(self, log_file):
"""Configure a new session-specific logger with appropriate handlers"""
# Create and configure a file handler
file_handler = logging.handlers.RotatingFileHandler(
filename=log_file,
maxBytes=1024 * 1024 * 3, # 3MB
backupCount=3
)
file_handler.setFormatter(TuningFormatter())
file_handler.setLevel(logging.DEBUG)
# Add the file handler to the logger
self.logger.addHandler(file_handler)
# Add Graylog handler in production
env = os.environ.get('FLASK_ENV', 'development')
if env == 'production':
try:
graylog_handler = GELFUDPHandler(
host=GRAYLOG_HOST,
port=GRAYLOG_PORT,
debugging_fields=True
)
graylog_handler.setFormatter(GraylogFormatter())
self.logger.addHandler(graylog_handler)
except Exception as e:
# Fall back to just file logging if Graylog setup fails
fallback_logger = logging.getLogger('eveai_app')
fallback_logger.warning(f"Failed to set up Graylog handler: {str(e)}")
# Set logger level and disable propagation
self.logger.setLevel(logging.DEBUG)
self.logger.propagate = False
def log_tuning(self, tuning_type: str, message: str, data=None, level=logging.DEBUG):
"""Log a tuning event with structured data""" """Log a tuning event with structured data"""
try: try:
# Create a standard LogRecord for tuning # Create a standard LogRecord for tuning
@@ -275,13 +201,82 @@ def log_tuning(self, tuning_type: str, message: str, data=None, level=logging.DE
self.logger.handle(record) self.logger.handle(record)
except Exception as e: except Exception as e:
fallback_logger = logging.getLogger('eveai_workers') print(f"Failed to log tuning message: {str(e)}")
fallback_logger.exception(f"Failed to log tuning message: {str(e)}")
# Set the custom log record factory # Set the custom log record factory
logging.setLogRecordFactory(TuningLogRecord) logging.setLogRecordFactory(TuningLogRecord)
def configure_logging():
"""Configure logging based on environment
When running in Kubernetes, directs logs to stdout in JSON format
Otherwise uses file-based logging for development/testing
"""
try:
# Verkrijg het absolute pad naar de logs directory
base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
logs_dir = os.path.join(base_dir, 'logs')
# Zorg ervoor dat de logs directory bestaat met de juiste permissies
if not os.path.exists(logs_dir):
try:
os.makedirs(logs_dir, exist_ok=True)
print(f"Logs directory aangemaakt op: {logs_dir}")
except (IOError, PermissionError) as e:
print(f"WAARSCHUWING: Kan logs directory niet aanmaken: {e}")
print(f"Logs worden mogelijk niet correct geschreven!")
# Check if running in Kubernetes
in_kubernetes = os.environ.get('KUBERNETES_SERVICE_HOST') is not None
# Controleer of de pythonjsonlogger pakket beschikbaar is als we in Kubernetes zijn
if in_kubernetes:
try:
import pythonjsonlogger.jsonlogger
has_json_logger = True
except ImportError:
print("WAARSCHUWING: python-json-logger pakket is niet geïnstalleerd.")
print("Voer 'pip install python-json-logger>=2.0.7' uit om JSON logging in te schakelen.")
print("Terugvallen op standaard logging formaat.")
has_json_logger = False
in_kubernetes = False # Fall back to standard logging
else:
has_json_logger = False
# Apply the configuration
logging_config = dict(LOGGING)
# Wijzig de json_console handler om terug te vallen op console als pythonjsonlogger niet beschikbaar is
if not has_json_logger and 'json_console' in logging_config['handlers']:
# Vervang json_console handler door een console handler met standaard formatter
logging_config['handlers']['json_console']['formatter'] = 'standard'
# In Kubernetes, conditionally modify specific loggers to use JSON console output
# This preserves the same logger names but changes where/how they log
if in_kubernetes:
for logger_name in logging_config['loggers']:
if logger_name: # Skip the root logger
logging_config['loggers'][logger_name]['handlers'] = ['json_console']
# Controleer of de logs directory schrijfbaar is voordat we de configuratie toepassen
logs_dir = os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs')
if os.path.exists(logs_dir) and not os.access(logs_dir, os.W_OK):
print(f"WAARSCHUWING: Logs directory bestaat maar is niet schrijfbaar: {logs_dir}")
print("Logs worden mogelijk niet correct geschreven!")
logging.config.dictConfig(logging_config)
logging.info(f"Logging configured. Environment: {'Kubernetes' if in_kubernetes else 'Development/Testing'}")
logging.info(f"Logs directory: {logs_dir}")
except Exception as e:
print(f"Error configuring logging: {str(e)}")
print("Gedetailleerde foutinformatie:")
import traceback
traceback.print_exc()
# Fall back to basic configuration
logging.basicConfig(level=logging.INFO)
LOGGING = { LOGGING = {
'version': 1, 'version': 1,
@@ -290,7 +285,7 @@ LOGGING = {
'file_app': { 'file_app': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/eveai_app.log', 'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'eveai_app.log'),
'maxBytes': 1024 * 1024 * 1, # 1MB 'maxBytes': 1024 * 1024 * 1, # 1MB
'backupCount': 2, 'backupCount': 2,
'formatter': 'standard', 'formatter': 'standard',
@@ -298,15 +293,15 @@ LOGGING = {
'file_workers': { 'file_workers': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/eveai_workers.log', 'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'eveai_workers.log'),
'maxBytes': 1024 * 1024 * 1, # 1MB 'maxBytes': 1024 * 1024 * 1, # 1MB
'backupCount': 2, 'backupCount': 2,
'formatter': 'standard', 'formatter': 'standard',
}, },
'file_chat': { 'file_chat_client': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/eveai_chat.log', 'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'eveai_chat_client.log'),
'maxBytes': 1024 * 1024 * 1, # 1MB 'maxBytes': 1024 * 1024 * 1, # 1MB
'backupCount': 2, 'backupCount': 2,
'formatter': 'standard', 'formatter': 'standard',
@@ -314,7 +309,7 @@ LOGGING = {
'file_chat_workers': { 'file_chat_workers': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/eveai_chat_workers.log', 'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'eveai_chat_workers.log'),
'maxBytes': 1024 * 1024 * 1, # 1MB 'maxBytes': 1024 * 1024 * 1, # 1MB
'backupCount': 2, 'backupCount': 2,
'formatter': 'standard', 'formatter': 'standard',
@@ -322,7 +317,7 @@ LOGGING = {
'file_api': { 'file_api': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/eveai_api.log', 'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'eveai_api.log'),
'maxBytes': 1024 * 1024 * 1, # 1MB 'maxBytes': 1024 * 1024 * 1, # 1MB
'backupCount': 2, 'backupCount': 2,
'formatter': 'standard', 'formatter': 'standard',
@@ -330,7 +325,7 @@ LOGGING = {
'file_beat': { 'file_beat': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/eveai_beat.log', 'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'eveai_beat.log'),
'maxBytes': 1024 * 1024 * 1, # 1MB 'maxBytes': 1024 * 1024 * 1, # 1MB
'backupCount': 2, 'backupCount': 2,
'formatter': 'standard', 'formatter': 'standard',
@@ -338,7 +333,7 @@ LOGGING = {
'file_entitlements': { 'file_entitlements': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/eveai_entitlements.log', 'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'eveai_entitlements.log'),
'maxBytes': 1024 * 1024 * 1, # 1MB 'maxBytes': 1024 * 1024 * 1, # 1MB
'backupCount': 2, 'backupCount': 2,
'formatter': 'standard', 'formatter': 'standard',
@@ -346,7 +341,7 @@ LOGGING = {
'file_sqlalchemy': { 'file_sqlalchemy': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/sqlalchemy.log', 'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'sqlalchemy.log'),
'maxBytes': 1024 * 1024 * 1, # 1MB 'maxBytes': 1024 * 1024 * 1, # 1MB
'backupCount': 2, 'backupCount': 2,
'formatter': 'standard', 'formatter': 'standard',
@@ -354,7 +349,7 @@ LOGGING = {
'file_security': { 'file_security': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/security.log', 'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'security.log'),
'maxBytes': 1024 * 1024 * 1, # 1MB 'maxBytes': 1024 * 1024 * 1, # 1MB
'backupCount': 2, 'backupCount': 2,
'formatter': 'standard', 'formatter': 'standard',
@@ -362,7 +357,7 @@ LOGGING = {
'file_rag_tuning': { 'file_rag_tuning': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/rag_tuning.log', 'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'rag_tuning.log'),
'maxBytes': 1024 * 1024 * 1, # 1MB 'maxBytes': 1024 * 1024 * 1, # 1MB
'backupCount': 2, 'backupCount': 2,
'formatter': 'standard', 'formatter': 'standard',
@@ -370,7 +365,7 @@ LOGGING = {
'file_embed_tuning': { 'file_embed_tuning': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/embed_tuning.log', 'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'embed_tuning.log'),
'maxBytes': 1024 * 1024 * 1, # 1MB 'maxBytes': 1024 * 1024 * 1, # 1MB
'backupCount': 2, 'backupCount': 2,
'formatter': 'standard', 'formatter': 'standard',
@@ -378,7 +373,7 @@ LOGGING = {
'file_business_events': { 'file_business_events': {
'level': 'INFO', 'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/business_events.log', 'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'business_events.log'),
'maxBytes': 1024 * 1024 * 1, # 1MB 'maxBytes': 1024 * 1024 * 1, # 1MB
'backupCount': 2, 'backupCount': 2,
'formatter': 'standard', 'formatter': 'standard',
@@ -388,100 +383,104 @@ LOGGING = {
'level': 'DEBUG', 'level': 'DEBUG',
'formatter': 'standard', 'formatter': 'standard',
}, },
'json_console': {
'class': 'logging.StreamHandler',
'level': 'INFO',
'formatter': 'json',
'stream': 'ext://sys.stdout',
},
'tuning_file': { 'tuning_file': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/tuning.log', 'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'tuning.log'),
'maxBytes': 1024 * 1024 * 3, # 3MB 'maxBytes': 1024 * 1024 * 3, # 3MB
'backupCount': 3, 'backupCount': 3,
'formatter': 'tuning', 'formatter': 'tuning',
}, },
'graylog': {
'level': 'DEBUG',
'class': 'graypy.GELFUDPHandler',
'host': GRAYLOG_HOST,
'port': GRAYLOG_PORT,
'debugging_fields': True,
'formatter': 'graylog'
},
}, },
'formatters': { 'formatters': {
'standard': { 'standard': {
'format': '%(asctime)s [%(levelname)s] %(name)s (%(component)s) [%(module)s:%(lineno)d]: %(message)s', 'format': '%(asctime)s [%(levelname)s] %(name)s (%(component)s) [%(module)s:%(lineno)d]: %(message)s',
'datefmt': '%Y-%m-%d %H:%M:%S' 'datefmt': '%Y-%m-%d %H:%M:%S'
}, },
'graylog': {
'format': '[%(levelname)s] %(name)s (%(component)s) [%(module)s:%(lineno)d in %(funcName)s] '
'[Thread: %(threadName)s]: %(message)s',
'datefmt': '%Y-%m-%d %H:%M:%S',
'()': GraylogFormatter
},
'tuning': { 'tuning': {
'()': TuningFormatter, '()': TuningFormatter,
'datefmt': '%Y-%m-%d %H:%M:%S UTC' 'datefmt': '%Y-%m-%d %H:%M:%S UTC'
},
'json': {
'format': '%(message)s',
'class': 'logging.Formatter' if not 'pythonjsonlogger' in sys.modules else 'pythonjsonlogger.jsonlogger.JsonFormatter',
'json_default': lambda obj: str(obj) if isinstance(obj, (dt, Exception)) else None,
'json_ensure_ascii': False,
'rename_fields': {
'asctime': 'timestamp',
'levelname': 'severity'
},
'timestamp': True,
'datefmt': '%Y-%m-%dT%H:%M:%S.%fZ'
} }
}, },
'loggers': { 'loggers': {
'eveai_app': { # logger for the eveai_app 'eveai_app': { # logger for the eveai_app
'handlers': ['file_app', 'graylog', ] if env == 'production' else ['file_app', ], 'handlers': ['file_app'],
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': False 'propagate': False
}, },
'eveai_workers': { # logger for the eveai_workers 'eveai_workers': { # logger for the eveai_workers
'handlers': ['file_workers', 'graylog', ] if env == 'production' else ['file_workers', ], 'handlers': ['file_workers'],
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': False 'propagate': False
}, },
'eveai_chat': { # logger for the eveai_chat 'eveai_chat_client': { # logger for the eveai_chat
'handlers': ['file_chat', 'graylog', ] if env == 'production' else ['file_chat', ], 'handlers': ['file_chat_client'],
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': False 'propagate': False
}, },
'eveai_chat_workers': { # logger for the eveai_chat_workers 'eveai_chat_workers': { # logger for the eveai_chat_workers
'handlers': ['file_chat_workers', 'graylog', ] if env == 'production' else ['file_chat_workers', ], 'handlers': ['file_chat_workers'],
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': False 'propagate': False
}, },
'eveai_api': { # logger for the eveai_chat_workers 'eveai_api': { # logger for the eveai_api
'handlers': ['file_api', 'graylog', ] if env == 'production' else ['file_api', ], 'handlers': ['file_api'],
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': False 'propagate': False
}, },
'eveai_beat': { # logger for the eveai_beat 'eveai_beat': { # logger for the eveai_beat
'handlers': ['file_beat', 'graylog', ] if env == 'production' else ['file_beat', ], 'handlers': ['file_beat'],
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': False 'propagate': False
}, },
'eveai_entitlements': { # logger for the eveai_entitlements 'eveai_entitlements': { # logger for the eveai_entitlements
'handlers': ['file_entitlements', 'graylog', ] if env == 'production' else ['file_entitlements', ], 'handlers': ['file_entitlements'],
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': False 'propagate': False
}, },
'sqlalchemy.engine': { # logger for the sqlalchemy 'sqlalchemy.engine': { # logger for the sqlalchemy
'handlers': ['file_sqlalchemy', 'graylog', ] if env == 'production' else ['file_sqlalchemy', ], 'handlers': ['file_sqlalchemy'],
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': False 'propagate': False
}, },
'security': { # logger for the security 'security': { # logger for the security
'handlers': ['file_security', 'graylog', ] if env == 'production' else ['file_security', ], 'handlers': ['file_security'],
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': False 'propagate': False
}, },
'business_events': { 'business_events': {
'handlers': ['file_business_events', 'graylog'], 'handlers': ['file_business_events'],
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': False 'propagate': False
}, },
# Single tuning logger # Single tuning logger
'tuning': { 'tuning': {
'handlers': ['tuning_file', 'graylog'] if env == 'production' else ['tuning_file'], 'handlers': ['tuning_file'],
'level': 'DEBUG', 'level': 'DEBUG',
'propagate': False, 'propagate': False,
}, },
'': { # root logger '': { # root logger
'handlers': ['console'], 'handlers': ['console'] if os.environ.get('KUBERNETES_SERVICE_HOST') is None else ['json_console'],
'level': 'WARNING', # Set higher level for root to minimize noise 'level': 'WARNING', # Set higher level for root to minimize noise
'propagate': False 'propagate': False
}, },
} }
} }

View File

@@ -0,0 +1,9 @@
version: "1.0.0"
name: "AUDIO Processor"
file_types: "mp3, mp4, ogg"
description: "A Processor for audio files"
configuration: {}
metadata:
author: "System"
date_added: "2023-01-01"
description: "A Processor for audio files"

View File

@@ -0,0 +1,59 @@
version: "1.0.0"
name: "DOCX Processor"
file_types: "docx"
description: "A processor for DOCX files"
configuration:
chunking_patterns:
name: "Chunking Patterns"
description: "A list of Patterns used to chunk files into logical pieces"
type: "chunking_patterns"
required: false
chunking_heading_level:
name: "Chunking Heading Level"
type: "integer"
description: "Maximum heading level to consider for chunking (1-6)"
required: false
default: 2
extract_comments:
name: "Extract Comments"
type: "boolean"
description: "Whether to include document comments in the markdown"
required: false
default: false
extract_headers_footers:
name: "Extract Headers/Footers"
type: "boolean"
description: "Whether to include headers and footers in the markdown"
required: false
default: false
preserve_formatting:
name: "Preserve Formatting"
type: "boolean"
description: "Whether to preserve bold, italic, and other text formatting"
required: false
default: true
list_style:
name: "List Style"
type: "enum"
description: "How to format lists in markdown"
required: false
default: "dash"
allowed_values: ["dash", "asterisk", "plus"]
image_handling:
name: "Image Handling"
type: "enum"
description: "How to handle embedded images"
required: false
default: "skip"
allowed_values: ["skip", "extract", "placeholder"]
table_alignment:
name: "Table Alignment"
type: "enum"
description: "How to align table contents"
required: false
default: "left"
allowed_values: ["left", "center", "preserve"]
metadata:
author: "System"
date_added: "2023-01-01"
description: "A processor for DOCX files"

View File

@@ -0,0 +1,49 @@
version: "1.0.0"
name: "HTML Processor"
file_types: "html"
description: "A processor for HTML files"
configuration:
chunking_patterns:
name: "Chunking Patterns"
description: "A list of Patterns used to chunk files into logical pieces"
type: "chunking_patterns"
required: false
chunking_heading_level:
name: "Chunking Heading Level"
type: "integer"
description: "Maximum heading level to consider for chunking (1-6)"
required: false
default: 2
html_tags:
name: "HTML Tags"
type: "string"
description: "A comma-separated list of HTML tags"
required: true
default: "p, h1, h2, h3, h4, h5, h6, li, table, thead, tbody, tr, td"
html_end_tags:
name: "HTML End Tags"
type: "string"
description: "A comma-separated list of HTML end tags (where can the chunk end)"
required: true
default: "p, li, table"
html_included_elements:
name: "HTML Included Elements"
type: "string"
description: "A comma-separated list of elements to be included"
required: true
default: "article, main"
html_excluded_elements:
name: "HTML Excluded Elements"
type: "string"
description: "A comma-separated list of elements to be excluded"
required: false
default: "header, footer, nav, script"
html_excluded_classes:
name: "HTML Excluded Classes"
type: "string"
description: "A comma-separated list of classes to be excluded"
required: false
metadata:
author: "System"
date_added: "2023-01-01"
description: "A processor for HTML files"

View File

@@ -0,0 +1,20 @@
version: "1.0.0"
name: "Markdown Processor"
file_types: "md"
description: "A Processor for markdown files"
configuration:
chunking_patterns:
name: "Chunking Patterns"
description: "A list of Patterns used to chunk files into logical pieces"
type: "chunking_patterns"
required: false
chunking_heading_level:
name: "Chunking Heading Level"
type: "integer"
description: "Maximum heading level to consider for chunking (1-6)"
required: false
default: 2
metadata:
author: "System"
date_added: "2023-01-01"
description: "A Processor for markdown files"

View File

@@ -0,0 +1,20 @@
version: "1.0.0"
name: "PDF Processor"
file_types: "pdf"
description: "A Processor for PDF files"
configuration:
chunking_patterns:
name: "Chunking Patterns"
description: "A list of Patterns used to chunk files into logical pieces"
type: "chunking_patterns"
required: false
chunking_heading_level:
name: "Chunking Heading Level"
type: "integer"
description: "Maximum heading level to consider for chunking (1-6)"
required: false
default: 2
metadata:
author: "System"
date_added: "2023-01-01"
description: "A Processor for PDF files"

View File

@@ -0,0 +1,46 @@
type: "PERSONAL_CONTACT_FORM"
version: "1.0.0"
name: "Personal Contact Form"
icon: "person"
fields:
name:
name: "Name"
description: "Your name"
type: "str"
required: true
email:
name: "Email"
type: "str"
description: "Your Name"
required: true
phone:
name: "Phone Number"
type: "str"
description: "Your Phone Number"
context: "Een kleine test om te zien of we context kunnen doorgeven en tonen"
required: true
address:
name: "Address"
type: "string"
description: "Your Address"
required: false
zip:
name: "Postal Code"
type: "string"
description: "Postal Code"
required: false
city:
name: "City"
type: "string"
description: "City"
required: false
country:
name: "Country"
type: "string"
description: "Country"
required: false
consent:
name: "Consent"
type: "boolean"
description: "Consent"
required: true

View File

@@ -0,0 +1,55 @@
type: "PROFESSIONAL_CONTACT_FORM"
version: "1.0.0"
name: "Professional Contact Form"
icon: "account_circle"
fields:
name:
name: "Name"
description: "Your name"
type: "str"
required: true
email:
name: "Email"
type: "str"
description: "Your Name"
required: true
phone:
name: "Phone Number"
type: "str"
description: "Your Phone Number"
required: true
company:
name: "Company Name"
type: "str"
description: "Company Name"
required: true
job_title:
name: "Job Title"
type: "str"
description: "Job Title"
required: false
address:
name: "Address"
type: "str"
description: "Your Address"
required: false
zip:
name: "Postal Code"
type: "str"
description: "Postal Code"
required: false
city:
name: "City"
type: "str"
description: "City"
required: false
country:
name: "Country"
type: "str"
description: "Country"
required: false
consent:
name: "Consent"
type: "bool"
description: "Consent"
required: true

View File

@@ -0,0 +1,44 @@
version: "1.2.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
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: "Updated for unified competencies and ko criteria"
description: "Assistant to create a new Vacancy based on Vacancy Text"

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

@@ -5,12 +5,17 @@ partner: "traicie"
chat: false chat: false
configuration: configuration:
name: name:
name: "name" name: "Name"
description: "The name the specialist is called upon." description: "The name the specialist is called upon."
type: "str" type: "str"
required: true required: true
role_reference:
name: "Role Reference"
description: "A customer reference to the role"
type: "str"
required: false
competencies: competencies:
name: "competencies" name: "Competencies"
description: "An ordered list of competencies." description: "An ordered list of competencies."
type: "ordered_list" type: "ordered_list"
list_type: "competency_details" list_type: "competency_details"
@@ -41,17 +46,17 @@ configuration:
required: false required: false
competency_details: competency_details:
title: title:
name: "title" name: "Title"
description: "Competency Title" description: "Competency Title"
type: "str" type: "str"
required: true required: true
description: description:
name: "description" name: "Description"
description: "Description (in context of the role) of the competency" description: "Description (in context of the role) of the competency"
type: "text" type: "text"
required: true required: true
is_knockout: is_knockout:
name: "Is Knockout" name: "KO"
description: "Defines if the competency is a knock-out criterium" description: "Defines if the competency is a knock-out criterium"
type: "boolean" type: "boolean"
required: true required: true
@@ -63,10 +68,32 @@ competency_details:
required: true required: true
default: true default: true
arguments: arguments:
vacancy_text: region:
name: "vacancy_text" name: "Region"
type: "text" type: "str"
description: "The Vacancy Text" 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 required: true
results: results:
competencies: 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,120 @@
version: "1.3.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_RECRUITER"
version: "1.0"
tasks:
- type: "TRAICIE_KO_CRITERIA_INTERVIEW_DEFINITION"
version: "1.0"
metadata:
author: "Josako"
date_added: "2025-06-16"
changes: "Realising the actual interaction with the LLM"
description: "Assistant to create a new Vacancy based on Vacancy Text"

View File

@@ -0,0 +1,120 @@
version: "1.3.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_RECRUITER_AGENT"
version: "1.0"
tasks:
- type: "TRAICIE_KO_CRITERIA_INTERVIEW_DEFINITION_TASK"
version: "1.0"
metadata:
author: "Josako"
date_added: "2025-06-18"
changes: "Add make to the selection specialist"
description: "Assistant to create a new Vacancy based on Vacancy Text"

View File

@@ -0,0 +1,30 @@
version: "1.0.0"
name: "KO Criteria Interview Definition"
task_description: >
In context of a vacancy in your company {tenant_name}, you are provided with a set of competencies. (both description
and title). The competencies are in between triple backquotes. You need to prepare for the interviews,
and are to provide for each of these ko criteria:
- A question to ask the recruitment candidate describing the context of the competency. Use your experience to not
just ask a closed question, but a question from which you can indirectly derive a positive or negative qualification of
the competency based on the answer of the candidate.
Apply the following tone of voice in both questions and answers: {tone_of_voice}
Apply the following language level in both questions and answers: {language_level}
Use {language} as language for both questions and answers.
```{competencies}```
{custom_description}
expected_output: >
For each of the ko criteria, you provide:
- the exact title in the original language
- the question
- a set of answers, with for each answer an indication if it is the correct answer, or a false response.
{custom_expected_output}
metadata:
author: "Josako"
date_added: "2025-06-15"
description: "A Task to define interview Q&A from given KO Criteria"
changes: "Initial Version"

View File

@@ -0,0 +1,42 @@
version: "1.0.0"
name: "KO Criteria Interview Definition"
task_description: >
In context of a vacancy in your company {tenant_name}, you are provided with a set of knock-out criteria
(both description and title). The criteria are in between triple backquotes.You need to prepare for the interviews,
and are to provide for each of these ko criteria:
- A short question to ask the recruitment candidate describing the context of the ko criterium. Use your experience to
ask a question that enables us to verify compliancy to the criterium.
- A set of 2 short answers to that question, from the candidates perspective. One of the answers will result in a
positive evaluation of the criterium, the other one in a negative evaluation. Mark each of the answers as positive
or negative.
Describe the answers from the perspective of the candidate. Be sure to include all necessary aspects in you answers.
Apply the following tone of voice in both questions and answers: {tone_of_voice}
Use the following description to understand tone of voice:
{tone_of_voice_context}
Apply the following language level in both questions and answers: {language_level}
Use {language} as language for both questions and answers.
Use the following description to understand language_level:
{language_level_context}
```{ko_criteria}```
{custom_description}
expected_output: >
For each of the ko criteria, you provide:
- the exact title as specified in the original language
- the question in {language}
- a positive answer, resulting in a positive evaluation of the criterium. In {language}.
- a negative answer, resulting in a negative evaluation of the criterium. In {language}.
{custom_expected_output}
metadata:
author: "Josako"
date_added: "2025-06-15"
description: "A Task to define interview Q&A from given KO Criteria"
changes: "Initial Version"

View File

@@ -0,0 +1,37 @@
version: "1.0.0"
name: "KO Criteria Interview Definition"
task_description: >
In context of a vacancy in your company {tenant_name}, you are provided with a set of competencies
(both description and title). The competencies are in between triple backquotes. The competencies provided should be
handled as knock-out criteria.
For each of the knock-out criteria, you need to define
- A short (1 sentence), closed-ended question (Yes / No) to ask the recruitment candidate. Use your experience to ask a question that
enables us to verify compliancy to the criterium.
- A set of 2 short answers (1 small sentence each) to that question (positive answer / negative answer), from the
candidates perspective.
The positive answer will result in a positive evaluation of the criterium, the negative answer in a negative evaluation
of the criterium. Try to avoid just using Yes / No as positive and negative answers.
Apply the following tone of voice in both questions and answers: {tone_of_voice}, i.e. {tone_of_voice_context}
Apply the following language level in both questions and answers: {language_level}, i.e. {language_level_context}
Use {language} as language for both questions and answers.
```{ko_criteria}```
{custom_description}
expected_output: >
For each of the ko criteria, you provide:
- the exact title as specified in the original language
- the question in {language}
- a positive answer, resulting in a positive evaluation of the criterium. In {language}.
- a negative answer, resulting in a negative evaluation of the criterium. In {language}.
{custom_expected_output}
metadata:
author: "Josako"
date_added: "2025-06-20"
description: "A Task to define interview Q&A from given KO Criteria"
changes: "Improvement to ensure closed-ended questions and short descriptions"

View File

@@ -32,5 +32,10 @@ AGENT_TYPES = {
"name": "Traicie HR BP Agent", "name": "Traicie HR BP Agent",
"description": "An HR Business Partner Agent", "description": "An HR Business Partner Agent",
"partner": "traicie" "partner": "traicie"
} },
"TRAICIE_RECRUITER_AGENT": {
"name": "Traicie Recruiter Agent",
"description": "An Senior Recruiter Agent",
"partner": "traicie"
},
} }

View File

@@ -2,28 +2,10 @@
CATALOG_TYPES = { CATALOG_TYPES = {
"STANDARD_CATALOG": { "STANDARD_CATALOG": {
"name": "Standard Catalog", "name": "Standard Catalog",
"Description": "A Catalog with information in Evie's Library, to be considered as a whole", "description": "A Catalog with information in Evie's Library, to be considered as a whole",
"configuration": {},
"document_version_configurations": []
}, },
"DOSSIER_CATALOG": { "DOSSIER_CATALOG": {
"name": "Dossier Catalog", "name": "Dossier Catalog",
"Description": "A Catalog with information in Evie's Library in which several Dossiers can be stored", "description": "A Catalog with information in Evie's Library in which several Dossiers can be stored",
"configuration": {
"tagging_fields": {
"name": "Tagging Fields",
"type": "tagging_fields",
"description": """Define the metadata fields that will be used for tagging documents.
Each field must have:
- type: one of 'string', 'integer', 'float', 'date', 'enum'
- required: boolean indicating if the field is mandatory
- description: field description
- allowed_values: list of values (for enum type only)
- min_value/max_value: range limits (for numeric types only)""",
"required": True,
"default": {},
}
},
"document_version_configurations": ["tagging_fields"]
}, },
} }

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

@@ -1,168 +1,28 @@
# Catalog Types # Processor Types
PROCESSOR_TYPES = { PROCESSOR_TYPES = {
"HTML_PROCESSOR": { "HTML_PROCESSOR": {
"name": "HTML Processor", "name": "HTML Processor",
"description": "A processor for HTML files",
"file_types": "html", "file_types": "html",
"Description": "A processor for HTML files",
"configuration": {
"chunking_patterns": {
"name": "Chunking Patterns",
"description": "A list of Patterns used to chunk files into logical pieces",
"type": "chunking_patterns",
"required": False
},
"chunking_heading_level": {
"name": "Chunking Heading Level",
"type": "integer",
"description": "Maximum heading level to consider for chunking (1-6)",
"required": False,
"default": 2
},
"html_tags": {
"name": "HTML Tags",
"type": "string",
"description": "A comma-separated list of HTML tags",
"required": True,
"default": "p, h1, h2, h3, h4, h5, h6, li, table, thead, tbody, tr, td"
},
"html_end_tags": {
"name": "HTML End Tags",
"type": "string",
"description": "A comma-separated list of HTML end tags (where can the chunk end)",
"required": True,
"default": "p, li, table"
},
"html_included_elements": {
"name": "HTML Included Elements",
"type": "string",
"description": "A comma-separated list of elements to be included",
"required": True,
"default": "article, main"
},
"html_excluded_elements": {
"name": "HTML Excluded Elements",
"type": "string",
"description": "A comma-separated list of elements to be excluded",
"required": False,
"default": "header, footer, nav, script"
},
"html_excluded_classes": {
"name": "HTML Excluded Classes",
"type": "string",
"description": "A comma-separated list of classes to be excluded",
"required": False,
},
},
}, },
"PDF_PROCESSOR": { "PDF_PROCESSOR": {
"name": "PDF Processor", "name": "PDF Processor",
"description": "A Processor for PDF files",
"file_types": "pdf", "file_types": "pdf",
"Description": "A Processor for PDF files",
"configuration": {
"chunking_patterns": {
"name": "Chunking Patterns",
"description": "A list of Patterns used to chunk files into logical pieces",
"type": "chunking_patterns",
"required": False
},
"chunking_heading_level": {
"name": "Chunking Heading Level",
"type": "integer",
"description": "Maximum heading level to consider for chunking (1-6)",
"required": False,
"default": 2
},
},
}, },
"AUDIO_PROCESSOR": { "AUDIO_PROCESSOR": {
"name": "AUDIO Processor", "name": "AUDIO Processor",
"description": "A Processor for audio files",
"file_types": "mp3, mp4, ogg", "file_types": "mp3, mp4, ogg",
"Description": "A Processor for audio files",
"configuration": {}
}, },
"MARKDOWN_PROCESSOR": { "MARKDOWN_PROCESSOR": {
"name": "Markdown Processor", "name": "Markdown Processor",
"description": "A Processor for markdown files",
"file_types": "md", "file_types": "md",
"Description": "A Processor for markdown files",
"configuration": {
"chunking_patterns": {
"name": "Chunking Patterns",
"description": "A list of Patterns used to chunk files into logical pieces",
"type": "chunking_patterns",
"required": False
},
"chunking_heading_level": {
"name": "Chunking Heading Level",
"type": "integer",
"description": "Maximum heading level to consider for chunking (1-6)",
"required": False,
"default": 2
},
}
}, },
"DOCX_PROCESSOR": { "DOCX_PROCESSOR": {
"name": "DOCX Processor", "name": "DOCX Processor",
"description": "A processor for DOCX files",
"file_types": "docx", "file_types": "docx",
"Description": "A processor for DOCX files",
"configuration": {
"chunking_patterns": {
"name": "Chunking Patterns",
"description": "A list of Patterns used to chunk files into logical pieces",
"type": "chunking_patterns",
"required": False
},
"chunking_heading_level": {
"name": "Chunking Heading Level",
"type": "integer",
"description": "Maximum heading level to consider for chunking (1-6)",
"required": False,
"default": 2
},
"extract_comments": {
"name": "Extract Comments",
"type": "boolean",
"description": "Whether to include document comments in the markdown",
"required": False,
"default": False
},
"extract_headers_footers": {
"name": "Extract Headers/Footers",
"type": "boolean",
"description": "Whether to include headers and footers in the markdown",
"required": False,
"default": False
},
"preserve_formatting": {
"name": "Preserve Formatting",
"type": "boolean",
"description": "Whether to preserve bold, italic, and other text formatting",
"required": False,
"default": True
},
"list_style": {
"name": "List Style",
"type": "enum",
"description": "How to format lists in markdown",
"required": False,
"default": "dash",
"allowed_values": ["dash", "asterisk", "plus"]
},
"image_handling": {
"name": "Image Handling",
"type": "enum",
"description": "How to handle embedded images",
"required": False,
"default": "skip",
"allowed_values": ["skip", "extract", "placeholder"]
},
"table_alignment": {
"name": "Table Alignment",
"type": "enum",
"description": "How to align table contents",
"required": False,
"default": "left",
"allowed_values": ["left", "center", "preserve"]
}
}
} }
} }

View File

@@ -0,0 +1,11 @@
# Specialist Form Types
SPECIALIST_FORM_TYPES = {
"PERSONAL_CONTACT_FORM": {
"name": "Personal Contact Form",
"description": "A form for entering your personal contact details",
},
"PROFESSIONAL_CONTACT_FORM": {
"name": "Professional Contact Form",
"description": "A form for entering your professional contact details",
},
}

View File

@@ -16,5 +16,9 @@ SPECIALIST_TYPES = {
"name": "Traicie Role Definition Specialist", "name": "Traicie Role Definition Specialist",
"description": "Assistant Defining Competencies and KO Criteria", "description": "Assistant Defining Competencies and KO Criteria",
"partner": "traicie" "partner": "traicie"
},
"TRAICIE_SELECTION_SPECIALIST": {
"name": "Traicie Selection Specialist",
"description": "Recruitment Selection Assistant",
} }
} }

View File

@@ -41,5 +41,10 @@ TASK_TYPES = {
"name": "Traicie Get KO Criteria", "name": "Traicie Get KO Criteria",
"description": "A Task to get KO Criteria from a Vacancy Text", "description": "A Task to get KO Criteria from a Vacancy Text",
"partner": "traicie" "partner": "traicie"
},
"TRAICIE_KO_CRITERIA_INTERVIEW_DEFINITION_TASK": {
"name": "Traicie KO Criteria Interview Definition",
"description": "A Task to define KO Criteria questions to be used during the interview",
"partner": "traicie"
} }
} }

View File

@@ -0,0 +1,416 @@
# Changelog
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.7-alfa]
### Added
- Basic Base Specialist additions for handling phases and transferring data between state and output
- Introduction of URL and QR-code for MagicLink
### Changed
- Logging improvement & simplification (remove Graylog)
- Traicie Selection Specialist v1.3 - full roundtrip & full process
## [2.3.6-alfa]
### Added
- Full Chat Client functionality, including Forms, ESS, theming
- First Demo version of Traicie Selection Specialist
## [2.3.5-alfa]
### Added
- Chat Client Initialisation (based on SpecialistMagicLink code)
- Definition of framework for the chat_client (using vue.js)
### Changed
- Remove AllowedLanguages from Tenant
- Remove Tenant URL (now in Make)
- Adapt chat client customisation options
### Fixed
- Several Bugfixes to administrative app
## [2.3.4-alfa]
### Added
- Introduction of Tenant Make
- Introduction of 'system' type for dynamic attributes
- Introduce Tenant Make to Traicie Specialists
### Changed
- Enable Specialist 'activation' / 'deactivation'
- Unique constraints introduced for Catalog Name (tenant level) and make name (public level)
## [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
- Changelog display
- Introduction of Specialist Magic Links
### Fixed
- dynamic fields for adding documents / urls to dossier catalog
- tabs in latest bootstrap version no longer functional
- partner association of license tier not working when no partner selected
- data-type dynamic field needs conversion to isoformat
- Add public tables to env.py of tenant schema
## [2.3.1-alfa]
### Added
- Introduction of ordered_list dynamic field type (using tabulator)
### Changed
- Bring configuration of PROCESSOR_TYPES & CATALOG_TYPES to new config standard
- Specialist Editor: move general information in tab
- Role Definition Specialist creates Selection Specialist from generated competencies
- Improvements to Selection Specialist (Agent definition to be started)
## [2.3.0-alfa]
### Added
- Introduction of Push Gateway for Prometheus
- Introduction of Partner Models
- Introduction of Tenant and Partner codes for more security
- Introduction of 'Management Partner' type and additional 'Partner Admin'-role
- Introduction of a technical services layer
- Introduction of partner-specific configurations
- Introduction of additional test environment
- Introduction of strict no-overage usage
- Introduction of LicensePeriod, Payments & Invoices
- Introduction of Processed File Viewer
- Introduction of Traicie Role Definition Specialist
- Allow invocation of non-interactive specialists in administrative interface (eveai_app)
- Introduction of advanced JSON editor
- 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
- Add additional environment capabilities in docker
- PDF Processor now uses Mistral OCR
- Allow additional chunking mechanisms for very long chunks (in case of very large documents)
- Allow for TrackedMistralAIEmbedding batching to allow for processing long documents
- RAG & SPIN Specialist improvements
- Move mail messaging from standard SMTP to Scaleway TEM mails
- Improve mail layouts
- Add functionality to add a default dictionary for dynamic forms
- AI model choices defined by Ask Eve AI iso Tenant (replaces ModelVariables completely)
- Improve HTML Processing
- Pagination improvements
- Update Material Kit Pro to latest version
### Removed
- Repopack implementation ==> Using PyCharm's new AI capabilities instead
### Fixed
- Synchronous vs Asynchronous behaviour in crewAI type specialists
- Nasty dynamic boolean fields bug corrected
- Several smaller bugfixes
- Tasks & Tools editors finished
### Security
- In case of vulnerabilities.
## [2.2.0-alfa]
### Added
- Mistral AI as main provider for embeddings, chains and specialists
- Usage measuring for specialists
- RAG from chain to specialist technology
- Dossier catalog management possibilities added to eveai_app
- Asset definition (Paused - other priorities)
- Prometheus and Grafana
- Add prometheus monitoring to business events
- Asynchronous execution of specialists
### Changed
- Moved choice for AI providers / models to specialists and prompts
- Improve RAG to not repeat historic answers
- Fixed embedding model, no more choices allowed
- clean url (of tracking parameters) before adding it to a catalog
### Deprecated
- For soon-to-be removed features.
### Removed
- Add Multiple URLs removed from menu
- Old Specialist items removed from interaction menu
-
### Fixed
- Set default language when registering Documents or URLs.
## [2.1.0-alfa]
### Added
- Zapier Refresh Document
- SPIN Specialist definition - from start to finish
- Introduction of startup scripts in eveai_app
- Caching for all configurations added
- Caching for processed specialist configurations
- Caching for specialist history
- Augmented Specialist Editor, including Specialist graphic presentation
- Introduction of specialist_execution_api, introducting SSE
- Introduction of crewai framework for specialist implementation
- Test app for testing specialists - also serves as a sample client application for SSE
-
### Changed
- Improvement of startup of applications using gevent, and better handling and scaling of multiple connections
- STANDARD_RAG Specialist improvement
-
### Deprecated
- eveai_chat - using sockets - will be replaced with new specialist_execution_api and SSE
## [2.0.1-alfa]
### Added
- Zapîer Integration (partial - only adding files).
- Addition of general chunking parameters (chunking_heading_level and chunking_patterns)
- Addition of DocX and markdown Processor Types
### Changed
- For changes in existing functionality.
### Deprecated
- For soon-to-be removed features.
### Removed
- For now removed features.
### Fixed
- Ensure the RAG Specialist is using the detailed_question
- Wordpress Chat Plugin: languages dropdown filled again
- OpenAI update - proxies no longer supported
- Build & Release script for Wordpress Plugins (including end user download folder)
### Security
- In case of vulnerabilities.
## [2.0.0-alfa]
### Added
- Introduction of dynamic Retrievers & Specialists
- Introduction of dynamic Processors
- Introduction of caching system
- Introduction of a better template manager
- Modernisation of external API/Socket authentication using projects
- Creation of new eveai_chat WordPress plugin to support specialists
### Changed
- Update of eveai_sync WordPress plugin
### Fixed
- Set default language when registering Documents or URLs.
### Security
- Security improvements to Docker images
## [1.0.14-alfa]
### Added
- New release script added to tag images with release number
- Allow the addition of multiple types of Catalogs
- Generic functionality to enable dynamic fields
- Addition of Retrievers to allow for smart collection of information in Catalogs
- Add dynamic fields to Catalog / Retriever / DocumentVersion
### Changed
- Processing parameters defined at Catalog level iso Tenant level
- Reroute 'blank' paths to 'admin'
### Deprecated
- For soon-to-be removed features.
### Removed
- For now removed features.
### Fixed
- Set default language when registering Documents or URLs.
### Security
- In case of vulnerabilities.
## [1.0.13-alfa]
### Added
- Finished Catalog introduction
- Reinitialization of WordPress site for syncing
### Changed
- Modification of WordPress Sync Component
- Cleanup of attributes in Tenant
### Fixed
- Overall bugfixes as result from the Catalog introduction
## [1.0.12-alfa]
### Added
- Added Catalog functionality
### Changed
- For changes in existing functionality.
### Deprecated
- For soon-to-be removed features.
### Removed
- For now removed features.
### Fixed
- Set default language when registering Documents or URLs.
### Security
- In case of vulnerabilities.
## [1.0.11-alfa]
### Added
- License Usage Calculation realised
- View License Usages
- Celery Beat container added
- First schedule in Celery Beat for calculating usage (hourly)
### Changed
- repopack can now split for different components
### Fixed
- Various fixes as consequence of changing file_location / file_name ==> bucket_name / object_name
- Celery Routing / Queuing updated
## [1.0.10-alfa]
### Added
- BusinessEventLog monitoring using Langchain native code
### Changed
- Allow longer audio files (or video) to be uploaded and processed
- Storage and Embedding usage now expressed in MiB iso tokens (more logical)
- Views for License / LicenseTier
### Removed
- Portkey removed for monitoring usage
## [1.0.9-alfa] - 2024/10/01
### Added
- Business Event tracing (eveai_workers & eveai_chat_workers)
- Flower Container added for monitoring
### Changed
- Healthcheck improvements
- model_utils turned into a class with lazy loading
### Deprecated
- For soon-to-be removed features.
### Removed
- For now removed features.
### Fixed
- Set default language when registering Documents or URLs.
## [1.0.8-alfa] - 2024-09-12
### Added
- Tenant type defined to allow for active, inactive, demo ... tenants
- Search and filtering functionality on Tenants
- Implementation of health checks (1st version)
- Provision for Prometheus monitoring (no implementation yet)
- Refine audio_processor and srt_processor to reduce duplicate code and support larger files
- Introduction of repopack to reason in LLMs about the code
### Fixed
- Refine audio_processor and srt_processor to reduce duplicate code and support larger files
## [1.0.7-alfa] - 2024-09-12
### Added
- Full Document API allowing for creation, updating and invalidation of documents.
- Metadata fields (JSON) added to DocumentVersion, allowing end-users to add structured information
- Wordpress plugin eveai_sync to synchronize Wordpress content with EveAI
### Fixed
- Maximal deduplication of code between views and api in document_utils.py
## [1.0.6-alfa] - 2024-09-03
### Fixed
- Problems with tenant scheme migrations - may have to be revisited
- Correction of default language settings when uploading docs or URLs
- Addition of a CHANGELOG.md file
## [1.0.5-alfa] - 2024-09-02
### Added
- Allow chatwidget to connect to multiple servers (e.g. development and production)
- Start implementation of API
- Add API-key functionality to tenants
- Deduplication of API and Document view code
- Allow URL addition to accept all types of files, not just HTML
- Allow new file types upload: srt, mp3, ogg, mp4
- Improve processing of different file types using Processor classes
### Removed
- Removed direct upload of Youtube URLs, due to continuous changes in Youtube website
## [1.0.4-alfa] - 2024-08-27
Skipped
## [1.0.3-alfa] - 2024-08-27
### Added
- Refinement of HTML processing - allow for excluded classes and elements.
- Allow for multiple instances of Evie on 1 website (pure + Wordpress plugin)
### Changed
- PDF Processing extracted in new PDF Processor class.
- Allow for longer and more complex PDFs to be uploaded.
## [1.0.2-alfa] - 2024-08-22
### Fixed
- Bugfix for ResetPasswordForm in config.py
## [1.0.1-alfa] - 2024-08-21
### Added
- Full Document Version Overview
### Changed
- Improvements to user creation and registration, renewal of passwords, ...
## [1.0.0-alfa] - 2024-08-16
### Added
- Initial release of the project.
### Changed
- None
### Fixed
- None
[Unreleased]: https://github.com/username/repo/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/username/repo/releases/tag/v1.0.0

View File

@@ -0,0 +1,37 @@
# Privacy Policy
## Version 1.0.0
*Effective Date: 2025-06-03*
### 1. Introduction
This Privacy Policy describes how EveAI collects, uses, and discloses your information when you use our services.
### 2. Information We Collect
We collect information you provide directly to us, such as account information, content you process through our services, and communication data.
### 3. How We Use Your Information
We use your information to provide, maintain, and improve our services, process transactions, send communications, and comply with legal obligations.
### 4. Data Security
We implement appropriate security measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction.
### 5. International Data Transfers
Your information may be transferred to and processed in countries other than the country you reside in, where data protection laws may differ.
### 6. Your Rights
Depending on your location, you may have certain rights regarding your personal information, such as access, correction, deletion, or restriction of processing.
### 7. Changes to This Policy
We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page.
### 8. Contact Us
If you have any questions about this Privacy Policy, please contact us at privacy@askeveai.be.

View File

@@ -0,0 +1,37 @@
# Terms of Service
## Version 1.0.0
*Effective Date: 2025-06-03*
### 1. Introduction
Welcome to EveAI. By accessing or using our services, you agree to be bound by these Terms of Service.
### 2. Service Description
EveAI provides AI-powered solutions for businesses to optimize their operations through intelligent document processing and specialist execution.
### 3. User Accounts
To access certain features of the Service, you must register for an account. You are responsible for maintaining the confidentiality of your account information.
### 4. Privacy
Your use of the Service is also governed by our Privacy Policy, which can be found [here](/content/privacy).
### 5. Intellectual Property
All content, features, and functionality of the Service are owned by EveAI and are protected by international copyright, trademark, and other intellectual property laws.
### 6. Limitation of Liability
In no event shall EveAI be liable for any indirect, incidental, special, consequential or punitive damages.
### 7. Changes to Terms
We reserve the right to modify these Terms at any time. Your continued use of the Service after such modifications will constitute your acceptance of the new Terms.
### 8. Governing Law
These Terms shall be governed by the laws of Belgium.

View File

@@ -70,6 +70,7 @@ services:
depends_on: depends_on:
- eveai_app - eveai_app
- eveai_api - eveai_api
- eveai_chat_client
networks: networks:
- eveai-network - eveai-network
@@ -91,6 +92,7 @@ services:
volumes: volumes:
- ../eveai_app:/app/eveai_app - ../eveai_app:/app/eveai_app
- ../common:/app/common - ../common:/app/common
- ../content:/app/content
- ../config:/app/config - ../config:/app/config
- ../migrations:/app/migrations - ../migrations:/app/migrations
- ../scripts:/app/scripts - ../scripts:/app/scripts
@@ -176,6 +178,44 @@ services:
# networks: # networks:
# - eveai-network # - 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: eveai_chat_workers:
image: josakola/eveai_chat_workers:latest image: josakola/eveai_chat_workers:latest
build: build:
@@ -440,4 +480,3 @@ volumes:
#secrets: #secrets:
# db-password: # db-password:
# file: ./db/password.txt # file: ./db/password.txt

View File

@@ -56,6 +56,7 @@ services:
depends_on: depends_on:
- eveai_app - eveai_app
- eveai_api - eveai_api
- eveai_chat_client
networks: networks:
- eveai-network - eveai-network
restart: "no" restart: "no"
@@ -106,6 +107,33 @@ services:
- eveai-network - eveai-network
restart: "no" 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: eveai_chat_workers:
image: josakola/eveai_chat_workers:${EVEAI_VERSION:-latest} image: josakola/eveai_chat_workers:${EVEAI_VERSION:-latest}
expose: expose:

View File

@@ -115,15 +115,41 @@ echo "Set COMPOSE_FILE to $COMPOSE_FILE"
echo "Set EVEAI_VERSION to $VERSION" echo "Set EVEAI_VERSION to $VERSION"
echo "Set DOCKER_ACCOUNT to $DOCKER_ACCOUNT" echo "Set DOCKER_ACCOUNT to $DOCKER_ACCOUNT"
# Define aliases for common Docker commands docker-compose() {
alias docker-compose="docker compose -f $COMPOSE_FILE" docker compose -f $COMPOSE_FILE "$@"
alias dc="docker compose -f $COMPOSE_FILE" }
alias dcup="docker compose -f $COMPOSE_FILE up -d --remove-orphans"
alias dcdown="docker compose -f $COMPOSE_FILE down" dc() {
alias dcps="docker compose -f $COMPOSE_FILE ps" docker compose -f $COMPOSE_FILE "$@"
alias dclogs="docker compose -f $COMPOSE_FILE logs" }
alias dcpull="docker compose -f $COMPOSE_FILE pull"
alias dcrefresh="docker compose -f $COMPOSE_FILE pull && docker compose -f $COMPOSE_FILE up -d --remove-orphans" dcup() {
docker compose -f $COMPOSE_FILE up -d --remove-orphans "$@"
}
dcdown() {
docker compose -f $COMPOSE_FILE down "$@"
}
dcps() {
docker compose -f $COMPOSE_FILE ps "$@"
}
dclogs() {
docker compose -f $COMPOSE_FILE logs "$@"
}
dcpull() {
docker compose -f $COMPOSE_FILE pull "$@"
}
dcrefresh() {
docker compose -f $COMPOSE_FILE pull && docker compose -f $COMPOSE_FILE up -d --remove-orphans "$@"
}
# Exporteer de functies zodat ze beschikbaar zijn in andere scripts
export -f docker-compose dc dcup dcdown dcps dclogs dcpull dcrefresh
echo "Docker environment switched to $ENVIRONMENT with version $VERSION" echo "Docker environment switched to $ENVIRONMENT with version $VERSION"
echo "You can now use 'docker-compose', 'dc', 'dcup', 'dcdown', 'dcps', 'dclogs', 'dcpull' or 'dcrefresh' commands" echo "You can now use 'docker-compose', 'dc', 'dcup', 'dcdown', 'dcps', 'dclogs', 'dcpull' or 'dcrefresh' commands"

View File

@@ -56,6 +56,7 @@ COPY config /app/config
COPY migrations /app/migrations COPY migrations /app/migrations
COPY scripts /app/scripts COPY scripts /app/scripts
COPY patched_packages /app/patched_packages COPY patched_packages /app/patched_packages
COPY content /app/content
# Set permissions for entrypoint script # Set permissions for entrypoint script
RUN chmod 777 /app/scripts/entrypoint.sh RUN chmod 777 /app/scripts/entrypoint.sh

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

9
docker/rebuild_chat_client.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
source ./docker_env_switch.sh dev
source .env
dcdown eveai_chat_client nginx
./update_chat_client_statics.sh
./build_and_push_eveai.sh -b nginx
dcup eveai_chat_client nginx

View File

@@ -0,0 +1,49 @@
#!/bin/bash
# Script to copy eveai_chat_client/static files to nginx/static
# without overwriting existing files
SRC_DIR="../eveai_chat_client/static/assets"
DEST_DIR="../nginx/static/assets"
# Check if source directory exists
if [ ! -d "$SRC_DIR" ]; then
echo "Error: Source directory $SRC_DIR does not exist!"
exit 1
fi
# Create destination directory if it doesn't exist
if [ ! -d "$DEST_DIR" ]; then
echo "Destination directory $DEST_DIR does not exist. Creating it..."
mkdir -p "$DEST_DIR"
fi
# Function to recursively copy files without overwriting
copy_without_overwrite() {
local src=$1
local dest=$2
# Loop through all items in source directory
for item in "$src"/*; do
# Get the filename from the path
base_name=$(basename "$item")
# If it's a directory, create it in the destination and recurse
if [ -d "$item" ]; then
if [ ! -d "$dest/$base_name" ]; then
echo "Creating directory: $dest/$base_name"
mkdir -p "$dest/$base_name"
fi
copy_without_overwrite "$item" "$dest/$base_name"
else
# If it's a file and doesn't exist in the destination, copy it
cp "$item" "$dest/$base_name"
fi
done
}
# Start the copy process
echo "Starting to copy files from $SRC_DIR to $DEST_DIR..."
copy_without_overwrite "$SRC_DIR" "$DEST_DIR"
echo "Copy completed!"

View File

@@ -0,0 +1,516 @@
# Evie Chat Client - Developer Documentation
## Overview
The Evie Chat Client is a modern, customizable chat interface for interacting with eveai specialists. It supports both anonymous and authenticated modes, with initial focus on anonymous mode. The client provides real-time interaction with AI specialists, customizable tenant branding, European-compliant analytics tracking, and secure QR code access.
## Key Features
- **Anonymous Mode**: Public access with tenant UUID and API key authentication
- **QR Code Access**: Secure pre-authenticated landing pages for QR code integration
- **Real-time Communication**: Server-Sent Events (SSE) for live updates and intermediate states
- **Tenant Customization**: Simple CSS variable-based theming with visual editor
- **Multiple Choice Options**: Dynamic button/dropdown responses from specialists
- **Chat History**: Persistent ChatSession and Interaction storage
- **File Upload Support**: Planned for future implementation
- **European Analytics**: Umami integration for GDPR-compliant tracking
## Architecture
### Project Structure
```
evie-project/
├── common/ # Shared code across components
│ ├── services/ # Reusable business logic
│ │ ├── chat_service.py # Chat session management
│ │ ├── specialist_service.py # Specialist interaction wrapper
│ │ ├── tenant_service.py # Tenant config & theming
│ │ └── qr_service.py # QR code session management
│ └── utils/ # Utility functions
│ ├── auth.py # API key validation
│ ├── tracking.py # Umami analytics integration
│ └── qr_utils.py # QR code generation utilities
├── eveai_chat_client/ # Chat client component
│ ├── app.py # Flask app entry point
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── chat_routes.py # Main chat interface routes
│ │ ├── api_routes.py # SSE/API endpoints
│ │ └── qr_routes.py # QR code landing pages
│ └── templates/
│ ├── base.html # Base template
│ ├── chat.html # Main chat interface
│ ├── qr_expired.html # QR code error page
│ └── components/
│ ├── message.html # Individual message component
│ ├── options.html # Multiple choice options
│ └── thinking.html # Intermediate states display
└── eveai_app/ # Admin interface (existing)
└── qr_management/ # QR code creation interface
├── create_qr.py
└── qr_templates.html
```
### Integration Approach
- **Services Layer**: Direct integration with common/services for better performance
- **Database**: Utilizes existing ChatSession and Interaction models
- **Caching**: Leverages existing Redis setup
- **Static Files**: Uses existing nginx/static structure
## QR Code Access Flow
### QR Code System Architecture
```mermaid
sequenceDiagram
participant Admin as Admin (eveai_app)
participant QRService as QR Service (common)
participant PublicDB as Public Schema
participant TenantDB as Tenant Schema
participant User as End User
participant ChatClient as Chat Client
participant ChatSession as Chat Session
%% QR Code Creation Flow
Admin->>QRService: Create QR code with specialist config
QRService->>PublicDB: Store qr_lookup (qr_id → tenant_code)
QRService->>TenantDB: Store qr_sessions (full config + args)
QRService->>Admin: Return QR code image with /qr/{qr_id}
%% QR Code Usage Flow
User->>ChatClient: Scan QR → GET /qr/{qr_id}
ChatClient->>PublicDB: Lookup tenant_code by qr_id
ChatClient->>TenantDB: Get full QR session data
ChatClient->>ChatSession: Create ChatSession with pre-filled args
ChatClient->>User: Set temp auth + redirect to chat interface
User->>ChatClient: Access chat with pre-authenticated session
```
### QR Code Data Flow
```mermaid
flowchart TD
A[Admin Creates QR Code] --> B[Generate UUID for QR Session]
B --> C[Store Lookup in Public Schema]
C --> D[Store Full Data in Tenant Schema]
D --> E[Generate QR Code Image]
F[User Scans QR Code] --> G[Extract QR Session ID from URL]
G --> H[Lookup Tenant Code in Public Schema]
H --> I[Retrieve Full QR Data from Tenant Schema]
I --> J{QR Valid & Not Expired?}
J -->|No| K[Show Error Page]
J -->|Yes| L[Create ChatSession with Pre-filled Args]
L --> M[Set Temporary Browser Authentication]
M --> N[Redirect to Chat Interface]
N --> O[Start Chat with Specialist]
```
## URL Structure & Parameters
### Main Chat Interface
```
GET /chat/{tenant_code}/{specialist_id}
```
**Query Parameters:**
- `api_key` (required for direct access): Tenant API key for authentication
- `session` (optional): Existing chat session ID
- `utm_source`, `utm_campaign`, `utm_medium` (optional): Analytics tracking
**Examples:**
```
# Direct access
/chat/550e8400-e29b-41d4-a716-446655440000/document-analyzer?api_key=xxx&utm_source=email
# QR code access (after redirect)
/chat/550e8400-e29b-41d4-a716-446655440000/document-analyzer?session=abc123-def456
```
### QR Code Landing Pages
```
GET /qr/{qr_session_id} # QR code entry point (redirects, no HTML page)
```
### API Endpoints
```
POST /api/chat/{tenant_code}/interact # Send message to specialist
GET /api/chat/{tenant_code}/status/{session_id} # SSE endpoint for updates
```
## Authentication & Security
### Anonymous Mode Access Methods
1. **Direct Access**: URL with API key parameter
2. **QR Code Access**: Pre-authenticated via secure landing page
### QR Code Security Model
- **QR Code Contains**: Only a UUID session identifier
- **Sensitive Data**: Stored securely in tenant database schema
- **Usage Control**: Configurable expiration and usage limits
- **Audit Trail**: Track QR code creation and usage
### Security Considerations
- Use tenant UUIDs to prevent enumeration attacks
- Validate API keys against tenant database
- Implement CORS policies for cross-origin requests
- Sanitize all user messages and file uploads
- QR sessions have configurable expiration and usage limits
## QR Code Management
### Database Schema
#### Public Schema (Routing Only)
```sql
CREATE TABLE qr_lookup (
qr_session_id UUID PRIMARY KEY,
tenant_code UUID NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_tenant_code (tenant_code)
);
```
#### Tenant Schema (Full QR Data)
```sql
CREATE TABLE qr_sessions (
id UUID PRIMARY KEY,
specialist_id UUID NOT NULL,
api_key VARCHAR(255) NOT NULL,
specialist_args JSONB,
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
usage_count INTEGER DEFAULT 0,
usage_limit INTEGER,
created_by_user_id UUID
);
```
### QR Code Creation (eveai_app)
```python
# In eveai_app admin interface
from common.services.qr_service import QRService
def create_specialist_qr_code():
qr_data = {
'tenant_code': current_tenant.code,
'specialist_id': selected_specialist.id,
'api_key': current_tenant.api_key,
'specialist_args': {
'department': 'sales',
'language': 'en',
'context': 'product_inquiry'
},
'metadata': {
'name': 'Sales Support QR - Product Brochure',
'usage_limit': 500,
'expires_days': 90
}
}
qr_service = QRService()
qr_session_id, qr_image = qr_service.create_qr_session(qr_data)
return qr_image
```
### QR Code Processing (eveai_chat_client)
```python
# In eveai_chat_client routes
from common.services.qr_service import QRService
from common.services.chat_service import ChatService
@app.route('/qr/<qr_session_id>')
def handle_qr_code(qr_session_id):
qr_service = QRService()
qr_data = qr_service.get_and_validate_qr_session(qr_session_id)
if not qr_data:
return render_template('qr_expired.html'), 410
# Create ChatSession with pre-filled arguments
chat_service = ChatService()
chat_session = chat_service.create_session(
tenant_code=qr_data['tenant_code'],
specialist_id=qr_data['specialist_id'],
initial_args=qr_data['specialist_args'],
source='qr_code'
)
# Set temporary authentication
flask_session['qr_auth'] = {
'tenant_code': qr_data['tenant_code'],
'api_key': qr_data['api_key'],
'chat_session_id': chat_session.id,
'expires_at': datetime.utcnow() + timedelta(hours=24)
}
# Redirect to chat interface
return redirect(f"/chat/{qr_data['tenant_code']}/{qr_data['specialist_id']}?session={chat_session.id}")
```
## Real-time Communication
### Server-Sent Events (SSE)
- **Connection**: Long-lived SSE connection per chat session
- **Message Types**:
- `message`: Complete specialist response
- `thinking`: Intermediate processing states
- `options`: Multiple choice response options
- `error`: Error messages
- `complete`: Interaction completion
### SSE Message Format
```json
{
"type": "thinking",
"data": {
"message": "Analyzing your request...",
"step": 1,
"total_steps": 3
}
}
```
## Tenant Customization
### Theme Configuration
Stored in tenant table as JSONB column:
```sql
ALTER TABLE tenants ADD COLUMN theme_config JSONB;
```
### CSS Variables Approach
Inline CSS variables in chat template:
```css
:root {
/* Brand Colors */
--primary-color: {{ tenant.theme_config.primary_color or '#007bff' }};
--secondary-color: {{ tenant.theme_config.secondary_color or '#6c757d' }};
--accent-color: {{ tenant.theme_config.accent_color or '#28a745' }};
/* Chat Interface */
--user-message-bg: {{ tenant.theme_config.user_message_bg or 'var(--primary-color)' }};
--bot-message-bg: {{ tenant.theme_config.bot_message_bg or '#f8f9fa' }};
--chat-bg: {{ tenant.theme_config.chat_bg or '#ffffff' }};
/* Typography */
--font-family: {{ tenant.theme_config.font_family or 'system-ui, -apple-system, sans-serif' }};
--font-size-base: {{ tenant.theme_config.font_size or '16px' }};
/* Branding */
--logo-url: url('/api/tenant/{{ tenant.code }}/logo');
--header-bg: {{ tenant.theme_config.header_bg or 'var(--primary-color)' }};
}
```
### Theme Editor (eveai_app)
Simple form interface with:
- Color pickers for brand colors
- Font selection dropdown
- Logo upload functionality
- Live preview of chat interface
- Reset to defaults option
## Multiple Choice Options
### Dynamic Rendering Logic
```python
def render_options(options_list):
if len(options_list) <= 3:
return render_template('components/options.html',
display_type='buttons',
options=options_list)
else:
return render_template('components/options.html',
display_type='dropdown',
options=options_list)
```
### Option Data Structure
```json
{
"type": "options",
"data": {
"question": "How would you like to proceed?",
"options": [
{"id": "option1", "text": "Continue analysis", "value": "continue"},
{"id": "option2", "text": "Generate report", "value": "report"},
{"id": "option3", "text": "Start over", "value": "restart"}
]
}
}
```
## Analytics Integration
### Umami Setup
- **European Hosting**: Self-hosted Umami instance
- **Privacy Compliant**: No cookies, GDPR compliant by design
- **Tracking Events**:
- Chat session start (including QR code source)
- Message sent
- Option selected
- Session duration
- Specialist interaction completion
- QR code usage
### Tracking Implementation
```javascript
// Track chat events
function trackEvent(eventName, eventData) {
if (window.umami) {
umami.track(eventName, eventData);
}
}
// Track QR code usage
function trackQRUsage(qrSessionId, tenantCode) {
trackEvent('qr_code_used', {
qr_session_id: qrSessionId,
tenant_code: tenantCode
});
}
```
## File Upload Support (Future)
### Planned Implementation
- **Multipart Upload**: Standard HTML5 file upload
- **File Types**: Documents, images, spreadsheets
- **Storage**: Tenant-specific S3 buckets
- **Processing**: Integration with existing document processing pipeline
- **UI**: Drag-and-drop interface with progress indicators
### Security Considerations
- File type validation
- Size limits per tenant
- Virus scanning integration
- Temporary file cleanup
## Development Guidelines
### Code Organization
- **Services**: Place reusable business logic in `common/services/`
- **Utils**: Place utility functions in `common/utils/`
- **Multi-tenant**: Maintain data isolation using existing patterns
- **Error Handling**: Implement proper error handling and logging
### Service Layer Examples
```python
# common/services/qr_service.py
class QRService:
def create_qr_session(self, qr_data):
# Create QR session with hybrid storage approach
pass
def get_and_validate_qr_session(self, qr_session_id):
# Validate and retrieve QR session data
pass
# common/services/chat_service.py
class ChatService:
def create_session(self, tenant_code, specialist_id, initial_args=None, source='direct'):
# Create chat session with optional pre-filled arguments
pass
```
### Testing Strategy
- Unit tests for services and utilities in `common/`
- Integration tests for chat flow including QR code access
- UI tests for theme customization
- Load testing for SSE connections
- Cross-browser compatibility testing
### Performance Considerations
- Cache tenant configurations in Redis
- Cache QR session lookups in Redis
- Optimize SSE connection management
- Implement connection pooling for database
- Use CDN for static assets
- Monitor real-time connection limits
## Deployment
### Container Configuration
- New `eveai_chat_client` container
- Integration with existing docker setup
- Environment configuration for tenant isolation
- Load balancer configuration for SSE connections
### Dependencies
- Flask and Flask-restx (existing)
- Celery integration (existing)
- PostgreSQL and Redis (existing)
- Umami analytics client library
- QR code generation library (qrcode)
## Future Enhancements
### Authenticated Mode
- User login integration
- Session persistence across devices
- Advanced specialist access controls
- User-specific chat history
### Advanced Features
- Voice message support
- Screen sharing capabilities
- Collaborative chat sessions
- Advanced analytics dashboard
- Mobile app integration
## Configuration Examples
### Environment Variables
```bash
CHAT_CLIENT_PORT=5000
TENANT_API_VALIDATION_CACHE_TTL=3600
SSE_CONNECTION_TIMEOUT=300
QR_SESSION_DEFAULT_EXPIRY_DAYS=30
QR_SESSION_MAX_USAGE_LIMIT=1000
UMAMI_WEBSITE_ID=your-website-id
UMAMI_SCRIPT_URL=https://your-umami.domain/script.js
```
### Sample Theme Configuration
```json
{
"primary_color": "#2563eb",
"secondary_color": "#64748b",
"accent_color": "#059669",
"user_message_bg": "#2563eb",
"bot_message_bg": "#f1f5f9",
"chat_bg": "#ffffff",
"font_family": "Inter, system-ui, sans-serif",
"font_size": "16px",
"header_bg": "#1e40af"
}
```
### Sample QR Session Data
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"tenant_code": "123e4567-e89b-12d3-a456-426614174000",
"specialist_id": "789e0123-e45f-67g8-h901-234567890123",
"api_key": "tenant_api_key_here",
"specialist_args": {
"department": "technical_support",
"product_category": "software",
"priority": "high",
"language": "en"
},
"metadata": {
"name": "Technical Support QR - Software Issues",
"created_by": "admin_user_id",
"usage_limit": 100,
"expires_at": "2025-09-01T00:00:00Z"
}
}
```
This documentation provides a comprehensive foundation for developing the Evie Chat Client with secure QR code integration while maintaining consistency with the existing eveai multi-tenant architecture.

View File

@@ -12,7 +12,7 @@ import logging.config
from common.models.user import TenantDomain from common.models.user import TenantDomain
from common.utils.cors_utils import get_allowed_origins from common.utils.cors_utils import get_allowed_origins
from common.utils.database import Database from common.utils.database import Database
from config.logging_config import LOGGING from config.logging_config import configure_logging
from .api.document_api import document_ns from .api.document_api import document_ns
from .api.auth import auth_ns from .api.auth import auth_ns
from .api.specialist_execution_api import specialist_execution_ns from .api.specialist_execution_api import specialist_execution_ns
@@ -40,7 +40,7 @@ def create_app(config_file=None):
app.celery = make_celery(app.name, app.config) app.celery = make_celery(app.name, app.config)
init_celery(app.celery, app) init_celery(app.celery, app)
logging.config.dictConfig(LOGGING) configure_logging()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info("eveai_api starting up") logger.info("eveai_api starting up")

View File

@@ -7,15 +7,15 @@ from werkzeug.middleware.proxy_fix import ProxyFix
import logging.config import logging.config
from common.extensions import (db, migrate, bootstrap, security, login_manager, cors, csrf, session, from common.extensions import (db, migrate, bootstrap, security, login_manager, cors, csrf, session,
minio_client, simple_encryption, metrics, cache_manager) minio_client, simple_encryption, metrics, cache_manager, content_manager)
from common.models.user import User, Role, Tenant, TenantDomain from common.models.user import User, Role, Tenant, TenantDomain
import common.models.interaction import common.models.interaction
import common.models.entitlements import common.models.entitlements
import common.models.document import common.models.document
from common.utils.startup_eveai import perform_startup_actions from common.utils.startup_eveai import perform_startup_actions
from config.logging_config import LOGGING from config.logging_config import configure_logging
from common.utils.security import set_tenant_session_data from common.utils.security import set_tenant_session_data
from .errors import register_error_handlers from common.utils.errors import register_error_handlers
from common.utils.celery_utils import make_celery, init_celery from common.utils.celery_utils import make_celery, init_celery
from common.utils.template_filters import register_filters from common.utils.template_filters import register_filters
from config.config import get_config from config.config import get_config
@@ -47,8 +47,16 @@ def create_app(config_file=None):
except OSError: except OSError:
pass pass
logging.config.dictConfig(LOGGING) # Configureer logging op basis van de omgeving (K8s of traditioneel)
logger = logging.getLogger(__name__) try:
configure_logging()
logger = logging.getLogger(__name__)
# Test dat logging werkt
logger.debug("Logging test in eveai_app")
except Exception as e:
print(f"Critical Error Initialising Error: {str(e)}")
import traceback
traceback.print_exc()
logger.info("eveai_app starting up") logger.info("eveai_app starting up")
@@ -92,6 +100,45 @@ def create_app(config_file=None):
# app.logger.debug(f"Before request - Session data: {session}") # app.logger.debug(f"Before request - Session data: {session}")
# app.logger.debug(f"Before request - Request headers: {request.headers}") # app.logger.debug(f"Before request - Request headers: {request.headers}")
@app.before_request
def before_request():
from flask import session, request
from flask_login import current_user
import datetime
app.logger.debug(f"Before request - URL: {request.url}")
app.logger.debug(f"Before request - Session permanent: {session.permanent}")
# Log session expiry tijd als deze bestaat
if current_user.is_authenticated:
# Controleer of sessie permanent is (nodig voor PERMANENT_SESSION_LIFETIME)
if not session.permanent:
session.permanent = True
app.logger.debug("Session marked as permanent (enables 60min timeout)")
# Log wanneer sessie zou verlopen
if '_permanent' in session:
expires_at = datetime.datetime.now() + app.permanent_session_lifetime
app.logger.debug(f"Session will expire at: {expires_at} (60 min from now)")
@app.route('/debug/session')
def debug_session():
from flask import session
from flask_security import current_user
import datetime
if current_user.is_authenticated:
info = {
'session_permanent': session.permanent,
'session_lifetime_minutes': app.permanent_session_lifetime.total_seconds() / 60,
'session_refresh_enabled': app.config.get('SESSION_REFRESH_EACH_REQUEST'),
'current_time': datetime.datetime.now().isoformat(),
'session_data_keys': list(session.keys())
}
return jsonify(info)
else:
return jsonify({'error': 'Not authenticated'})
# Register template filters # Register template filters
register_filters(app) register_filters(app)
@@ -124,6 +171,7 @@ def register_extensions(app):
minio_client.init_app(app) minio_client.init_app(app)
cache_manager.init_app(app) cache_manager.init_app(app)
metrics.init_app(app) metrics.init_app(app)
content_manager.init_app(app)
def register_blueprints(app): def register_blueprints(app):

View File

@@ -54,6 +54,7 @@
<hr> <hr>
{% include 'footer.html' %} {% include 'footer.html' %}
{% include 'scripts.html' %} {% include 'scripts.html' %}
{% include 'ordered_list_configs.html' %}
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content_title %}{{ title }}{% endblock %}
{% block content_description %}{{ description }}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-secondary" id="showRaw">Show Raw</button>
<button class="btn btn-sm btn-outline-primary active" id="showRendered">Show Rendered</button>
</div>
</div>
<div class="card-body">
<!-- Raw markdown view (hidden by default) -->
<div id="rawMarkdown" class="code-wrapper" style="display: none;">
<pre><code class="language-markdown">{{ markdown_content }}</code></pre>
</div>
<!-- Rendered markdown view -->
<div id="renderedMarkdown" class="markdown-body">
{{ markdown_content | markdown }}
</div>
</div>
</div>
</div>
{% endblock %}
{% block styles %}
{{ super() }}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@4.0.0/github-markdown.min.css">
<style>
pre, code {
margin: 0;
padding: 0;
white-space: pre-wrap !important;
word-wrap: break-word !important;
max-width: 100%;
}
pre code {
padding: 1rem !important;
border-radius: 4px;
font-size: 0.75rem;
line-height: 1.5;
white-space: pre-wrap !important;
}
.code-wrapper {
position: relative;
width: 100%;
}
.markdown-body {
padding: 1rem;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.6;
}
/* Dark mode styling (optional) */
@media (prefers-color-scheme: dark) {
.markdown-body {
color: #c9d1d9;
background-color: #0d1117;
}
}
</style>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize syntax highlighting
document.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
// Toggle buttons for display
const showRawBtn = document.getElementById('showRaw');
const showRenderedBtn = document.getElementById('showRendered');
const rawMarkdown = document.getElementById('rawMarkdown');
const renderedMarkdown = document.getElementById('renderedMarkdown');
showRawBtn.addEventListener('click', function() {
rawMarkdown.style.display = 'block';
renderedMarkdown.style.display = 'none';
showRawBtn.classList.add('active');
showRenderedBtn.classList.remove('active');
});
showRenderedBtn.addEventListener('click', function() {
rawMarkdown.style.display = 'none';
renderedMarkdown.style.display = 'block';
showRawBtn.classList.remove('active');
showRenderedBtn.classList.add('active');
});
});
</script>
{% endblock %}

View File

@@ -5,16 +5,18 @@
{% block content_title %}Document Versions{% endblock %} {% block content_title %}Document Versions{% endblock %}
{% block content_description %}View Versions for {{ document }}{% 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 %} {% block content %}
<div class="container"> <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") }} {{ 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"> <div class="form-group mt-3 d-flex justify-content-between">
<button type="submit" name="action" value="edit_document_version" class="btn btn-primary">Edit Document Version</button> <div>
<button type="submit" name="action" value="view_document_version_markdown" class="btn btn-danger">View Processed Document</button> <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="process_document_version" class="btn btn-danger">Process 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> </div>
</form> </form>
</div> </div>

View File

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

View File

@@ -19,17 +19,17 @@
<div class="nav-wrapper position-relative end-0"> <div class="nav-wrapper position-relative end-0">
<ul class="nav nav-pills nav-fill p-1" role="tablist"> <ul class="nav nav-pills nav-fill p-1" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<a class="nav-link mb-0 px-0 py-1 active" data-toggle="tab" href="#storage-tab" role="tab" aria-controls="model-info" aria-selected="true"> <a class="nav-link mb-0 px-0 py-1 active" data-bs-toggle="tab" href="#storage-tab" role="tab" aria-controls="model-info" aria-selected="true">
Storage Storage
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link mb-0 px-0 py-1" data-toggle="tab" href="#embedding-tab" role="tab" aria-controls="license-info" aria-selected="false"> <a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#embedding-tab" role="tab" aria-controls="license-info" aria-selected="false">
Embedding Embedding
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link mb-0 px-0 py-1" data-toggle="tab" href="#interaction-tab" role="tab" aria-controls="chunking" aria-selected="false"> <a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#interaction-tab" role="tab" aria-controls="chunking" aria-selected="false">
Interaction Interaction
</a> </a>
</li> </li>

View File

@@ -19,17 +19,17 @@
<div class="nav-wrapper position-relative end-0"> <div class="nav-wrapper position-relative end-0">
<ul class="nav nav-pills nav-fill p-1" role="tablist"> <ul class="nav nav-pills nav-fill p-1" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<a class="nav-link mb-0 px-0 py-1 active" data-toggle="tab" href="#storage-tab" role="tab" aria-controls="model-info" aria-selected="true"> <a class="nav-link mb-0 px-0 py-1 active" data-bs-toggle="tab" href="#storage-tab" role="tab" aria-controls="model-info" aria-selected="true">
Storage Storage
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link mb-0 px-0 py-1" data-toggle="tab" href="#embedding-tab" role="tab" aria-controls="license-info" aria-selected="false"> <a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#embedding-tab" role="tab" aria-controls="license-info" aria-selected="false">
Embedding Embedding
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link mb-0 px-0 py-1" data-toggle="tab" href="#interaction-tab" role="tab" aria-controls="chunking" aria-selected="false"> <a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#interaction-tab" role="tab" aria-controls="chunking" aria-selected="false">
Interaction Interaction
</a> </a>
</li> </li>

View File

@@ -107,17 +107,17 @@
<!-- Nav Tabs --> <!-- Nav Tabs -->
<ul class="nav nav-tabs" id="periodTabs" role="tablist"> <ul class="nav nav-tabs" id="periodTabs" role="tablist">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" id="status-tab" data-toggle="tab" href="#status" role="tab"> <a class="nav-link active" id="status-tab" data-bs-toggle="tab" href="#status" role="tab">
Status & Timeline Status & Timeline
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" id="usage-tab" data-toggle="tab" href="#usage" role="tab"> <a class="nav-link" id="usage-tab" data-bs-toggle="tab" href="#usage" role="tab">
Usage Usage
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" id="financial-tab" data-toggle="tab" href="#financial" role="tab"> <a class="nav-link" id="financial-tab" data-bs-toggle="tab" href="#financial" role="tab">
Financial Financial
</a> </a>
</li> </li>

View File

@@ -1,3 +1,4 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% from "macros.html" import render_field, render_included_field %} {% from "macros.html" import render_field, render_included_field %}
@@ -19,17 +20,17 @@
<div class="nav-wrapper position-relative end-0"> <div class="nav-wrapper position-relative end-0">
<ul class="nav nav-pills nav-fill p-1" role="tablist"> <ul class="nav nav-pills nav-fill p-1" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<a class="nav-link mb-0 px-0 py-1 active" data-toggle="tab" href="#storage-tab" role="tab" aria-controls="model-info" aria-selected="true"> <a class="nav-link mb-0 px-0 py-1 active" data-bs-toggle="tab" href="#storage-tab" role="tab" aria-controls="storage-tab" aria-selected="true">
Storage Storage
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link mb-0 px-0 py-1" data-toggle="tab" href="#embedding-tab" role="tab" aria-controls="license-info" aria-selected="false"> <a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#embedding-tab" role="tab" aria-controls="embedding-tab" aria-selected="false">
Embedding Embedding
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link mb-0 px-0 py-1" data-toggle="tab" href="#interaction-tab" role="tab" aria-controls="chunking" aria-selected="false"> <a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#interaction-tab" role="tab" aria-controls="interaction-tab" aria-selected="false">
Interaction Interaction
</a> </a>
</li> </li>
@@ -68,4 +69,4 @@
{% block content_footer %} {% block content_footer %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,364 @@
<script type="module">
window.EveAI = window.EveAI || {};
window.EveAI.OrderedListEditors = {
instances: {},
initialize: function(containerId, data, listType, options = {}) {
console.log('Initializing OrderedListEditor for', containerId, 'with data', data, 'and listType', listType);
const container = document.getElementById(containerId);
if (!container || typeof container !== 'object' || !('classList' in container)) {
console.error(`Container with ID ${containerId} not found or not a valid element:`, container);
return null;
}
if (this.instances[containerId]) return this.instances[containerId];
if (typeof window.Tabulator !== 'function') {
console.error('Tabulator not loaded (window.Tabulator missing).');
container.innerHTML = `<div class="alert alert-danger p-3">
<strong>Error:</strong> Tabulator not loaded
</div>`;
return null;
}
// Get the list type configuration
const listTypeConfig = this._getListTypeConfig(listType);
if (!listTypeConfig) {
console.error(`List type configuration for ${listType} not found.`);
container.innerHTML = `<div class="alert alert-danger p-3">
<strong>Error:</strong> List type configuration for ${listType} not found
</div>`;
return null;
}
// Create column definitions from list type
const columns = this._createColumnsFromListType(listTypeConfig);
// Debug log for data and columns
console.log('Data for Tabulator:', data);
console.log('Columns for Tabulator:', columns);
// Debug log for column titles
console.log('Column titles:', columns.map(col => col.title || ''));
// Initialize Tabulator
try {
console.log('Creating Tabulator for', containerId);
const table = new Tabulator(container, {
data: data || [],
columns: columns,
layout: "fitColumns", // Changed to fitColumns to ensure columns display horizontally
movableRows: true,
movableRowsPlaceholder: false, // Don't use placeholder, show actual row content
movableRowsSender: "table", // Keep a copy of the row in the table while dragging
rowHeader: {headerSort:false, resizable: false, minWidth:30, width:30, rowHandle:true, formatter:"handle"},
maxHeight: "50%", // Auto height to display all rows
placeholder: "No Data Available",
autoResize: false,
resizableColumnFit: true,
responsiveLayout: false,
tooltips: true, // Enable tooltips
tooltipsHeader: true,
selectable: false, // Disable row selection to prevent jumping
selectableRangeMode: "click", // Only select on click, not on drag
selectableRollingSelection: false, // Disable rolling selection
scrollToRowIfVisible: false, // Don't scroll to row even if it's already visible
scrollToRowPosition: "nearest",
...options
});
console.log('Tabulator created for', containerId);
container.classList.add('tabulator-initialized');
// Debug: Log table structure
console.log('Table structure:', {
tableElement: container,
tableData: table.getData(),
tableColumns: table.getColumnDefinitions()
});
// Add row button
const addRowBtn = document.createElement('button');
addRowBtn.className = 'btn btn-sm btn-primary mt-2';
addRowBtn.innerHTML = 'Add Row';
addRowBtn.addEventListener('click', () => {
const newRow = {};
// Create empty row with default values
Object.entries(listTypeConfig).forEach(([key, field]) => {
if (field.type === 'boolean') {
newRow[key] = field.default === true;
} else {
newRow[key] = field.default !== undefined ? field.default : '';
}
});
table.addRow(newRow);
this._updateTextarea(containerId, table);
});
container.parentNode.insertBefore(addRowBtn, container.nextSibling);
// Add explode button for fullscreen mode
const explodeBtn = document.createElement('button');
explodeBtn.className = 'btn btn-sm btn-secondary mt-2 ms-2';
explodeBtn.innerHTML = '<i class="material-icons">fullscreen</i> Expand';
explodeBtn.addEventListener('click', () => {
container.classList.toggle('fullscreen-mode');
// Update button text based on current state
if (container.classList.contains('fullscreen-mode')) {
explodeBtn.innerHTML = '<i class="material-icons">fullscreen_exit</i> Collapse';
} else {
explodeBtn.innerHTML = '<i class="material-icons">fullscreen</i> Expand';
}
// Redraw table to adjust to new size
table.redraw(true);
});
container.parentNode.insertBefore(explodeBtn, addRowBtn.nextSibling);
// Store instance
this.instances[containerId] = {
table: table,
textarea: document.getElementById(containerId.replace('-editor', ''))
};
// Prevent scrolling when clicking on cells
container.addEventListener('click', function(e) {
// Prevent the default behavior which might cause scrolling
if (e.target.closest('.tabulator-cell')) {
e.preventDefault();
}
}, { passive: false });
// Update textarea on various events that change data
table.on("dataChanged", () => {
console.log("dataChanged event triggered");
this._updateTextarea(containerId, table);
});
// Listen for row movement
table.on("rowMoved", () => {
console.log("rowMoved event triggered");
this._updateTextarea(containerId, table);
});
// Listen for cell edits
table.on("cellEdited", () => {
console.log("cellEdited event triggered");
this._updateTextarea(containerId, table);
});
return table;
} catch (e) {
console.error(`Error initializing Tabulator for ${containerId}:`, e);
container.innerHTML = `<div class="alert alert-danger p-3">
<strong>Error initializing Tabulator:</strong><br>${e.message}
</div>`;
return null;
}
},
_updateTextarea: function(containerId, table) {
const instance = this.instances[containerId];
if (instance && instance.textarea) {
const data = table.getData();
console.log('Updating textarea with data:', data);
instance.textarea.value = JSON.stringify(data);
console.log('Textarea value updated:', instance.textarea.value);
// Trigger change event on textarea to ensure form validation recognizes the change
const event = new Event('change', { bubbles: true });
instance.textarea.dispatchEvent(event);
// Also trigger input event for any listeners that might be using that
const inputEvent = new Event('input', { bubbles: true });
instance.textarea.dispatchEvent(inputEvent);
} else {
console.error('Cannot update textarea: instance or textarea not found for', containerId);
}
},
_getListTypeConfig: function(listType) {
// Try to get the list type configuration from window.listTypeConfigs
if (window.listTypeConfigs && window.listTypeConfigs[listType]) {
return window.listTypeConfigs[listType];
}
// If not found, log a warning and return a default configuration
console.warn(`List type configuration for ${listType} not found in window.listTypeConfigs. Using a default configuration.`);
return {
title: {
name: "Title",
description: "Title",
type: "str",
required: true
},
description: {
name: "Description",
description: "Description",
type: "text",
required: true
}
};
},
// Custom formatter for text columns to truncate text in normal mode
_truncateFormatter: function(cell, formatterParams, onRendered) {
const value = cell.getValue();
const maxLength = formatterParams.maxLength || 100;
if (value && value.length > maxLength) {
// Create a truncated version with "..." and show more indicator
const truncated = value.substring(0, maxLength) + "...";
// Return HTML with truncated text and a "show more" button
return `<div class="truncated-cell">
<div class="truncated-content">${truncated}</div>
<div class="show-more" title="Click to edit and see full text">
<i class="material-icons">more_horiz</i>
</div>
</div>`;
}
return value;
},
_createColumnsFromListType: function(listTypeConfig) {
const columns = [];
// Add columns for each field in the list type
Object.entries(listTypeConfig).forEach(([key, field]) => {
const column = {
title: field.name || key,
field: key,
headerTooltip: field.description,
headerSort: false,
visible: true,
resizable: "header",
};
console.log("Column ", field.name, " type: ", field.type)
// Set width based on field type
if (field.type === 'boolean') {
column.minWidth = 50;
column.maxWidth = 80; // Limit maximum width
column.widthGrow = 0; // Don't allow boolean columns to grow
} else if (field.type === 'text') {
column.width = 400; // Much larger width for text columns (especially description)
column.minWidth = 300; // Ensure text columns have adequate minimum width
column.widthGrow = 2; // Allow text columns to grow significantly more
} else {
column.width = 150; // Default width for other columns
column.minWidth = 100;
column.widthGrow = 1; // Allow some growth
}
// Ensure consistent width calculation
column.widthShrink = 0; // Don't allow shrinking below minWidth
// Set editor based on field type
if (field.type === 'boolean') {
column.formatter = 'tickCross';
column.editor = 'tickCross';
column.hozAlign = 'center';
column.headerHozAlign = 'center';
column.formatterParams = {
allowEmpty: true,
allowTruthy: true,
tickElement: "<i class='material-icons'>check_circle</i>",
crossElement: "<i class='material-icons'>cancel</i>"
};
} else if (field.type === 'enum' && field.allowed_values) {
column.editor = 'select';
column.editorParams = {
values: field.allowed_values
};
column.hozAlign = 'left';
column.headerHozAlign = 'left';
} else if (field.type === 'text') {
column.editor = 'textarea';
column.formatter = this._truncateFormatter; // Use custom formatter to truncate text
column.variableHeight = true;
// Configure formatter parameters
column.formatterParams = {
maxLength: 50,
autoResize: true
};
// Prevent scrolling when editing text cells
column.editorParams = {
elementAttributes: {
preventScroll: true
}
};
column.hozAlign = 'left';
column.headerHozAlign = 'left';
} else {
column.editor = 'input';
column.hozAlign = 'left';
column.headerHozAlign = 'left';
}
columns.push(column);
});
// We don't add a delete button column as per requirements
// to prevent users from deleting rows
return columns;
},
get: function(containerId) {
return this.instances[containerId] || null;
},
destroy: function(containerId) {
if (this.instances[containerId]) {
if (this.instances[containerId].table && typeof this.instances[containerId].table.destroy === 'function') {
this.instances[containerId].table.destroy();
}
delete this.instances[containerId];
}
const container = document.getElementById(containerId);
if (container) {
container.classList.remove('tabulator-initialized');
container.innerHTML = '';
}
}
};
document.addEventListener('DOMContentLoaded', function() {
// Initialize list type configurations
window.listTypeConfigs = window.listTypeConfigs || {};
// Initialize ordered list editors
document.querySelectorAll('.ordered-list-field').forEach(function(textarea) {
const containerId = textarea.id + '-editor';
console.log('Initializing ordered list editor for', containerId);
// Create container if it doesn't exist
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
container.className = 'ordered-list-editor';
textarea.parentNode.insertBefore(container, textarea.nextSibling);
textarea.classList.add('d-none'); // Hide the textarea
}
try {
const data = textarea.value ? JSON.parse(textarea.value) : [];
const listType = textarea.getAttribute('data-list-type');
// Check if we have the list type configuration
if (listType && !window.listTypeConfigs[listType]) {
console.warn(`List type configuration for ${listType} not found. Using default configuration.`);
}
window.EveAI.OrderedListEditors.initialize(containerId, data, listType);
} catch (e) {
console.error('Error initializing ordered list editor:', e);
container.innerHTML = `<div class="alert alert-danger p-3">
<strong>Error initializing ordered list editor:</strong><br>${e.message}
</div>`;
}
});
});
</script>

View File

@@ -15,29 +15,17 @@
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{% set disabled_fields = ['type', 'type_version'] %} {% set disabled_fields = ['type', 'type_version'] %}
{% set exclude_fields = [] %} {% set exclude_fields = [] %}
<!-- Render Static Fields -->
{% for field in form.get_static_fields() %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %}
<!-- Overview Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="specialist-overview" id="specialist-svg">
<img src="{{ svg_path }}" alt="Specialist Overview" class="w-100">
</div>
</div>
</div>
</div>
</div>
<!-- Nav Tabs --> <!-- Nav Tabs -->
<div class="row mt-5"> <div class="row mt-5">
<div class="col-lg-12"> <div class="col-lg-12">
<div class="nav-wrapper position-relative end-0"> <div class="nav-wrapper position-relative end-0">
<ul class="nav nav-pills nav-fill p-1" role="tablist"> <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="#general-tab" role="tab">
General
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link mb-0 px-0 py-1 active" data-bs-toggle="tab" href="#configuration-tab" role="tab"> <a class="nav-link mb-0 px-0 py-1 active" data-bs-toggle="tab" href="#configuration-tab" role="tab">
Configuration Configuration
@@ -67,6 +55,27 @@
</div> </div>
<div class="tab-content tab-space"> <div class="tab-content tab-space">
<!-- General Tab -->
<div class="tab-pane fade" id="general-tab" role="tabpanel">
<!-- Render Static Fields -->
{% for field in form.get_static_fields() %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %}
<!-- Overview Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="specialist-overview" id="specialist-svg">
<img src="{{ svg_path }}" alt="Specialist Overview" class="w-100">
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Configuration Tab --> <!-- Configuration Tab -->
<div class="tab-pane fade show active" id="configuration-tab" role="tabpanel"> <div class="tab-pane fade show active" id="configuration-tab" role="tabpanel">
{% for collection_name, fields in form.get_dynamic_fields().items() %} {% for collection_name, fields in form.get_dynamic_fields().items() %}
@@ -420,6 +429,14 @@ document.addEventListener('DOMContentLoaded', function() {
color: #344767 !important; /* Default dark color */ color: #344767 !important; /* Default dark color */
} }
/* Style for active tabs */
.nav-link.active {
background-color: #5e72e4 !important; /* Primary blue color */
color: white !important;
font-weight: 600;
box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11), 0 1px 3px rgba(0, 0, 0, 0.08);
}
/* Style for disabled tabs */ /* Style for disabled tabs */
.nav-link.disabled { .nav-link.disabled {
opacity: 0.5; opacity: 0.5;
@@ -476,4 +493,3 @@ document.addEventListener('DOMContentLoaded', function() {
} }
</style> </style>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends 'base.html' %}
{% from "macros.html" import render_field %}
{% block title %}Edit Specialist Magic Link{% endblock %}
{% block content_title %}Edit Specialist Magic Link{% endblock %}
{% block content_description %}Edit a Specialist Magic Link{% endblock %}
{% block content %}
<form method="post">
{{ form.hidden_tag() }}
{% set disabled_fields = ['magic_link_code', 'chat_client_url', 'qr_code_url'] %}
{% set exclude_fields = [] %}
<!-- Render Static Fields -->
{% for field in form.get_static_fields() %}
{% if field.name == 'qr_code_url' and field.data %}
<div class="form-group">
<label for="{{ field.id }}">{{ field.label.text }}</label>
<div style="max-width: 200px;">
<img src="{{ field.data }}" alt="QR Code" class="img-fluid">
</div>
<input type="hidden" name="{{ field.name }}" value="{{ field.data|e }}">
</div>
{% elif field.name == 'chat_client_url' %}
<div class="form-group">
<label for="{{ field.id }}" class="form-label">{{ field.label.text }}</label>
<div class="input-group">
<input type="text" class="form-control" value="{{ field.data }}" id="{{ field.id }}" readonly>
<a href="{{ field.data }}" class="btn btn-primary" target="_blank">Open link</a>
</div>
<input type="hidden" name="{{ field.name }}" value="{{ field.data|e }}">
</div>
{% else %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endif %}
{% 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 Specialist Magic Link</button>
</form>
{% endblock %}
{% block content_footer %}
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% from "macros.html" import render_field %}
{% block title %}Specialist Magic Link{% endblock %}
{% block content_title %}Register Specialist Magic Link{% endblock %}
{% block content_description %}Define a new specialist magic link{% endblock %}
{% block content %}
<form method="post">
{{ form.hidden_tag() }}
{% set disabled_fields = [] %}
{% set exclude_fields = [] %}
{% for field in form %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %}
<button type="submit" class="btn btn-primary">Register Specialist Magic Link</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 %}Specialist Magic Links{% endblock %}
{% block content_title %}Specialist Magic Links{% endblock %}
{% block content_description %}View Specialists Magic Links{% 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('interaction_bp.handle_specialist_magic_link_selection') }}" id="specialistMagicLinksForm">
{{ render_selectable_table(headers=["Specialist ML ID", "Name", "Magic Link Code"], rows=rows, selectable=True, id="specialistMagicLinksTable") }}
<div class="form-group mt-3 d-flex justify-content-between">
<div>
<button type="submit" name="action" value="edit_specialist_magic_link" class="btn btn-primary" onclick="return validateTableSelection('specialistMagicLinksForm')">Edit Specialist Magic Link</button>
</div>
<button type="submit" name="action" value="create_specialist_magic_link" class="btn btn-success">Register Specialist Magic Link</button>
</div>
</form>
</div>
{% endblock %}
{% block content_footer %}
{{ render_pagination(pagination, 'interaction_bp.specialist_magic_links') }}
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% from 'macros.html' import render_selectable_table, render_pagination %} {% from 'macros.html' import render_selectable_table, render_pagination %}
{% block title %}Retrievers{% endblock %} {% block title %}Specialists{% endblock %}
{% block content_title %}Specialists{% endblock %} {% block content_title %}Specialists{% endblock %}
{% block content_description %}View Specialists for Tenant{% endblock %} {% block content_description %}View Specialists for Tenant{% endblock %}
@@ -10,7 +10,7 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
<form method="POST" action="{{ url_for('interaction_bp.handle_specialist_selection') }}" id="specialistsForm"> <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 class="form-group mt-3 d-flex justify-content-between">
<div> <div>
<button type="submit" name="action" value="edit_specialist" class="btn btn-primary" onclick="return validateTableSelection('specialistsForm')">Edit Specialist</button> <button type="submit" name="action" value="edit_specialist" class="btn btn-primary" onclick="return validateTableSelection('specialistsForm')">Edit Specialist</button>

View File

@@ -1,5 +1,26 @@
{# Helper functie om veilig de class van een veld te krijgen #}
{% macro get_field_class(field, default='') %}
{% if field.render_kw is not none and field.render_kw.get('class') is not none %}
{{ field.render_kw.get('class') }}
{% else %}
{{ default }}
{% endif %}
{% endmacro %}
{% macro render_field_content(field, disabled=False, readonly=False, class='') %} {% macro render_field_content(field, disabled=False, readonly=False, class='') %}
{% if field.type == 'BooleanField' %} {# Check if this is a hidden input field, if so, render only the field without label #}
{{ debug_to_console("Field Class: ", field.widget.__class__.__name__) }}
{% if field.widget.__class__.__name__ == 'HiddenInput' %}
{{ debug_to_console("Hidden Field: ", "Detected") }}
{{ field(class="form-control " + class, disabled=disabled, readonly=readonly) }}
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
{% elif field.type == 'BooleanField' %}
<div class="form-group"> <div class="form-group">
<div class="form-check form-switch"> <div class="form-check form-switch">
{{ field(class="form-check-input " + class, disabled=disabled, readonly=readonly, required=False) }} {{ field(class="form-check-input " + class, disabled=disabled, readonly=readonly, required=False) }}
@@ -55,9 +76,14 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% set field_class = get_field_class(field) %}
{% if field.type == 'TextAreaField' and 'json-editor' in class %} {% if field.type == 'TextAreaField' and 'json-editor' in class %}
<div id="{{ field.id }}-editor" class="json-editor-container"></div> <div id="{{ field.id }}-editor" class="json-editor-container"></div>
{{ field(class="form-control d-none " + class, disabled=disabled, readonly=readonly) }} {{ field(class="form-control d-none " + class, disabled=disabled, readonly=readonly) }}
{% elif field.type == 'OrderedListField' or 'ordered-list-field' in field_class %}
{# Create container for ordered list editor and hide the textarea #}
<div id="{{ field.id }}-editor" class="ordered-list-editor"></div>
{{ field(class="form-control d-none " + field_class|trim, disabled=disabled, readonly=readonly) }}
{% elif field.type == 'SelectField' %} {% elif field.type == 'SelectField' %}
{{ field(class="form-control form-select " + class, disabled=disabled, readonly=readonly) }} {{ field(class="form-control form-select " + class, disabled=disabled, readonly=readonly) }}
{% else %} {% else %}
@@ -76,6 +102,7 @@
{% endmacro %} {% endmacro %}
{% macro render_field(field, disabled_fields=[], readonly_fields=[], exclude_fields=[], class='') %} {% macro render_field(field, disabled_fields=[], readonly_fields=[], exclude_fields=[], class='') %}
<!-- Debug info --> <!-- Debug info -->
<!-- Field name: {{ field.name }}, Field type: {{ field.__class__.__name__ }} --> <!-- Field name: {{ field.name }}, Field type: {{ field.__class__.__name__ }} -->
@@ -123,7 +150,7 @@
{% elif cell.type == 'badge' %} {% elif cell.type == 'badge' %}
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span> <span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
{% elif cell.type == 'link' %} {% elif cell.type == 'link' %}
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a> <a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-bs-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
{% else %} {% else %}
{{ cell.value }} {{ cell.value }}
{% endif %} {% endif %}
@@ -177,7 +204,7 @@
{% elif cell.type == 'badge' %} {% elif cell.type == 'badge' %}
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span> <span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
{% elif cell.type == 'link' %} {% elif cell.type == 'link' %}
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a> <a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-bs-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
{% else %} {% else %}
{{ cell.value }} {{ cell.value }}
{% endif %} {% endif %}
@@ -342,7 +369,7 @@
{% elif cell.type == 'badge' %} {% elif cell.type == 'badge' %}
<span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span> <span class="badge badge-sm {{ cell.badge_class }}">{{ cell.value }}</span>
{% elif cell.type == 'link' %} {% elif cell.type == 'link' %}
<a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a> <a href="{{ cell.href }}" class="text-secondary font-weight-normal text-xs" data-bs-toggle="tooltip" data-original-title="{{ cell.title }}">{{ cell.value }}</a>
{% else %} {% else %}
{{ cell.value }} {{ cell.value }}
{% endif %} {% endif %}
@@ -436,3 +463,9 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% macro debug_to_console(var_name, var_value) %}
<script>
console.log('{{ var_name }}:', {{ var_value|tojson }});
</script>
{% endmacro %}

View File

@@ -73,6 +73,7 @@
{'name': 'Tenant Overview', 'url': '/user/tenant_overview', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']}, {'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': '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 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': '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']}, {'name': 'Users', 'url': '/user/view_users', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
]) }} ]) }}
@@ -106,6 +107,7 @@
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
{{ dropdown('Interactions', 'hub', [ {{ dropdown('Interactions', 'hub', [
{'name': 'Specialists', 'url': '/interaction/specialists', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']}, {'name': 'Specialists', 'url': '/interaction/specialists', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
{'name': 'Specialist Magic Links', 'url': '/interaction/specialist_magic_links', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
{'name': 'Chat Sessions', 'url': '/interaction/chat_sessions', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']}, {'name': 'Chat Sessions', 'url': '/interaction/chat_sessions', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
]) }} ]) }}
{% endif %} {% endif %}
@@ -120,6 +122,7 @@
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
{{ dropdown(current_user.user_name, 'person', [ {{ dropdown(current_user.user_name, 'person', [
{'name': 'Session Defaults', 'url': '/session_defaults', 'roles': ['Super User', 'Tenant Admin']}, {'name': 'Session Defaults', 'url': '/session_defaults', 'roles': ['Super User', 'Tenant Admin']},
{'name': 'Release Notes', 'url': '/release_notes', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
{'name': 'Logout', 'url': '/logout'} {'name': 'Logout', 'url': '/logout'}
]) }} ]) }}
{% else %} {% else %}
@@ -133,4 +136,4 @@
</nav> </nav>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,7 @@
{# Include this template in any page that uses ordered_list fields #}
{# Usage: {% include 'ordered_list_configs.html' %} #}
{# The form must be available in the template context as 'form' #}
{% if form and form.get_list_type_configs_js %}
{{ form.get_list_type_configs_js()|safe }}
{% endif %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "macros.html" import render_field %} {% from "macros.html" import render_field, debug_to_console %}
{% block title %}Register Partner Service{% endblock %} {% block title %}Edit Partner Service{% endblock %}
{% block content_title %}Register Partner Service{% endblock %} {% block content_title %}Edit Partner Service{% endblock %}
{% block content_description %}Register Partner Service{% endblock %} {% block content_description %}Edit Partner Service{% endblock %}
{% block content %} {% block content %}
<form method="post"> <form method="post">
@@ -16,6 +16,8 @@
{% endfor %} {% endfor %}
<!-- Render Dynamic Fields --> <!-- Render Dynamic Fields -->
{% for collection_name, fields in form.get_dynamic_fields().items() %} {% for collection_name, fields in form.get_dynamic_fields().items() %}
{{ debug_to_console('collection_name', collection_name) }}
{{ debug_to_console('fields', fields) }}
{% if fields|length > 0 %} {% if fields|length > 0 %}
<h4 class="mt-4">{{ collection_name }}</h4> <h4 class="mt-4">{{ collection_name }}</h4>
{% endif %} {% endif %}
@@ -23,6 +25,6 @@
{{ render_field(field, disabled_fields, exclude_fields) }} {{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
<button type="submit" class="btn btn-primary">Register Partner Service</button> <button type="submit" class="btn btn-primary">Save Partner Service</button>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -9,14 +9,19 @@
<script src="{{url_for('static', filename='assets/js/material-kit-pro.min.js')}}"></script> <script src="{{url_for('static', filename='assets/js/material-kit-pro.min.js')}}"></script>
{% include 'eveai_json_editor.html' %} {% include 'eveai_json_editor.html' %}
{% include 'eveai_ordered_list_editor.html' %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Initialize tooltips // Initialize tooltips if bootstrap is available
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) if (typeof bootstrap !== 'undefined') {
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
return new bootstrap.Tooltip(tooltipTriggerEl) var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
}); return new bootstrap.Tooltip(tooltipTriggerEl)
});
} else {
console.warn('Bootstrap is not defined. Tooltips will not be initialized.');
}
// De JSON editor initialisatie is hierboven al samengevoegd. // De JSON editor initialisatie is hierboven al samengevoegd.
// Deze dubbele DOMContentLoaded listener en .json-editor initialisatie kan verwijderd worden. // Deze dubbele DOMContentLoaded listener en .json-editor initialisatie kan verwijderd worden.
@@ -46,9 +51,12 @@ document.addEventListener('DOMContentLoaded', function() {
// Find and click the corresponding tab button // Find and click the corresponding tab button
const tabButton = document.querySelector(`[data-bs-toggle="tab"][data-bs-target="#${tabId}"]`); const tabButton = document.querySelector(`[data-bs-toggle="tab"][data-bs-target="#${tabId}"]`);
if (tabButton) { if (tabButton && typeof bootstrap !== 'undefined') {
const tab = new bootstrap.Tab(tabButton); const tab = new bootstrap.Tab(tabButton);
tab.show(); tab.show();
} else if (tabButton) {
// Fallback if bootstrap is not available
tabButton.click();
} }
// Scroll the invalid field into view and focus it // Scroll the invalid field into view and focus it

View File

@@ -19,3 +19,37 @@
{% endblock %} {% endblock %}
{% block content_footer %} {% endblock %} {% block content_footer %} {% endblock %}
{% block scripts %}
<script>
// JavaScript om de gebruiker's timezone te detecteren
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');
}
});
// Initialiseer Select2 voor timezone selectie
$('#timezone').select2({
placeholder: 'Selecteer een timezone...',
allowClear: true,
maximumSelectionLength: 10,
theme: 'bootstrap',
width: '100%'
});
});
</script>
{% endblock %}

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

@@ -26,7 +26,7 @@
{% block scripts %} {% block scripts %}
<script> <script>
// JavaScript to detect user's timezone // JavaScript om de gebruiker's timezone te detecteren
document.addEventListener('DOMContentLoaded', (event) => { document.addEventListener('DOMContentLoaded', (event) => {
// Detect timezone // Detect timezone
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -45,6 +45,31 @@
console.error('Failed to send timezone to server'); console.error('Failed to send timezone to server');
} }
}); });
$('#timezone').select2({
placeholder: 'Selecteer een timezone...',
allowClear: true,
theme: 'bootstrap',
width: '100%',
dropdownAutoWidth: true,
dropdownCssClass: 'timezone-dropdown', // Een custom class voor specifieke styling
scrollAfterSelect: false,
// Verbeterd scroll gedrag
dropdownParent: $('body')
});
// Stel de huidige waarde in als de dropdown wordt geopend
$('#timezone').on('select2:open', function() {
if ($(this).val()) {
setTimeout(function() {
let selectedOption = $('.select2-results__option[aria-selected=true]');
if (selectedOption.length) {
selectedOption[0].scrollIntoView({ behavior: 'auto', block: 'center' });
}
}, 0);
}
});
}); });
</script> </script>
{% endblock %} {% 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' %} {% 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 %} {% block title %}Tenant Overview{% endblock %}
@@ -9,162 +9,23 @@
{% block content %} {% block content %}
<form method="post"> <form method="post">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<!-- Main Tenant Information --> {% set disabled_fields = [] %}
{% set main_fields = ['name', 'code', 'website', 'default_language', 'allowed_languages', 'type'] %}
{% for field in form %} {% 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 %} {% 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-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> </form>
{% endblock %} {% endblock %}
{% block content_footer %} {% block content_footer %}
{% endblock %} {% 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

@@ -1,4 +1,4 @@
from flask import session from flask import session, current_app
from flask_security import current_user from flask_security import current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, SelectField from wtforms import StringField, SelectField
@@ -36,7 +36,7 @@ class SessionDefaultsForm(FlaskForm):
else: else:
self.partner_name.data = "" self.partner_name.data = ""
self.default_language.choices = [(lang, lang.lower()) for lang in self.default_language.choices = [(lang, lang.lower()) for lang in
session.get('tenant').get('allowed_languages')] current_app.config['SUPPORTED_LANGUAGES']]
self.default_language.data = session.get('default_language') self.default_language.data = session.get('default_language')
# Get a new session for catalog queries # Get a new session for catalog queries

View File

@@ -1,15 +1,25 @@
from flask import request, render_template, Blueprint, session, current_app, jsonify, flash, redirect from flask import request, render_template, Blueprint, session, current_app, jsonify, flash, redirect, url_for
from flask_security import roles_required, roles_accepted from flask_security import roles_required, roles_accepted
from flask_wtf.csrf import generate_csrf from flask_wtf.csrf import generate_csrf
import os
import requests
from common.models.document import Catalog from common.models.document import Catalog
from common.models.user import Tenant from common.models.user import Tenant
from common.utils.database import Database from common.utils.database import Database
from common.utils.nginx_utils import prefixed_url_for from common.utils.nginx_utils import prefixed_url_for
from .basic_forms import SessionDefaultsForm from .basic_forms import SessionDefaultsForm
from common.extensions import content_manager
import markdown
basic_bp = Blueprint('basic_bp', __name__) basic_bp = Blueprint('basic_bp', __name__)
# Markdown filter toevoegen aan Jinja2
@basic_bp.app_template_filter('markdown')
def render_markdown(text):
return markdown.markdown(text, extensions=['tables', 'fenced_code'])
@basic_bp.before_request @basic_bp.before_request
def log_before_request(): def log_before_request():
@@ -101,3 +111,58 @@ def check_csrf():
'session_data': dict(session) 'session_data': dict(session)
}) })
@basic_bp.route('/content/<content_type>', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def view_content(content_type):
"""
Show content like release notes, terms of use, etc.
Args:
content_type (str): Type content (eg. 'changelog', 'terms', 'privacy')
"""
try:
current_app.logger.debug(f"Showing content {content_type}")
major_minor = request.args.get('version')
patch = request.args.get('patch')
# Gebruik de ContentManager om de content op te halen
content_data = content_manager.read_content(content_type, major_minor, patch)
if not content_data:
flash(f'Content van type {content_type} werd niet gevonden.', 'danger')
return redirect(prefixed_url_for('basic_bp.index'))
# Titels en beschrijvingen per contenttype
titles = {
'changelog': 'Release Notes',
'terms': 'Terms & Conditions',
'privacy': 'Privacy Statement',
# Voeg andere types toe indien nodig
}
descriptions = {
'changelog': 'EveAI Release Notes',
'terms': "Terms & Conditions for using AskEveAI's Evie",
'privacy': "Privacy Statement for AskEveAI's Evie",
# Voeg andere types toe indien nodig
}
return render_template(
'basic/view_markdown.html',
title=titles.get(content_type, content_type.capitalize()),
description=descriptions.get(content_type, ''),
markdown_content=content_data['content'],
version=content_data['version']
)
except Exception as e:
current_app.logger.error(f"Error displaying content {content_type}: {str(e)}")
flash(f'Error displaying content: {str(e)}', 'danger')
return redirect(prefixed_url_for('basic_bp.index'))
@basic_bp.route('/release_notes', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def release_notes():
"""Doorverwijzen naar de nieuwe content view voor changelog"""
current_app.logger.debug(f"Redirecting to content viewer")
return redirect(prefixed_url_for('basic_bp.view_content', content_type='changelog'))

View File

@@ -6,6 +6,7 @@ from wtforms.validators import DataRequired, Length, Optional, URL, ValidationEr
from flask_wtf.file import FileField, FileRequired from flask_wtf.file import FileField, FileRequired
import json import json
from wtforms.widgets.core import HiddenInput
from wtforms_sqlalchemy.fields import QuerySelectField from wtforms_sqlalchemy.fields import QuerySelectField
from common.extensions import cache_manager from common.extensions import cache_manager
@@ -17,8 +18,15 @@ from config.type_defs.processor_types import PROCESSOR_TYPES
from .dynamic_form_base import DynamicFormBase 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): class CatalogForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=50)]) name = StringField('Name', validators=[DataRequired(), Length(max=50), validate_catalog_name])
description = TextAreaField('Description', validators=[Optional()]) description = TextAreaField('Description', validators=[Optional()])
# Select Field for Catalog Type (Uses the CATALOG_TYPES defined in config) # Select Field for Catalog Type (Uses the CATALOG_TYPES defined in config)
@@ -41,7 +49,8 @@ class CatalogForm(FlaskForm):
class EditCatalogForm(DynamicFormBase): 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()]) description = TextAreaField('Description', validators=[Optional()])
# Select Field for Catalog Type (Uses the CATALOG_TYPES defined in config) # Select Field for Catalog Type (Uses the CATALOG_TYPES defined in config)
@@ -181,7 +190,7 @@ class AddDocumentForm(DynamicFormBase):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.language.choices = [(language, language) for language in self.language.choices = [(language, language) for language in
session.get('tenant').get('allowed_languages')] current_app.config['SUPPORTED_LANGUAGES']]
if not self.language.data: if not self.language.data:
self.language.data = session.get('tenant').get('default_language') self.language.data = session.get('tenant').get('default_language')
@@ -201,7 +210,7 @@ class AddURLForm(DynamicFormBase):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.language.choices = [(language, language) for language in self.language.choices = [(language, language) for language in
session.get('tenant').get('allowed_languages')] current_app.config['SUPPORTED_LANGUAGES']]
if not self.language.data: if not self.language.data:
self.language.data = session.get('tenant').get('default_language') self.language.data = session.get('tenant').get('default_language')

View File

@@ -20,7 +20,6 @@ from common.utils.document_utils import create_document_stack, start_embedding_t
from common.utils.dynamic_field_utils import create_default_config_from_type_config from common.utils.dynamic_field_utils import create_default_config_from_type_config
from common.utils.eveai_exceptions import EveAIInvalidLanguageException, EveAIUnsupportedFileType, \ from common.utils.eveai_exceptions import EveAIInvalidLanguageException, EveAIUnsupportedFileType, \
EveAIDoubleURLException, EveAIException EveAIDoubleURLException, EveAIException
from config.type_defs.processor_types import PROCESSOR_TYPES
from .document_forms import AddDocumentForm, AddURLForm, EditDocumentForm, EditDocumentVersionForm, \ from .document_forms import AddDocumentForm, AddURLForm, EditDocumentForm, EditDocumentVersionForm, \
CatalogForm, EditCatalogForm, RetrieverForm, EditRetrieverForm, ProcessorForm, EditProcessorForm CatalogForm, EditCatalogForm, RetrieverForm, EditRetrieverForm, ProcessorForm, EditProcessorForm
from common.utils.middleware import mw_before_request from common.utils.middleware import mw_before_request
@@ -29,7 +28,6 @@ from common.utils.nginx_utils import prefixed_url_for
from common.utils.view_assistants import form_validation_failed, prepare_table_for_macro from common.utils.view_assistants import form_validation_failed, prepare_table_for_macro
from .document_list_view import DocumentListView from .document_list_view import DocumentListView
from .document_version_list_view import DocumentVersionListView from .document_version_list_view import DocumentVersionListView
from config.type_defs.catalog_types import CATALOG_TYPES
document_bp = Blueprint('document_bp', __name__, url_prefix='/document') document_bp = Blueprint('document_bp', __name__, url_prefix='/document')
@@ -126,8 +124,8 @@ def edit_catalog(catalog_id):
tenant_id = session.get('tenant').get('id') tenant_id = session.get('tenant').get('id')
form = EditCatalogForm(request.form, obj=catalog) form = EditCatalogForm(request.form, obj=catalog)
configuration_config = CATALOG_TYPES[catalog.type]["configuration"] full_config = cache_manager.catalogs_config_cache.get_config(catalog.type)
form.add_dynamic_fields("configuration", configuration_config, catalog.configuration) form.add_dynamic_fields("configuration", full_config, catalog.configuration)
if request.method == 'POST' and form.validate_on_submit(): if request.method == 'POST' and form.validate_on_submit():
form.populate_obj(catalog) form.populate_obj(catalog)
@@ -160,8 +158,9 @@ def processor():
new_processor = Processor() new_processor = Processor()
form.populate_obj(new_processor) form.populate_obj(new_processor)
new_processor.catalog_id = form.catalog.data.id new_processor.catalog_id = form.catalog.data.id
processor_config = cache_manager.processors_config_cache.get_config(new_processor.type)
new_processor.configuration = create_default_config_from_type_config( new_processor.configuration = create_default_config_from_type_config(
PROCESSOR_TYPES[new_processor.type]["configuration"]) processor_config["configuration"])
set_logging_information(new_processor, dt.now(tz.utc)) set_logging_information(new_processor, dt.now(tz.utc))
@@ -197,8 +196,8 @@ def edit_processor(processor_id):
# Create form instance with the processor # Create form instance with the processor
form = EditProcessorForm(request.form, obj=processor) form = EditProcessorForm(request.form, obj=processor)
configuration_config = PROCESSOR_TYPES[processor.type]["configuration"] full_config = cache_manager.processors_config_cache.get_config(processor.type)
form.add_dynamic_fields("configuration", configuration_config, processor.configuration) form.add_dynamic_fields("configuration", full_config, processor.configuration)
if form.validate_on_submit(): if form.validate_on_submit():
# Update basic fields # Update basic fields
@@ -390,9 +389,7 @@ def add_document():
catalog = Catalog.query.get_or_404(catalog_id) catalog = Catalog.query.get_or_404(catalog_id)
if catalog.configuration and len(catalog.configuration) > 0: if catalog.configuration and len(catalog.configuration) > 0:
document_version_configurations = CATALOG_TYPES[catalog.type]['document_version_configurations'] form.add_dynamic_fields("tagging_fields", catalog.configuration)
for config in document_version_configurations:
form.add_dynamic_fields(config, catalog.configuration[config])
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
@@ -402,10 +399,8 @@ def add_document():
sub_file_type = form.sub_file_type.data sub_file_type = form.sub_file_type.data
filename = secure_filename(file.filename) filename = secure_filename(file.filename)
extension = filename.rsplit('.', 1)[1].lower() extension = filename.rsplit('.', 1)[1].lower()
catalog_properties = {}
document_version_configurations = CATALOG_TYPES[catalog.type]['document_version_configurations'] catalog_properties = form.get_dynamic_data("tagging_fields")
for config in document_version_configurations:
catalog_properties[config] = form.get_dynamic_data(config)
api_input = { api_input = {
'catalog_id': catalog_id, 'catalog_id': catalog_id,
@@ -445,9 +440,7 @@ def add_url():
catalog = Catalog.query.get_or_404(catalog_id) catalog = Catalog.query.get_or_404(catalog_id)
if catalog.configuration and len(catalog.configuration) > 0: if catalog.configuration and len(catalog.configuration) > 0:
document_version_configurations = CATALOG_TYPES[catalog.type]['document_version_configurations'] form.add_dynamic_fields("tagging_fields", catalog.configuration)
for config in document_version_configurations:
form.add_dynamic_fields(config, catalog.configuration[config])
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
@@ -459,7 +452,8 @@ def add_url():
file_content, filename, extension = process_url(url, tenant_id) file_content, filename, extension = process_url(url, tenant_id)
catalog_properties = {} catalog_properties = {}
document_version_configurations = CATALOG_TYPES[catalog.type]['document_version_configurations'] full_config = cache_manager.catalogs_config_cache.get_config(catalog.type)
document_version_configurations = full_config['document_version_configurations']
for config in document_version_configurations: for config in document_version_configurations:
catalog_properties[config] = form.get_dynamic_data(config) catalog_properties[config] = form.get_dynamic_data(config)
@@ -582,13 +576,14 @@ def edit_document_version_view(document_version_id):
catalog = Catalog.query.get_or_404(catalog_id) catalog = Catalog.query.get_or_404(catalog_id)
if catalog.configuration and len(catalog.configuration) > 0: if catalog.configuration and len(catalog.configuration) > 0:
document_version_configurations = CATALOG_TYPES[catalog.type]['document_version_configurations'] full_config = cache_manager.catalogs_config_cache.get_config(catalog.type)
document_version_configurations = full_config['document_version_configurations']
for config in document_version_configurations: for config in document_version_configurations:
form.add_dynamic_fields(config, catalog.configuration[config], doc_vers.catalog_properties[config]) form.add_dynamic_fields(config, full_config, doc_vers.catalog_properties[config])
if form.validate_on_submit(): if form.validate_on_submit():
catalog_properties = {} catalog_properties = {}
document_version_configurations = CATALOG_TYPES[catalog.type]['document_version_configurations'] # Use the full_config variable we already defined
for config in document_version_configurations: for config in document_version_configurations:
catalog_properties[config] = form.get_dynamic_data(config) catalog_properties[config] = form.get_dynamic_data(config)
@@ -897,4 +892,3 @@ def clean_markdown(markdown):
if markdown.endswith("```"): if markdown.endswith("```"):
markdown = markdown[:-3].strip() markdown = markdown[:-3].strip()
return markdown return markdown

View File

@@ -1,11 +1,16 @@
from datetime import date
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import (IntegerField, FloatField, BooleanField, StringField, TextAreaField, FileField, from wtforms import (IntegerField, FloatField, BooleanField, StringField, TextAreaField, FileField,
validators, ValidationError) validators, ValidationError)
from flask import current_app, request from flask import current_app, request, session
import json import json
from wtforms.fields.choices import SelectField from wtforms.fields.choices import SelectField
from wtforms.fields.datetime import DateField 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 from common.utils.config_field_types import TaggingFields, json_to_patterns, patterns_to_json
@@ -49,6 +54,51 @@ class ChunkingPatternsField(TextAreaField):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
class OrderedListField(TextAreaField):
"""Field for ordered list data that will be rendered as a Tabulator table"""
def __init__(self, *args, **kwargs):
list_type = kwargs.pop('list_type', '')
# Behoud bestaande render_kw attributen als die er zijn
if 'render_kw' in kwargs:
existing_render_kw = kwargs['render_kw']
else:
existing_render_kw = {}
current_app.logger.debug(f"incomming render_kw for ordered list field: {existing_render_kw}")
# Stel nieuwe render_kw samen
new_render_kw = {
'data-list-type': list_type,
'data-handle-enter': 'true'
}
# Voeg klasse toe en behoud bestaande klassen
if 'class' in existing_render_kw:
existing_classes = existing_render_kw['class']
if isinstance(existing_classes, list):
existing_classes += ' ordered-list-field'
new_render_kw['class'] = existing_classes
else:
# String classes samenvoegen
new_render_kw['class'] = f"{existing_classes} ordered-list-field"
else:
new_render_kw['class'] = 'ordered-list-field'
# Voeg alle bestaande attributen toe aan nieuwe render_kw
for key, value in existing_render_kw.items():
if key != 'class': # Klassen hebben we al verwerkt
new_render_kw[key] = value
current_app.logger.debug(f"final render_kw for ordered list field: {new_render_kw}")
# Update kwargs met de nieuwe gecombineerde render_kw
kwargs['render_kw'] = new_render_kw
super().__init__(*args, **kwargs)
class DynamicFormBase(FlaskForm): class DynamicFormBase(FlaskForm):
def __init__(self, formdata=None, *args, **kwargs): def __init__(self, formdata=None, *args, **kwargs):
super(DynamicFormBase, self).__init__(*args, **kwargs) super(DynamicFormBase, self).__init__(*args, **kwargs)
@@ -89,6 +139,8 @@ class DynamicFormBase(FlaskForm):
validators_list.append(self._validate_tagging_fields_filter) validators_list.append(self._validate_tagging_fields_filter)
elif field_type == 'dynamic_arguments': elif field_type == 'dynamic_arguments':
validators_list.append(self._validate_dynamic_arguments) validators_list.append(self._validate_dynamic_arguments)
elif field_type == 'ordered_list':
validators_list.append(self._validate_ordered_list)
return validators_list return validators_list
@@ -227,10 +279,68 @@ class DynamicFormBase(FlaskForm):
except Exception as e: except Exception as e:
raise ValidationError(f"Invalid argument definition: {str(e)}") raise ValidationError(f"Invalid argument definition: {str(e)}")
def _validate_ordered_list(self, form, field):
"""Validate the ordered list structure"""
if not field.data:
return
try:
# Parse JSON data
list_data = json.loads(field.data)
# Validate it's a list
if not isinstance(list_data, list):
raise ValidationError("Ordered list must be a list")
# Validate each item in the list is a dictionary
for i, item in enumerate(list_data):
if not isinstance(item, dict):
raise ValidationError(f"Item {i} in ordered list must be an object")
except json.JSONDecodeError:
raise ValidationError("Invalid JSON format")
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): def add_dynamic_fields(self, collection_name, config, initial_data=None):
"""Add dynamic fields to the form based on the configuration.""" """Add dynamic fields to the form based on the configuration.
Args:
collection_name: The name of the collection of fields to add
config: The full configuration object, which should contain the field definitions
for the collection_name and may also contain list_type definitions
initial_data: Optional initial data for the fields
"""
current_app.logger.debug(f"Adding dynamic fields for collection {collection_name} with config: {config}")
# Store the full configuration for later use in get_list_type_configs_js
if not hasattr(self, '_full_configs'):
self._full_configs = {}
self._full_configs[collection_name] = config
# Get the specific field configuration for this collection
field_config = config.get(collection_name, {})
if not field_config:
# Handle the case where config is already the specific field configuration
return
self.dynamic_fields[collection_name] = [] self.dynamic_fields[collection_name] = []
for field_name, field_def in config.items(): for field_name, field_def in field_config.items():
# Prefix the field name with the collection name # Prefix the field name with the collection name
full_field_name = f"{collection_name}_{field_name}" full_field_name = f"{collection_name}_{field_name}"
label = field_def.get('name', field_name) label = field_def.get('name', field_name)
@@ -264,6 +374,13 @@ class DynamicFormBase(FlaskForm):
field_class = ChunkingPatternsField field_class = ChunkingPatternsField
extra_classes = ['monospace-text', 'pattern-input'] extra_classes = ['monospace-text', 'pattern-input']
field_kwargs = {} field_kwargs = {}
elif field_type == 'ordered_list':
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: else:
extra_classes = '' extra_classes = ''
field_class = { field_class = {
@@ -275,6 +392,7 @@ class DynamicFormBase(FlaskForm):
'text': TextAreaField, 'text': TextAreaField,
'date': DateField, 'date': DateField,
'file': FileField, 'file': FileField,
'color': ColorField,
}.get(field_type, StringField) }.get(field_type, StringField)
field_kwargs = {} field_kwargs = {}
@@ -289,12 +407,24 @@ class DynamicFormBase(FlaskForm):
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
current_app.logger.error(f"Error converting initial data to JSON: {e}") current_app.logger.error(f"Error converting initial data to JSON: {e}")
field_data = "{}" field_data = "{}"
elif field_type == 'ordered_list' and isinstance(field_data, list):
try:
field_data = json.dumps(field_data)
except (TypeError, ValueError) as e:
current_app.logger.error(f"Error converting ordered list data to JSON: {e}")
field_data = "[]"
elif field_type == 'chunking_patterns': elif field_type == 'chunking_patterns':
try: try:
field_data = json_to_patterns(field_data) field_data = json_to_patterns(field_data)
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
current_app.logger.error(f"Error converting initial data to a list of patterns: {e}") current_app.logger.error(f"Error converting initial data to a list of patterns: {e}")
field_data = {} field_data = {}
elif field_type == 'date' and isinstance(field_data, str):
try:
field_data = date.fromisoformat(field_data)
except ValueError:
current_app.logger.error(f"Error converting ISO date string '{field_data}' to date object")
field_data = None
elif default is not None: elif default is not None:
field_data = default field_data = default
@@ -305,6 +435,17 @@ class DynamicFormBase(FlaskForm):
render_kw['data-bs-toggle'] = 'tooltip' render_kw['data-bs-toggle'] = 'tooltip'
render_kw['data-bs-placement'] = 'right' 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}")
# Create the field # Create the field
field_kwargs.update({ field_kwargs.update({
'label': label, 'label': label,
@@ -340,6 +481,73 @@ class DynamicFormBase(FlaskForm):
# Return all fields that are not dynamic # Return all fields that are not dynamic
return [field for name, field in self._fields.items() if name not in dynamic_field_names] return [field for name, field in self._fields.items() if name not in dynamic_field_names]
def get_list_type_configs_js(self):
"""Generate JavaScript code for list type configurations used by ordered_list fields."""
from common.extensions import cache_manager
list_types = {}
# First check if we have any full configurations stored
if hasattr(self, '_full_configs'):
# Look for list types in the stored full configurations
for config_name, config in self._full_configs.items():
for key, value in config.items():
# Check if this is a list type definition (not a field definition)
if isinstance(value, dict) and all(isinstance(v, dict) for v in value.values()):
# This looks like a list type definition
list_types[key] = value
# Collect all list types used in ordered_list fields
for collection_name, field_names in self.dynamic_fields.items():
for full_field_name in field_names:
field = getattr(self, full_field_name)
if isinstance(field, OrderedListField):
list_type = field.render_kw.get('data-list-type')
if list_type and list_type not in list_types:
# First try to get from current_app.config
list_type_config = current_app.config.get('LIST_TYPES', {}).get(list_type)
if list_type_config:
list_types[list_type] = list_type_config
else:
# Try to find the list type in specialist configurations using the cache
try:
# Get all specialist types
specialist_types = cache_manager.specialists_types_cache.get_types()
# For each specialist type, check if it has the list type we're looking for
for specialist_type in specialist_types:
try:
# Get the latest version for this specialist type
latest_version = cache_manager.specialists_version_tree_cache.get_latest_version(specialist_type)
# Get the configuration for this specialist type and version
specialist_config = cache_manager.specialists_config_cache.get_config(specialist_type, latest_version)
# Check if this specialist has the list type we're looking for
if list_type in specialist_config:
list_types[list_type] = specialist_config[list_type]
break
except Exception as e:
current_app.logger.debug(f"Error checking specialist {specialist_type}: {e}")
continue
except Exception as e:
current_app.logger.error(f"Error retrieving specialist configurations: {e}")
# If no list types found, return empty script
if not list_types:
return ""
# Generate JavaScript code
js_code = "<script>\n"
js_code += "window.listTypeConfigs = window.listTypeConfigs || {};\n"
for list_type, config in list_types.items():
js_code += f"window.listTypeConfigs['{list_type}'] = {json.dumps(config, indent=2)};\n"
js_code += "</script>\n"
return js_code
def get_dynamic_fields(self): def get_dynamic_fields(self):
"""Return a dictionary of dynamic fields per collection.""" """Return a dictionary of dynamic fields per collection."""
result = {} result = {}
@@ -361,7 +569,7 @@ class DynamicFormBase(FlaskForm):
if field.type == 'BooleanField': if field.type == 'BooleanField':
data[original_field_name] = full_field_name in self.raw_formdata data[original_field_name] = full_field_name in self.raw_formdata
current_app.logger.debug(f"Value for {original_field_name} is {data[original_field_name]}") current_app.logger.debug(f"Value for {original_field_name} is {data[original_field_name]}")
elif isinstance(field, (TaggingFieldsField, TaggingFieldsFilterField, DynamicArgumentsField)) and field.data: elif isinstance(field, (TaggingFieldsField, TaggingFieldsFilterField, DynamicArgumentsField, OrderedListField)) and field.data:
try: try:
data[original_field_name] = json.loads(field.data) data[original_field_name] = json.loads(field.data)
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -372,6 +580,8 @@ class DynamicFormBase(FlaskForm):
data[original_field_name] = patterns_to_json(field.data) data[original_field_name] = patterns_to_json(field.data)
except Exception as e: except Exception as e:
current_app.logger.error(f"Error converting initial data to patterns: {e}") current_app.logger.error(f"Error converting initial data to patterns: {e}")
elif isinstance(field, DateField):
data[original_field_name] = field.data.isoformat()
else: else:
data[original_field_name] = field.data data[original_field_name] = field.data
return data return data
@@ -422,7 +632,7 @@ def validate_tagging_fields(form, field):
raise ValidationError(f"Field {field_name} missing required 'type' property") raise ValidationError(f"Field {field_name} missing required 'type' property")
# Validate type # 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']}") raise ValidationError(f"Field {field_name} has invalid type: {field_def['type']}")
# Validate enum fields have allowed_values # Validate enum fields have allowed_values
@@ -443,4 +653,4 @@ def validate_tagging_fields(form, field):
except json.JSONDecodeError: except json.JSONDecodeError:
raise ValidationError("Invalid JSON format") raise ValidationError("Invalid JSON format")
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
raise ValidationError(f"Invalid field definition: {str(e)}") raise ValidationError(f"Invalid field definition: {str(e)}")

View File

@@ -259,6 +259,10 @@ def view_usages():
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int) per_page = request.args.get('per_page', 10, type=int)
if not session.get('tenant', None):
flash('You can only view usage for a Tenant. Select a Tenant to continue!', 'danger')
return redirect(prefixed_url_for('user_bp.select_tenant'))
tenant_id = session.get('tenant').get('id') tenant_id = session.get('tenant').get('id')
query = LicenseUsage.query.filter_by(tenant_id=tenant_id).order_by(desc(LicenseUsage.id)) query = LicenseUsage.query.filter_by(tenant_id=tenant_id).order_by(desc(LicenseUsage.id))

View File

@@ -7,8 +7,10 @@ from wtforms.validators import DataRequired, Length, Optional
from wtforms_sqlalchemy.fields import QuerySelectMultipleField from wtforms_sqlalchemy.fields import QuerySelectMultipleField
from common.models.document import Retriever from common.models.document import Retriever
from common.models.interaction import EveAITool from common.models.interaction import EveAITool, Specialist
from common.models.user import TenantMake
from common.extensions import cache_manager from common.extensions import cache_manager
from common.utils.form_assistants import validate_json
from .dynamic_form_base import DynamicFormBase from .dynamic_form_base import DynamicFormBase
@@ -23,6 +25,7 @@ def get_tools():
class SpecialistForm(FlaskForm): class SpecialistForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=50)]) name = StringField('Name', validators=[DataRequired(), Length(max=50)])
description = TextAreaField('Description', validators=[Optional()])
retrievers = QuerySelectMultipleField( retrievers = QuerySelectMultipleField(
'Retrievers', 'Retrievers',
@@ -33,7 +36,7 @@ class SpecialistForm(FlaskForm):
) )
type = SelectField('Specialist Type', validators=[DataRequired()]) type = SelectField('Specialist Type', validators=[DataRequired()])
active = BooleanField('Active', validators=[Optional()], default=True)
tuning = BooleanField('Enable Specialist Tuning', default=False) tuning = BooleanField('Enable Specialist Tuning', default=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -46,6 +49,7 @@ class SpecialistForm(FlaskForm):
class EditSpecialistForm(DynamicFormBase): class EditSpecialistForm(DynamicFormBase):
name = StringField('Name', validators=[DataRequired()]) name = StringField('Name', validators=[DataRequired()])
description = TextAreaField('Description', validators=[Optional()]) description = TextAreaField('Description', validators=[Optional()])
active = BooleanField('Active', validators=[Optional()], default=True)
retrievers = QuerySelectMultipleField( retrievers = QuerySelectMultipleField(
'Retrievers', 'Retrievers',
@@ -132,4 +136,54 @@ class ExecuteSpecialistForm(DynamicFormBase):
description = TextAreaField('Specialist Description', validators=[Optional()], render_kw={'readonly': True}) description = TextAreaField('Specialist Description', validators=[Optional()], render_kw={'readonly': True})
class SpecialistMagicLinkForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
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()])
valid_from = DateField('Valid From', id='form-control datepicker', validators=[Optional()])
valid_to = DateField('Valid To', id='form-control datepicker', validators=[Optional()])
# Metadata fields
user_metadata = TextAreaField('User Metadata', validators=[Optional(), validate_json])
system_metadata = TextAreaField('System Metadata', validators=[Optional(), validate_json])
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
specialists = Specialist.query.all()
# Dynamically populate the specialist field
self.specialist_id.choices = [(specialist.id, specialist.name) for specialist in specialists]
class EditSpecialistMagicLinkForm(DynamicFormBase):
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
description = TextAreaField('Description', validators=[Optional()])
magic_link_code = StringField('Magic Link Code', validators=[DataRequired(), Length(max=55)],
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})
chat_client_url = StringField('Chat Client URL', validators=[Optional()], render_kw={'readonly': True})
qr_code_url = StringField('QR Code', validators=[Optional()], render_kw={'readonly': True})
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()])
# Metadata fields
user_metadata = TextAreaField('User Metadata', validators=[Optional(), validate_json])
system_metadata = TextAreaField('System Metadata', validators=[Optional(), validate_json])
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
specialist = Specialist.query.get(kwargs['specialist_id'])
if specialist:
self.specialist_name.data = specialist.name
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]

View File

@@ -1,5 +1,6 @@
import ast import ast
import json import json
import uuid
from datetime import datetime as dt, timezone as tz from datetime import datetime as dt, timezone as tz
import time import time
@@ -13,9 +14,10 @@ from werkzeug.utils import secure_filename
from common.models.document import Embedding, DocumentVersion, Retriever from common.models.document import Embedding, DocumentVersion, Retriever
from common.models.interaction import (ChatSession, Interaction, InteractionEmbedding, Specialist, SpecialistRetriever, from common.models.interaction import (ChatSession, Interaction, InteractionEmbedding, Specialist, SpecialistRetriever,
EveAIAgent, EveAITask, EveAITool, EveAIAssetVersion) EveAIAgent, EveAITask, EveAITool, EveAIAssetVersion, SpecialistMagicLink)
from common.extensions import db, cache_manager from common.extensions import db, cache_manager
from common.models.user import SpecialistMagicLinkTenant
from common.services.interaction.specialist_services import SpecialistServices from common.services.interaction.specialist_services import SpecialistServices
from common.utils.asset_utils import create_asset_stack, add_asset_version_file from common.utils.asset_utils import create_asset_stack, add_asset_version_file
from common.utils.execution_progress import ExecutionProgressTracker from common.utils.execution_progress import ExecutionProgressTracker
@@ -24,12 +26,10 @@ from common.utils.model_logging_utils import set_logging_information, update_log
from common.utils.middleware import mw_before_request from common.utils.middleware import mw_before_request
from common.utils.nginx_utils import prefixed_url_for from common.utils.nginx_utils import prefixed_url_for
from common.utils.view_assistants import form_validation_failed, prepare_table_for_macro from common.utils.view_assistants import form_validation_failed, prepare_table_for_macro
from common.utils.specialist_utils import initialize_specialist
from config.type_defs.specialist_types import SPECIALIST_TYPES
from .interaction_forms import (SpecialistForm, EditSpecialistForm, EditEveAIAgentForm, EditEveAITaskForm, from .interaction_forms import (SpecialistForm, EditSpecialistForm, EditEveAIAgentForm, EditEveAITaskForm,
EditEveAIToolForm, AddEveAIAssetForm, EditEveAIAssetVersionForm, ExecuteSpecialistForm) EditEveAIToolForm, AddEveAIAssetForm, EditEveAIAssetVersionForm, ExecuteSpecialistForm,
SpecialistMagicLinkForm, EditSpecialistMagicLinkForm)
interaction_bp = Blueprint('interaction_bp', __name__, url_prefix='/interaction') interaction_bp = Blueprint('interaction_bp', __name__, url_prefix='/interaction')
@@ -162,6 +162,7 @@ def specialist():
new_specialist.type = form.type.data new_specialist.type = form.type.data
new_specialist.type_version = cache_manager.specialists_version_tree_cache.get_latest_version( new_specialist.type_version = cache_manager.specialists_version_tree_cache.get_latest_version(
new_specialist.type) new_specialist.type)
new_specialist.active = form.active.data
new_specialist.tuning = form.tuning.data new_specialist.tuning = form.tuning.data
set_logging_information(new_specialist, dt.now(tz.utc)) set_logging_information(new_specialist, dt.now(tz.utc))
@@ -184,7 +185,7 @@ def specialist():
current_app.logger.info(f'Specialist {new_specialist.name} successfully added for tenant {tenant_id}!') current_app.logger.info(f'Specialist {new_specialist.name} successfully added for tenant {tenant_id}!')
# Initialize the newly create specialist # Initialize the newly create specialist
initialize_specialist(new_specialist.id, new_specialist.type, new_specialist.type_version) SpecialistServices.initialize_specialist(new_specialist.id, new_specialist.type, new_specialist.type_version)
return redirect(prefixed_url_for('interaction_bp.edit_specialist', specialist_id=new_specialist.id)) return redirect(prefixed_url_for('interaction_bp.edit_specialist', specialist_id=new_specialist.id))
@@ -204,8 +205,7 @@ def edit_specialist(specialist_id):
form = EditSpecialistForm(request.form, obj=specialist) form = EditSpecialistForm(request.form, obj=specialist)
specialist_config = cache_manager.specialists_config_cache.get_config(specialist.type, specialist.type_version) specialist_config = cache_manager.specialists_config_cache.get_config(specialist.type, specialist.type_version)
configuration_config = specialist_config.get('configuration') form.add_dynamic_fields("configuration", specialist_config, specialist.configuration)
form.add_dynamic_fields("configuration", configuration_config, specialist.configuration)
agent_rows = prepare_table_for_macro(specialist.agents, agent_rows = prepare_table_for_macro(specialist.agents,
[('id', ''), ('name', ''), ('type', ''), ('type_version', '')]) [('id', ''), ('name', ''), ('type', ''), ('type_version', '')])
@@ -232,6 +232,7 @@ def edit_specialist(specialist_id):
specialist.name = form.name.data specialist.name = form.name.data
specialist.description = form.description.data specialist.description = form.description.data
specialist.tuning = form.tuning.data specialist.tuning = form.tuning.data
specialist.active = form.active.data
# Update the configuration dynamic fields # Update the configuration dynamic fields
specialist.configuration = form.get_dynamic_data("configuration") specialist.configuration = form.get_dynamic_data("configuration")
@@ -298,7 +299,7 @@ def specialists():
# prepare table data # prepare table data
rows = prepare_table_for_macro(the_specialists, rows = prepare_table_for_macro(the_specialists,
[('id', ''), ('name', ''), ('type', '')]) [('id', ''), ('name', ''), ('type', ''), ('type_version', ''), ('active', ''),])
# Render the catalogs in a template # Render the catalogs in a template
return render_template('interaction/specialists.html', rows=rows, pagination=pagination) return render_template('interaction/specialists.html', rows=rows, pagination=pagination)
@@ -521,8 +522,7 @@ def edit_asset_version(asset_version_id):
form = EditEveAIAssetVersionForm(asset_version) form = EditEveAIAssetVersionForm(asset_version)
asset_config = cache_manager.assets_config_cache.get_config(asset_version.asset.type, asset_config = cache_manager.assets_config_cache.get_config(asset_version.asset.type,
asset_version.asset.type_version) asset_version.asset.type_version)
configuration_config = asset_config.get('configuration') form.add_dynamic_fields("configuration", asset_config, asset_version.configuration)
form.add_dynamic_fields("configuration", configuration_config, asset_version.configuration)
if form.validate_on_submit(): if form.validate_on_submit():
# Update the configuration dynamic fields # Update the configuration dynamic fields
@@ -582,9 +582,8 @@ def execute_specialist(specialist_id):
return redirect(prefixed_url_for('interaction_bp.specialists')) return redirect(prefixed_url_for('interaction_bp.specialists'))
form = ExecuteSpecialistForm(request.form, obj=specialist) form = ExecuteSpecialistForm(request.form, obj=specialist)
arguments_config = specialist_config.get('arguments', None) if 'arguments' in specialist_config:
if arguments_config: form.add_dynamic_fields('arguments', specialist_config)
form.add_dynamic_fields('arguments', arguments_config)
if form.validate_on_submit(): if form.validate_on_submit():
# We're only interested in gathering the dynamic arguments # We're only interested in gathering the dynamic arguments
@@ -674,4 +673,189 @@ def session_interactions(chat_session_id):
This route shows all interactions for a given chat_session_id (int). This route shows all interactions for a given chat_session_id (int).
""" """
chat_session = ChatSession.query.get_or_404(chat_session_id) chat_session = ChatSession.query.get_or_404(chat_session_id)
return session_interactions_by_session_id(chat_session.session_id) return session_interactions_by_session_id(chat_session.session_id)
# Routes for SpecialistMagicLink Management -------------------------------------------------------
@interaction_bp.route('/specialist_magic_link', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def specialist_magic_link():
form = SpecialistMagicLinkForm()
if request.method == 'GET':
magic_link_code = f"SPECIALIST_ML-{str(uuid.uuid4())}"
form.magic_link_code.data = magic_link_code
if form.validate_on_submit():
tenant_id = session.get('tenant').get('id')
try:
new_specialist_magic_link = SpecialistMagicLink()
# Populate fields individually instead of using populate_obj
form.populate_obj(new_specialist_magic_link)
set_logging_information(new_specialist_magic_link, dt.now(tz.utc))
# Create 'public' SpecialistMagicLinkTenant
new_spec_ml_tenant = SpecialistMagicLinkTenant()
new_spec_ml_tenant.magic_link_code = new_specialist_magic_link.magic_link_code
new_spec_ml_tenant.tenant_id = tenant_id
# Define the make valid for this magic link
make_id = SpecialistServices.get_specialist_system_field(new_specialist_magic_link.specialist_id,
"make", "tenant_make")
if make_id:
new_spec_ml_tenant.tenant_make_id = make_id
elif session.get('tenant').get('default_tenant_make_id'):
new_spec_ml_tenant.tenant_make_id = session.get('tenant').get('default_tenant_make_id')
db.session.add(new_specialist_magic_link)
db.session.add(new_spec_ml_tenant)
db.session.commit()
flash('Specialist Magic Link successfully added!', 'success')
current_app.logger.info(f'Specialist {new_specialist_magic_link.name} successfully added for '
f'tenant {tenant_id}!')
return redirect(prefixed_url_for('interaction_bp.edit_specialist_magic_link',
specialist_magic_link_id=new_specialist_magic_link.id))
except Exception as e:
db.session.rollback()
current_app.logger.error(f'Failed to add specialist magic link. Error: {str(e)}', exc_info=True)
flash(f'Failed to add specialist magic link. Error: {str(e)}', 'danger')
return render_template('interaction/specialist_magic_link.html', form=form)
@interaction_bp.route('/specialist_magic_link/<int:specialist_magic_link_id>', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def edit_specialist_magic_link(specialist_magic_link_id):
specialist_ml = SpecialistMagicLink.query.get_or_404(specialist_magic_link_id)
# We need to pass along the extra kwarg specialist_id, as this id is required to initialize the form
form = EditSpecialistMagicLinkForm(request.form, obj=specialist_ml, specialist_id=specialist_ml.specialist_id)
# Find the Specialist type and type_version to enable to retrieve the arguments
specialist = Specialist.query.get_or_404(specialist_ml.specialist_id)
specialist_config = cache_manager.specialists_config_cache.get_config(specialist.type, specialist.type_version)
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
# Set the chat client URL
tenant_id = session.get('tenant').get('id')
chat_client_prefix = current_app.config.get('CHAT_CLIENT_PREFIX', 'chat_client/chat/')
base_url = request.url_root
magic_link_code = specialist_ml.magic_link_code
# Parse the URL om poortinformatie te behouden als deze afwijkt van de standaard
url_parts = request.url.split('/')
host_port = url_parts[2] # Dit bevat zowel hostname als poort indien aanwezig
# Generate the full URL for chat client with magic link code
chat_client_url = f"{request.scheme}://{host_port}/{chat_client_prefix}{magic_link_code}"
form.chat_client_url.data = chat_client_url
# Generate QR code as data URI for direct embedding in HTML
try:
import qrcode
import io
import base64
# Generate QR code as PNG for better compatibility
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4
)
qr.add_data(chat_client_url)
qr.make(fit=True)
# Generate PNG image in memory
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format='PNG')
img_data = buffer.getvalue()
# Create data URI for direct embedding in HTML
img_base64 = base64.b64encode(img_data).decode('utf-8')
data_uri = f"data:image/png;base64,{img_base64}"
# Store the data URI in the form data
form.qr_code_url.data = data_uri
current_app.logger.debug(f"QR code generated successfully for {magic_link_code}")
current_app.logger.debug(f"QR code data URI starts with: {data_uri[:50]}...")
except Exception as e:
current_app.logger.error(f"Failed to generate QR code: {str(e)}")
form.qr_code_url.data = "Error generating QR code"
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))
try:
db.session.commit()
flash('Specialist Magic Link updated successfully!', 'success')
current_app.logger.info(f'Specialist Magic Link {specialist_ml.id} updated successfully')
return redirect(prefixed_url_for('interaction_bp.specialist_magic_links'))
except SQLAlchemyError as e:
db.session.rollback()
flash(f'Failed to update specialist Magic Link. Error: {str(e)}', 'danger')
current_app.logger.error(f'Failed to update specialist Magic Link {specialist_ml.id}. Error: {str(e)}')
else:
form_validation_failed(request, form)
return render_template('interaction/edit_specialist_magic_link.html', form=form)
@interaction_bp.route('/specialist_magic_links', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def specialist_magic_links():
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
query = SpecialistMagicLink.query.order_by(SpecialistMagicLink.id)
pagination = query.paginate(page=page, per_page=per_page)
the_specialist_magic_links = pagination.items
# prepare table data
rows = prepare_table_for_macro(the_specialist_magic_links, [('id', ''), ('name', ''), ('magic_link_code', ''),])
# Render the catalogs in a template
return render_template('interaction/specialist_magic_links.html', rows=rows, pagination=pagination)
@interaction_bp.route('/handle_specialist_magic_link_selection', methods=['POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def handle_specialist_magic_link_selection():
action = request.form.get('action')
if action == 'create_specialist_magic_link':
return redirect(prefixed_url_for('interaction_bp.specialist_magic_link'))
specialist_ml_identification = request.form.get('selected_row')
specialist_ml_id = ast.literal_eval(specialist_ml_identification).get('value')
if action == "edit_specialist_magic_link":
return redirect(prefixed_url_for('interaction_bp.edit_specialist_magic_link',
specialist_magic_link_id=specialist_ml_id))
return redirect(prefixed_url_for('interaction_bp.specialists'))

View File

@@ -161,19 +161,19 @@ def edit_partner_service(partner_service_id):
partner_service = PartnerService.query.get_or_404(partner_service_id) partner_service = PartnerService.query.get_or_404(partner_service_id)
partner = session.get('partner', None) partner = session.get('partner', None)
partner_id = session['partner']['id'] partner_id = session['partner']['id']
current_app.logger.debug(f"Request Type: {request.method}")
form = EditPartnerServiceForm(obj=partner_service) form = EditPartnerServiceForm(obj=partner_service)
if request.method == 'GET': partner_service_config = cache_manager.partner_services_config_cache.get_config(partner_service.type,
partner_service_config = cache_manager.partner_services_config_cache.get_config(partner_service.type, partner_service.type_version)
partner_service.type_version) configuration_config = partner_service_config.get('configuration')
configuration_config = partner_service_config.get('configuration') current_app.logger.debug(f"Configuration config for {partner_service.type} {partner_service.type_version}: "
current_app.logger.debug(f"Configuration config for {partner_service.type} {partner_service.type_version}: " f"{configuration_config}")
f"{configuration_config}") form.add_dynamic_fields("configuration", partner_service_config, partner_service.configuration)
form.add_dynamic_fields("configuration", configuration_config, partner_service.configuration) permissions_config = partner_service_config.get('permissions')
permissions_config = partner_service_config.get('permissions') current_app.logger.debug(f"Permissions config for {partner_service.type} {partner_service.type_version}: "
current_app.logger.debug(f"Permissions config for {partner_service.type} {partner_service.type_version}: " f"{permissions_config}")
f"{permissions_config}") form.add_dynamic_fields("permissions", partner_service_config, partner_service.permissions)
form.add_dynamic_fields("permissions", permissions_config, partner_service.permissions)
if request.method == 'POST': if request.method == 'POST':
current_app.logger.debug(f"Form returned: {form.data}") current_app.logger.debug(f"Form returned: {form.data}")

View File

@@ -2,12 +2,15 @@ from flask import current_app, session
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import (StringField, BooleanField, SubmitField, EmailField, IntegerField, DateField, from wtforms import (StringField, BooleanField, SubmitField, EmailField, IntegerField, DateField,
SelectField, SelectMultipleField, FieldList, FormField, TextAreaField) 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 import pytz
from flask_security import current_user 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 common.services.user import UserServices
from config.type_defs.service_types import SERVICE_TYPES from config.type_defs.service_types import SERVICE_TYPES
from eveai_app.views.dynamic_form_base import DynamicFormBase
class TenantForm(FlaskForm): class TenantForm(FlaskForm):
@@ -17,7 +20,6 @@ class TenantForm(FlaskForm):
website = StringField('Website', validators=[DataRequired(), Length(max=255)]) website = StringField('Website', validators=[DataRequired(), Length(max=255)])
# language fields # language fields
default_language = SelectField('Default Language', choices=[], validators=[DataRequired()]) default_language = SelectField('Default Language', choices=[], validators=[DataRequired()])
allowed_languages = SelectMultipleField('Allowed Languages', choices=[], validators=[DataRequired()])
# invoicing fields # invoicing fields
currency = SelectField('Currency', choices=[], validators=[DataRequired()]) currency = SelectField('Currency', choices=[], validators=[DataRequired()])
# Timezone # Timezone
@@ -32,13 +34,56 @@ class TenantForm(FlaskForm):
super(TenantForm, self).__init__(*args, **kwargs) super(TenantForm, self).__init__(*args, **kwargs)
# initialise language fields # initialise language fields
self.default_language.choices = [(lang, lang.lower()) for lang in current_app.config['SUPPORTED_LANGUAGES']] self.default_language.choices = [(lang, lang.lower()) for lang in current_app.config['SUPPORTED_LANGUAGES']]
self.allowed_languages.choices = [(lang, lang.lower()) for lang in current_app.config['SUPPORTED_LANGUAGES']]
# initialise currency field # initialise currency field
self.currency.choices = [(curr, curr) for curr in current_app.config['SUPPORTED_CURRENCIES']] self.currency.choices = [(curr, curr) for curr in current_app.config['SUPPORTED_CURRENCIES']]
# initialise timezone # initialise timezone
self.timezone.choices = [(tz, tz) for tz in pytz.all_timezones] self.timezone.choices = [(tz, tz) for tz in pytz.common_timezones]
# Initialize fallback algorithms # Initialize fallback algorithms
self.type.choices = [(t, t) for t in current_app.config['TENANT_TYPES']] 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
# 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)
class EditTenantForm(FlaskForm):
id = IntegerField('ID', widget=HiddenInput())
name = StringField('Name', validators=[DataRequired(), Length(max=80)])
code = StringField('Code', validators=[DataRequired()], render_kw={'readonly': True})
type = SelectField('Tenant Type', validators=[Optional()], default='Active')
website = StringField('Website', validators=[DataRequired(), Length(max=255)])
# language fields
default_language = SelectField('Default Language', choices=[], validators=[DataRequired()])
# invoicing fields
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)
# Embedding variables
submit = SubmitField('Submit')
def __init__(self, *args, **kwargs):
super(EditTenantForm, self).__init__(*args, **kwargs)
# initialise language fields
self.default_language.choices = [(lang, lang.lower()) for lang in current_app.config['SUPPORTED_LANGUAGES']]
# initialise currency field
self.currency.choices = [(curr, curr) for curr in current_app.config['SUPPORTED_CURRENCIES']]
# initialise timezone
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 = self.id.data
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 # Show field only for Super Users with partner in session
if not current_user.has_roles('Super User') or 'partner' not in session: if not current_user.has_roles('Super User') or 'partner' not in session:
self._fields.pop('assign_to_partner', None) self._fields.pop('assign_to_partner', None)
@@ -131,4 +176,35 @@ class EditTenantProjectForm(FlaskForm):
self.services.choices = [(key, value['description']) for key, value in SERVICE_TYPES.items()] self.services.choices = [(key, value['description']) for key, value in SERVICE_TYPES.items()]
def validate_make_name(form, field):
# Check if tenant_make already exists in the database
existing_make = TenantMake.query.filter_by(name=field.data).first()
if existing_make:
current_app.logger.debug(f'Existing make: {existing_make.id}')
current_app.logger.debug(f'Form has id: {hasattr(form, 'id')}')
if hasattr(form, 'id'):
current_app.logger.debug(f'Form has id: {form.id.data}')
if existing_make:
if not hasattr(form, 'id') or form.id.data != existing_make.id:
raise ValidationError(f'A Make with name "{field.data}" already exists. Choose another name.')
class TenantMakeForm(DynamicFormBase):
name = StringField('Name', validators=[DataRequired(), Length(max=50), 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)])
class EditTenantMakeForm(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 import uuid
from datetime import datetime as dt, timezone as tz from datetime import datetime as dt, timezone as tz
from flask import request, redirect, flash, render_template, Blueprint, session, current_app 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 from sqlalchemy.exc import SQLAlchemyError, IntegrityError
import ast import ast
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant, TenantMake
from common.extensions import db, security, minio_client, simple_encryption 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 common.utils.security_utils import send_confirmation_email, send_reset_email
from config.type_defs.service_types import SERVICE_TYPES from config.type_defs.service_types import SERVICE_TYPES
from .user_forms import TenantForm, CreateUserForm, EditUserForm, TenantDomainForm, TenantSelectionForm, \ from .user_forms import TenantForm, CreateUserForm, EditUserForm, TenantDomainForm, TenantSelectionForm, \
TenantProjectForm, EditTenantProjectForm TenantProjectForm, EditTenantProjectForm, TenantMakeForm, EditTenantForm, EditTenantMakeForm
from common.utils.database import Database from common.utils.database import Database
from common.utils.view_assistants import prepare_table_for_macro, form_validation_failed from common.utils.view_assistants import prepare_table_for_macro, form_validation_failed
from common.utils.simple_encryption import generate_api_key from common.utils.simple_encryption import generate_api_key
@@ -110,12 +112,18 @@ def tenant():
@roles_accepted('Super User', 'Partner Admin') @roles_accepted('Super User', 'Partner Admin')
def edit_tenant(tenant_id): def edit_tenant(tenant_id):
tenant = Tenant.query.get_or_404(tenant_id) # This will return a 404 if no tenant is found tenant = Tenant.query.get_or_404(tenant_id) # This will return a 404 if no tenant is found
form = TenantForm(obj=tenant) form = EditTenantForm(obj=tenant)
if form.validate_on_submit(): if form.validate_on_submit():
# Populate the tenant with form data # Populate the tenant with form data
form.populate_obj(tenant) 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() db.session.commit()
flash('Tenant updated successfully.', 'success') flash('Tenant updated successfully.', 'success')
if session.get('tenant'): if session.get('tenant'):
@@ -459,8 +467,18 @@ def edit_tenant_domain(tenant_domain_id):
def tenant_overview(): def tenant_overview():
tenant_id = session['tenant']['id'] tenant_id = session['tenant']['id']
tenant = Tenant.query.get_or_404(tenant_id) tenant = Tenant.query.get_or_404(tenant_id)
form = TenantForm(obj=tenant) form = EditTenantForm(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']) @user_bp.route('/tenant_project', methods=['GET', 'POST'])
@@ -622,6 +640,133 @@ def delete_tenant_project(tenant_project_id):
return redirect(prefixed_url_for('user_bp.tenant_projects')) 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)
tenant_id = session['tenant']['id']
query = TenantMake.query.filter_by(tenant_id=tenant_id).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 = EditTenantMakeForm(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()
return None
return None
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'))
return None
def reset_uniquifier(user): def reset_uniquifier(user):
security.datastore.set_uniquifier(user) security.datastore.set_uniquifier(user)
db.session.add(user) db.session.add(user)

View File

@@ -4,7 +4,7 @@ from flask import Flask
import os import os
from common.utils.celery_utils import make_celery, init_celery from common.utils.celery_utils import make_celery, init_celery
from config.logging_config import LOGGING from config.logging_config import configure_logging
from config.config import get_config from config.config import get_config
@@ -21,7 +21,7 @@ def create_app(config_file=None):
case _: case _:
app.config.from_object(get_config('dev')) app.config.from_object(get_config('dev'))
logging.config.dictConfig(LOGGING) configure_logging()
register_extensions(app) register_extensions(app)

View File

@@ -0,0 +1,114 @@
import logging
import os
from flask import Flask, jsonify, request
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 configure_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
configure_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("-------------------------------------------------------------------------------------------------")
# @app.before_request
# def app_before_request():
# app.logger.debug(f'App before request: {request.path} ===== Method: {request.method} =====')
# app.logger.debug(f'Full URL: {request.url}')
# app.logger.debug(f'Endpoint: {request.endpoint}')
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,825 @@
/* Chat App Container Layout */
.chat-app-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
min-height: 0; /* Belangrijk voor flexbox overflow */
padding: 20px; /* Algemene padding voor alle kanten */
box-sizing: border-box;
}
/* Gemeenschappelijke container voor consistente breedte */
.chat-component-container {
width: 100%;
max-width: 1000px; /* Optimale breedte */
margin-left: auto;
margin-right: auto;
display: flex;
flex-direction: column;
flex: 1; /* Neemt beschikbare verticale ruimte in */
}
/* Message Area - neemt alle beschikbare ruimte */
.chat-messages-area {
flex: 1; /* Neemt alle beschikbare ruimte */
overflow: hidden; /* Voorkomt dat het groter wordt dan container */
display: flex;
flex-direction: column;
min-height: 0; /* Belangrijk voor nested flexbox */
margin-bottom: 20px; /* Ruimte tussen messages en input */
border-radius: 15px;
background: rgba(255,255,255,0.1);
backdrop-filter: blur(10px);
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
width: 100%;
max-width: 1000px; /* Optimale breedte */
margin-left: auto;
margin-right: auto; /* Horizontaal centreren */
align-self: center; /* Extra centrering in flexbox context */
}
/* Chat Input - altijd onderaan */
.chat-input-area {
flex: none; /* Neemt alleen benodigde ruimte */
border-radius: 15px;
background: rgba(255,255,255,0.15);
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.2);
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
z-index: 10;
width: 100%;
max-width: 1000px; /* Optimale breedte */
margin-left: auto;
margin-right: auto; /* Horizontaal centreren */
align-self: center; /* Extra centrering in flexbox context */
}
/* Zorg dat de MessageHistory container ook flexbox gebruikt */
.message-history-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 20px; /* Interne padding voor MessageHistory */
box-sizing: border-box;
width: 100%;
max-width: 1000px; /* Optimale breedte */
margin-left: auto;
margin-right: auto; /* Horizontaal centreren */
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding-right: 10px; /* Ruimte voor scrollbar */
margin-right: -10px; /* Compenseer voor scrollbar */
scroll-behavior: smooth;
}
/* Chat Input styling */
.chat-input-container {
width: 100%;
position: relative;
padding: 20px; /* Interne padding voor ChatInput */
box-sizing: border-box;
max-width: 1000px; /* Optimale breedte */
margin-left: auto;
margin-right: auto; /* Horizontaal centreren */
}
.chat-input {
display: flex;
align-items: flex-end;
gap: 12px;
padding: 20px;
background: white;
border-radius: 15px;
box-shadow: 0 2px 15px rgba(0,0,0,0.1);
border: 1px solid rgba(0,0,0,0.05);
}
.input-main {
flex: 1;
position: relative;
}
.message-input {
width: 100%;
min-height: 45px;
max-height: 120px;
padding: 12px 18px;
border: 1px solid #ddd;
border-radius: 25px;
resize: none;
font-family: inherit;
font-size: 14px;
line-height: 1.4;
outline: none;
transition: all 0.2s ease;
box-sizing: border-box;
}
.message-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
.message-input.over-limit {
border-color: #dc3545;
background-color: rgba(220, 53, 69, 0.05);
}
.input-actions {
display: flex;
align-items: center;
gap: 8px;
}
.send-btn {
width: 45px;
height: 45px;
border: none;
border-radius: 50%;
background: var(--primary-color);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.2s ease;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.send-btn:hover:not(:disabled) {
background: var(--secondary-color);
transform: scale(1.05);
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.send-btn:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* Character counter */
.character-counter {
position: absolute;
bottom: -25px;
right: 15px;
font-size: 12px;
color: #666;
padding: 2px 6px;
background: rgba(255,255,255,0.9);
border-radius: 10px;
backdrop-filter: blur(5px);
}
.character-counter.over-limit {
color: #dc3545;
font-weight: bold;
background: rgba(220, 53, 69, 0.1);
}
/* Loading spinner */
.loading-spinner {
font-size: 16px;
animation: spin 1s linear infinite;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.chat-app-container {
padding: 10px; /* Kleinere padding op mobiel */
}
.chat-messages-area {
margin-bottom: 15px;
max-width: 100%; /* Op mobiel volledige breedte gebruiken */
}
.chat-input-area {
max-width: 100%; /* Op mobiel volledige breedte gebruiken */
}
.message-history-container {
padding: 15px;
max-width: 100%; /* Op mobiel volledige breedte gebruiken */
}
.chat-input-container {
padding: 15px;
max-width: 100%; /* Op mobiel volledige breedte gebruiken */
}
.chat-input {
padding: 15px;
gap: 10px;
}
.action-btn {
width: 40px;
height: 40px;
font-size: 16px;
}
.message-input {
font-size: 16px; /* Voorkomt zoom op iOS */
padding: 10px 15px;
min-height: 40px;
}
.chat-component-container {
max-width: 100%; /* Op mobiel volledige breedte gebruiken */
}
}
/* Extra small screens */
@media (max-width: 480px) {
.chat-app-container {
padding: 8px;
}
.chat-messages-area {
margin-bottom: 12px;
}
.message-history-container {
padding: 12px;
}
.chat-input-container {
padding: 12px;
}
}
/* Loading states */
.chat-input.loading .message-input {
opacity: 0.7;
}
.chat-input.loading .action-btn {
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* Scrollbar styling voor webkit browsers */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: rgba(0,0,0,0.1);
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.3);
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.5);
}
/* Verberg lege message bubbles tot er inhoud is */
.message-text:empty {
display: none;
}
.progress-tracker .status-icon.error {
color: #f44336;
}
.progress-tracker.error .progress-header {
background-color: rgba(244, 67, 54, 0.1);
border-color: #f44336;
}
/* Zorg dat de progress tracker goed wordt weergegeven in een lege message bubble */
.message-content:has(.message-text:empty) .message-progress {
margin-bottom: 0;
}
/* Verberg de message content container als er geen inhoud is en de verwerking bezig is */
.message-content:has(.message-text:empty):not(:has(.message-progress.completed)):not(:has(.message-progress.error)) {
background: transparent;
box-shadow: none;
border: none;
padding: 0;
margin: 0;
}
/* Focus binnen ChatInput voor toegankelijkheid */
.chat-input:focus-within {
box-shadow: 0 2px 20px rgba(0, 123, 255, 0.2);
border-color: rgba(0, 123, 255, 0.3);
}
/* Smooth transitions */
.chat-messages-area,
.chat-input-area {
transition: all 0.3s ease;
}
.chat-messages-area:hover,
.chat-input-area:hover {
box-shadow: 0 6px 25px rgba(0,0,0,0.15);
}
/* Message Bubbles Styling - Aangepast voor werkelijke template structuur */
/* Basis message container */
.message {
display: flex;
margin-bottom: 16px;
padding: 0 20px;
animation: messageSlideIn 0.3s ease-out;
clear: both;
}
/* User message alignment - rechts uitgelijnd */
.message.user {
justify-content: flex-end;
}
/* AI/Bot message alignment - links uitgelijnd */
.message.ai,
.message.bot {
justify-content: flex-start;
}
/* Message content wrapper - dit wordt de bubble */
.message-content {
max-width: 70%;
padding: 12px 16px;
border-radius: 18px;
word-wrap: break-word;
position: relative;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: all 0.2s ease;
display: inline-block;
}
/* User message bubble styling */
.message.user .message-content {
background: rgba(0, 0, 0, 0.1);
color: white;
border-bottom-right-radius: 4px;
}
/* AI/Bot message bubble styling */
.message.ai .message-content,
.message.bot .message-content {
background: rgba(255, 255, 255, 0.1);
color: #212529;
border-bottom-left-radius: 4px;
margin-right: 60px;
}
/* Message text content */
.message-text {
line-height: 1.4;
font-size: 14px;
margin-bottom: 6px;
}
.message-text p {
margin: 0;
}
.message-text p + p {
margin-top: 8px;
}
.btn-small {
padding: 4px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #545b62;
}
/* Special message types */
/* Form messages */
.form-message {
justify-content: center;
margin: 20px 0;
}
.form-message .message-content {
max-width: 90%;
background: white;
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
/* System messages */
.system-message {
text-align: center;
background: rgba(108, 117, 125, 0.1);
color: #6c757d;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
margin: 10px auto;
max-width: 80%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.system-icon {
font-size: 14px;
}
/* Error messages */
.error-message {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 8px;
padding: 12px 16px;
margin: 10px auto;
max-width: 80%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.error-icon {
font-size: 16px;
color: #dc3545;
}
.retry-btn {
background: #dc3545;
color: white;
border: none;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s ease;
}
.retry-btn:hover {
background: #c82333;
}
/* Message reactions */
.message-reactions {
display: flex;
gap: 4px;
margin-top: 8px;
flex-wrap: wrap;
}
.reaction {
background: rgba(0,0,0,0.05);
border: 1px solid rgba(0,0,0,0.1);
border-radius: 12px;
padding: 2px 8px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.reaction:hover {
background: rgba(0,0,0,0.1);
transform: scale(1.05);
}
/* Image and file messages */
.message-image {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
transition: transform 0.2s ease;
}
.message-image:hover {
transform: scale(1.02);
}
.image-caption {
font-size: 13px;
margin-bottom: 6px;
opacity: 0.9;
}
.file-attachment {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: rgba(0,0,0,0.03);
border-radius: 8px;
margin-bottom: 8px;
}
.file-icon {
font-size: 24px;
}
.file-info {
flex: 1;
}
.file-name {
font-weight: 500;
margin-bottom: 2px;
}
.file-size {
font-size: 12px;
opacity: 0.7;
}
.file-download {
font-size: 20px;
text-decoration: none;
cursor: pointer;
transition: transform 0.2s ease;
}
.file-download:hover {
transform: scale(1.1);
}
/* Hover effects voor message bubbles */
.message-content:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
/* Bestaande animation en date-separator blijven hetzelfde */
@keyframes messageSlideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Empty state styling - blijft hetzelfde */
.empty-state {
text-align: center;
padding: 40px 20px;
color: #6c757d;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-text {
font-size: 18px;
font-weight: 500;
margin-bottom: 8px;
}
.empty-subtext {
font-size: 14px;
opacity: 0.8;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.message {
padding: 0 15px;
}
.message-content {
max-width: 85%;
padding: 10px 14px;
font-size: 14px;
}
.message.user .message-content {
margin-left: 40px;
}
.message.ai .message-content,
.message.bot .message-content {
margin-right: 40px;
}
}
@media (max-width: 480px) {
.message {
padding: 0 10px;
}
.message-content {
max-width: 90%;
margin-left: 20px !important;
margin-right: 20px !important;
}
}
/* Progress Tracker Styling */
.progress-tracker {
margin: 8px 0;
border-radius: 8px;
background: #f8f9fa;
overflow: hidden;
transition: all 0.3s ease;
font-size: 13px;
}
.progress-tracker.expanded {
max-height: 200px;
}
.progress-tracker.completed {
background: rgba(155, 255, 155, 0.1);
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
cursor: pointer;
background: rgba(0,0,0,0.02);
border-bottom: 1px solid transparent;
transition: all 0.2s ease;
}
.progress-header:hover {
background: rgba(0,0,0,0.05);
}
.progress-tracker.expanded .progress-header {
border-bottom-color: #e9ecef;
}
.progress-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: #495057;
}
.status-icon {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
position: relative;
}
.status-icon.completed {
background: #28a745;
color: white;
font-size: 8px;
line-height: 12px;
text-align: center;
}
.status-icon.in-progress {
background: #007bff;
animation: pulse 1.5s infinite;
}
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #f3f3f3;
border-top: 2px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.progress-toggle {
color: #6c757d;
font-size: 14px;
transition: transform 0.2s ease;
}
.progress-tracker.expanded .progress-toggle {
transform: rotate(180deg);
}
.progress-error {
padding: 8px 12px;
color: #721c24;
background: #f8d7da;
border-top: 1px solid #f5c6cb;
font-size: 12px;
}
.progress-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.progress-tracker.expanded .progress-content {
max-height: 150px;
overflow-y: auto;
}
.progress-content.single-line {
max-height: 30px;
overflow: hidden;
padding: 8px 12px;
}
.progress-line {
padding: 4px 12px;
border-bottom: 1px solid rgba(0,0,0,0.05);
color: #6c757d;
line-height: 1.3;
}
.progress-line:last-child {
border-bottom: none;
}
/* Animaties */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* Integratie met message bubbles */
.message.ai .progress-tracker,
.message.bot .progress-tracker {
margin-bottom: 8px;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.progress-tracker {
font-size: 12px;
}
.progress-header {
padding: 6px 10px;
}
.progress-line {
padding: 3px 10px;
}
.progress-content.single-line {
padding: 6px 10px;
}
}

Some files were not shown because too many files have changed in this diff Show More