From 04cdff8b27ec14a553121f340840b53d03b94742 Mon Sep 17 00:00:00 2001 From: hasinaxp Date: Wed, 20 Nov 2024 20:26:35 +0530 Subject: [PATCH 01/27] mahesh_mailbox_mock_issue --- kairon/api/app/routers/bot/bot.py | 94 ++- kairon/chat/agent_processor.py | 19 + kairon/chat/utils.py | 19 + kairon/events/server.py | 2 + kairon/shared/channels/mail/__init__.py | 0 kairon/shared/channels/mail/constants.py | 25 + kairon/shared/channels/mail/data_objects.py | 127 ++++ kairon/shared/channels/mail/processor.py | 241 +++++++ kairon/shared/channels/mail/scheduler.py | 66 ++ kairon/shared/constants.py | 2 +- kairon/shared/data/data_models.py | 8 + metadata/integrations.yml | 10 + requirements/prod.txt | 1 + system.yaml | 6 +- tests/unit_test/channels/mail_channel_test.py | 608 ++++++++++++++++++ 15 files changed, 1224 insertions(+), 4 deletions(-) create mode 100644 kairon/shared/channels/mail/__init__.py create mode 100644 kairon/shared/channels/mail/constants.py create mode 100644 kairon/shared/channels/mail/data_objects.py create mode 100644 kairon/shared/channels/mail/processor.py create mode 100644 kairon/shared/channels/mail/scheduler.py create mode 100644 tests/unit_test/channels/mail_channel_test.py diff --git a/kairon/api/app/routers/bot/bot.py b/kairon/api/app/routers/bot/bot.py index c2e2c8590..d927b667d 100644 --- a/kairon/api/app/routers/bot/bot.py +++ b/kairon/api/app/routers/bot/bot.py @@ -13,7 +13,7 @@ Response, Endpoint, RasaConfig, - StoryRequest, + BulkTrainingDataAddRequest, TrainingDataGeneratorStatusModel, StoryRequest, SynonymRequest, RegexRequest, StoryType, ComponentConfig, SlotRequest, DictData, LookupTablesRequest, Forms, TextDataLowerCase, SlotMappingRequest, EventConfig, MultiFlowStoryRequest, BotSettingsRequest @@ -25,17 +25,23 @@ from kairon.shared.account.activity_log import UserActivityLogger from kairon.shared.actions.data_objects import ActionServerLogs from kairon.shared.auth import Authentication +from kairon.shared.channels.mail.data_objects import MailClassificationConfig from kairon.shared.constants import TESTER_ACCESS, DESIGNER_ACCESS, CHAT_ACCESS, UserActivityType, ADMIN_ACCESS, \ EventClass, AGENT_ACCESS from kairon.shared.content_importer.content_processor import ContentImporterLogProcessor from kairon.shared.content_importer.data_objects import ContentValidationLogs from kairon.shared.data.assets_processor import AssetsProcessor from kairon.shared.data.audit.processor import AuditDataProcessor +from kairon.shared.data.constant import EVENT_STATUS, ENDPOINT_TYPE, TOKEN_TYPE, ModelTestType, \ + TrainingDataSourceType, AuditlogActions +from kairon.shared.data.data_models import MailConfigRequest from kairon.shared.data.constant import ENDPOINT_TYPE, ModelTestType, \ AuditlogActions from kairon.shared.data.data_objects import TrainingExamples, ModelTraining, Rules from kairon.shared.data.model_processor import ModelProcessor from kairon.shared.data.processor import MongoProcessor +from kairon.shared.data.training_data_generation_processor import TrainingDataGenerationProcessor +from kairon.shared.data.utils import DataUtility from kairon.shared.events.processor import ExecutorProcessor from kairon.shared.importer.data_objects import ValidationLogs from kairon.shared.importer.processor import DataImporterLogProcessor @@ -606,6 +612,30 @@ def upload_files( return {"message": "Upload in progress! Check logs."} +@router.post("/upload/data_generation/file", response_model=Response) +async def upload_data_generation_file( + background_tasks: BackgroundTasks, + doc: UploadFile, + current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) +): + """ + Uploads document for training data generation and triggers event for intent creation + """ + TrainingDataGenerationProcessor.is_in_progress(current_user.get_bot()) + TrainingDataGenerationProcessor.check_data_generation_limit(current_user.get_bot()) + file_path = await Utility.upload_document(doc) + TrainingDataGenerationProcessor.set_status(bot=current_user.get_bot(), + user=current_user.get_user(), status=EVENT_STATUS.INITIATED.value, + document_path=file_path) + token, _ = Authentication.generate_integration_token( + current_user.get_bot(), current_user.email, token_type=TOKEN_TYPE.DYNAMIC.value + ) + background_tasks.add_task( + DataUtility.trigger_data_generation_event, current_user.get_bot(), current_user.get_user(), token + ) + return {"message": "File uploaded successfully and training data generation has begun"} + + @router.get("/download/data") async def download_data( background_tasks: BackgroundTasks, @@ -836,6 +866,68 @@ async def get_action_server_logs(start_idx: int = 0, page_size: int = 10, return Response(data=data) +@router.post("/data/bulk", response_model=Response) +async def add_training_data( + request_data: BulkTrainingDataAddRequest, + current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) +): + """ + Adds intents, training examples and responses along with story against the responses + """ + try: + TrainingDataGenerationProcessor.validate_history_id(request_data.history_id) + status, training_data_added = mongo_processor.add_training_data( + training_data=request_data.training_data, + bot=current_user.get_bot(), + user=current_user.get_user(), + is_integration=current_user.get_integration_status() + ) + TrainingDataGenerationProcessor.update_is_persisted_flag(request_data.history_id, training_data_added) + except Exception as e: + raise AppException(e) + return {"message": "Training data added successfully!", "data": status} + + +@router.put("/update/data/generator/status", response_model=Response) +async def update_training_data_generator_status( + request_data: TrainingDataGeneratorStatusModel, + current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) +): + """ + Update training data generator status + """ + try: + TrainingDataGenerationProcessor.retrieve_response_and_set_status(request_data, current_user.get_bot(), + current_user.get_user()) + except Exception as e: + raise AppException(e) + return {"message": "Status updated successfully!"} + + +@router.get("/data/generation/history", response_model=Response) +async def get_train_data_history( + log_type: TrainingDataSourceType = TrainingDataSourceType.document.value, + current_user: User = Security(Authentication.get_current_user_and_bot, scopes=TESTER_ACCESS), +): + """ + Fetches File Data Generation history, when and who initiated the process + """ + file_history = TrainingDataGenerationProcessor.get_training_data_generator_history(current_user.get_bot(), log_type) + return {"data": {"training_history": file_history}} + + +@router.get("/data/generation/latest", response_model=Response) +async def get_latest_data_generation_status( + current_user: User = Security(Authentication.get_current_user_and_bot, scopes=TESTER_ACCESS), +): + """ + Fetches status for latest data generation request + """ + latest_data_generation_status = TrainingDataGenerationProcessor.fetch_latest_workload(current_user.get_bot(), + current_user.get_user()) + return {"data": latest_data_generation_status} + + @router.get("/slots", response_model=Response) async def get_slots( current_user: User = Security(Authentication.get_current_user_and_bot, scopes=TESTER_ACCESS), diff --git a/kairon/chat/agent_processor.py b/kairon/chat/agent_processor.py index a88f27614..78c083bf3 100644 --- a/kairon/chat/agent_processor.py +++ b/kairon/chat/agent_processor.py @@ -78,3 +78,22 @@ async def handle_channel_message(bot: Text, userdata: UserMessage): if not is_live_agent_enabled: return await AgentProcessor.get_agent(bot).handle_message(userdata) return await LiveAgentHandler.process_live_agent(bot, userdata) + + @staticmethod + def get_agent_without_cache(bot: str, use_store: bool = True) -> Agent: + endpoint = AgentProcessor.mongo_processor.get_endpoints( + bot, raise_exception=False + ) + action_endpoint = Utility.get_action_url(endpoint) + model_path = Utility.get_latest_model(bot) + domain = AgentProcessor.mongo_processor.load_domain(bot) + if use_store: + mongo_store = Utility.get_local_mongo_store(bot, domain) + lock_store_endpoint = Utility.get_lock_store(bot) + agent = KaironAgent.load(model_path, action_endpoint=action_endpoint, tracker_store=mongo_store, + lock_store=lock_store_endpoint) + else: + agent = KaironAgent.load(model_path, action_endpoint=action_endpoint) + + agent.model_ver = model_path.split("/")[-1] + return agent diff --git a/kairon/chat/utils.py b/kairon/chat/utils.py index 6c7c7a51c..41cf95660 100644 --- a/kairon/chat/utils.py +++ b/kairon/chat/utils.py @@ -43,6 +43,25 @@ async def chat( ) return chat_response + @staticmethod + async def process_messages_via_bot( + messages: [str], + account: int, + bot: str, + user: str, + is_integration_user: bool = False, + metadata: Dict = None, + ): + responses = [] + uncached_model = AgentProcessor.get_agent_without_cache(bot, False) + metadata = ChatUtils.get_metadata(account, bot, is_integration_user, metadata) + for message in messages: + msg = UserMessage(message, sender_id=user, metadata=metadata) + chat_response = await uncached_model.handle_message(msg) + responses.append(chat_response) + return responses + + @staticmethod def reload(bot: Text, user: Text): exc = None diff --git a/kairon/events/server.py b/kairon/events/server.py index 344e8528f..d34dec98a 100644 --- a/kairon/events/server.py +++ b/kairon/events/server.py @@ -56,6 +56,8 @@ async def lifespan(app: FastAPI): """ MongoDB is connected on the bot trainer startup """ config: dict = Utility.mongoengine_connection(Utility.environment['database']["url"]) connect(**config) + from kairon.shared.channels.mail.scheduler import MailScheduler + MailScheduler.epoch() yield disconnect() diff --git a/kairon/shared/channels/mail/__init__.py b/kairon/shared/channels/mail/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/kairon/shared/channels/mail/constants.py b/kairon/shared/channels/mail/constants.py new file mode 100644 index 000000000..a4775f15c --- /dev/null +++ b/kairon/shared/channels/mail/constants.py @@ -0,0 +1,25 @@ + + +class MailConstants: + DEFAULT_SMTP_SERVER = 'smtp.gmail.com' + DEFAULT_IMAP_SERVER = 'imap.gmail.com' + DEFAULT_SMTP_PORT = 587 + DEFAULT_LLM_TYPE = "openai" + DEFAULT_HYPERPARAMETERS = { + "frequency_penalty": 0, + "logit_bias": {}, + "max_tokens": 300, + "model": "gpt-4o-mini", + "n": 1, + "presence_penalty": 0, + "stop": None, + "stream": False, + "temperature": 0, + "top_p": 0 + } + DEFAULT_TEMPLATE = "

Dear {name},

{bot_response}



Generated by kAIron AI.\n" + DEFAULT_SYSTEM_PROMPT = 'Classify into one of the intents and extract entities as given in the context.' \ + 'If the mail does not belong to any of the intents, classify intent as null.' + + PROCESS_MESSAGE_BATCH_SIZE = 4 + diff --git a/kairon/shared/channels/mail/data_objects.py b/kairon/shared/channels/mail/data_objects.py new file mode 100644 index 000000000..bccec5466 --- /dev/null +++ b/kairon/shared/channels/mail/data_objects.py @@ -0,0 +1,127 @@ +import time +from mongoengine import Document, StringField, DictField, ListField, FloatField, BooleanField + +from kairon import Utility +from kairon.exceptions import AppException + + + + +class MailClassificationConfig(Document): + intent: str = StringField(required=True) + entities: list[str] = ListField(StringField()) + subjects: list[str] = ListField(StringField()) + classification_prompt: str = StringField() + reply_template: str = StringField() + bot: str = StringField(required=True) + user: str = StringField() + timestamp: float = FloatField() + status: bool = BooleanField(default=True) + + + @staticmethod + def create_doc( + intent: str, + entities: list[str], + subjects: list[str], + classification_prompt: str, + reply_template: str, + bot: str, + user: str + ): + mail_config = None + try: + exists = MailClassificationConfig.objects(bot=bot, intent=intent).first() + if exists and exists.status: + raise AppException(f"Mail configuration already exists for intent [{intent}]") + elif exists and not exists.status: + exists.update( + entities=entities, + subjects=subjects, + classification_prompt=classification_prompt, + reply_template=reply_template, + timestamp=time.time(), + status=True, + user=user + ) + mail_config = exists + else: + mail_config = MailClassificationConfig( + intent=intent, + entities=entities, + subjects=subjects, + classification_prompt=classification_prompt, + reply_template=reply_template, + bot=bot, + timestamp=time.time(), + status=True, + user=user + ) + mail_config.save() + + except Exception as e: + raise AppException(str(e)) + + return mail_config + + @staticmethod + def get_docs(bot: str): + try: + objs = MailClassificationConfig.objects(bot=bot, status=True) + return_data = [] + for obj in objs: + data = obj.to_mongo().to_dict() + data.pop('_id') + data.pop('timestamp') + data.pop('status') + data.pop('user') + return_data.append(data) + return return_data + except Exception as e: + raise AppException(str(e)) + + @staticmethod + def get_doc(bot: str, intent: str): + try: + obj = MailClassificationConfig.objects(bot=bot, intent=intent, status=True).first() + if not obj: + raise AppException(f"Mail configuration does not exist for intent [{intent}]") + data = obj.to_mongo().to_dict() + data.pop('_id') + data.pop('timestamp') + data.pop('status') + data.pop('user') + return data + except Exception as e: + raise AppException(str(e)) + + + @staticmethod + def delete_doc(bot: str, intent: str): + try: + MailClassificationConfig.objects(bot=bot, intent=intent).delete() + except Exception as e: + raise AppException(str(e)) + + @staticmethod + def soft_delete_doc(bot: str, intent: str): + try: + MailClassificationConfig.objects(bot=bot, intent=intent).update(status=False) + except Exception as e: + raise AppException(str(e)) + + @staticmethod + def update_doc(bot: str, intent: str, **kwargs): + keys = ['entities', 'subjects', 'classification_prompt', 'reply_template'] + for key in kwargs.keys(): + if key not in keys: + raise AppException(f"Invalid key [{key}] provided for updating mail config") + try: + MailClassificationConfig.objects(bot=bot, intent=intent).update(**kwargs) + except Exception as e: + raise AppException(str(e)) + + + + + diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py new file mode 100644 index 000000000..d5d07e1ce --- /dev/null +++ b/kairon/shared/channels/mail/processor.py @@ -0,0 +1,241 @@ +import asyncio +import re + +from apscheduler.schedulers.background import BackgroundScheduler +from loguru import logger +from pydantic.schema import timedelta +from pydantic.validators import datetime +from imap_tools import MailBox, AND +from kairon.chat.utils import ChatUtils +from kairon.exceptions import AppException +from kairon.shared.account.data_objects import Bot +from kairon.shared.channels.mail.constants import MailConstants +from kairon.shared.channels.mail.data_objects import MailClassificationConfig +from kairon.shared.chat.processor import ChatDataProcessor +from kairon.shared.constants import ChannelTypes +from kairon.shared.data.data_objects import BotSettings +from kairon.shared.llm.processor import LLMProcessor +import json +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +import smtplib + + + +class MailProcessor: + def __init__(self, bot): + self.config = ChatDataProcessor.get_channel_config(ChannelTypes.MAIL, bot, False)['config'] + self.llm_type = self.config.get('llm_type', "openai") + self.hyperparameters = self.config.get('hyperparameters', MailConstants.DEFAULT_HYPERPARAMETERS) + self.bot = bot + bot_info = Bot.objects.get(id=bot) + self.account = bot_info.account + self.llm_processor = LLMProcessor(self.bot, self.llm_type) + self.mail_configs = list(MailClassificationConfig.objects(bot=self.bot)) + self.mail_configs_dict = {item.intent: item for item in self.mail_configs} + self.bot_settings = BotSettings.objects(bot=self.bot).get() + self.mailbox = None + self.smtp = None + + + def login_imap(self): + if self.mailbox: + return + email_account = self.config['email_account'] + email_password = self.config['email_password'] + imap_server = self.config.get('imap_server', MailConstants.DEFAULT_IMAP_SERVER) + self.mailbox = MailBox(imap_server).login(email_account, email_password) + + def logout_imap(self): + if self.mailbox: + self.mailbox.logout() + self.mailbox = None + + def login_smtp(self): + if self.smtp: + return + email_account = self.config['email_account'] + email_password = self.config['email_password'] + smtp_server = self.config.get('smtp_server', MailConstants.DEFAULT_SMTP_SERVER) + smtp_port = self.config.get('smtp_port', MailConstants.DEFAULT_SMTP_PORT) + smtp_port = int(smtp_port) + self.smtp = smtplib.SMTP(smtp_server, smtp_port) + self.smtp.starttls() + self.smtp.login(email_account, email_password) + + def logout_smtp(self): + if self.smtp: + self.smtp.quit() + self.smtp = None + + async def send_mail(self, to: str, subject: str, body: str): + try: + email_account = self.config['email_account'] + msg = MIMEMultipart() + msg['From'] = email_account + msg['To'] = to + msg['Subject'] = subject + msg.attach(MIMEText(body, 'html')) + self.smtp.sendmail(email_account, to, msg.as_string()) + except Exception as e: + logger.error(f"Error sending mail to {to}: {str(e)}") + + def process_mail(self, intent: str, rasa_chat_response: dict): + slots = rasa_chat_response.get('slots', []) + slots = {key.strip(): value.strip() for slot_str in slots for key, value in [slot_str.split(":", 1)] if + value.strip() != 'None'} + + + responses = '

'.join(response.get('text', '') for response in rasa_chat_response.get('response', [])) + slots['bot_response'] = responses + mail_template = self.mail_configs_dict.get(intent, None) + if mail_template and mail_template.reply_template: + mail_template = mail_template.reply_template + else: + mail_template = MailConstants.DEFAULT_TEMPLATE + + return mail_template.format(**{key: str(value) for key, value in slots.items()}) + + async def classify_messages(self, messages: [dict]) -> [dict]: + if self.bot_settings.llm_settings['enable_faq']: + try: + system_prompt = self.config.get('system_prompt', MailConstants.DEFAULT_SYSTEM_PROMPT) + system_prompt += '\n return json format: [{"intent": "intent_name", "entities": {"entity_name": "value"}, "mail_id": "mail_id", "subject": "subject"}], if not classifiable set intent and not-found entity values as null' + context_prompt = self.get_context_prompt() + messages = json.dumps(messages) + info = await self.llm_processor.predict(messages, + self.bot_settings.user, + system_prompt=system_prompt, + context_prompt=context_prompt, + similarity_prompt=[], + hyperparameters=self.hyperparameters) + classifications = MailProcessor.extract_jsons_from_text(info["content"])[0] + return classifications + except Exception as e: + logger.error(str(e)) + raise AppException(str(e)) + + @staticmethod + async def process_messages(bot: str, batch: [dict]): + try: + mp = MailProcessor(bot) + classifications = await mp.classify_messages(batch) + user_messages: [str] = [] + responses = [] + intents = [] + for classification in classifications: + try: + intent = classification['intent'] + if not intent or intent == 'null': + continue + entities = classification['entities'] + sender_id = classification['mail_id'] + subject = f"{classification['subject']}" + + #mail_id is in the format "name " + if '<' in sender_id: + sender_id = sender_id.split('<')[1].split('>')[0] + + entities_str = ', '.join([f'"{key}": "{value}"' for key, value in entities.items() if value and value != 'null']) + user_msg = f'/{intent}{{{entities_str}}}' + logger.info(user_msg) + + user_messages.append(user_msg) + responses.append({ + 'to': sender_id, + 'subject': subject, + }) + intents.append(intent) + except Exception as e: + logger.exception(e) + logger.info(responses) + + chat_responses = await ChatUtils.process_messages_via_bot(user_messages, + mp.account, + bot, + mp.bot_settings.user, + False, + { + 'channel': ChannelTypes.MAIL.value + }) + logger.info(chat_responses) + + + for index, response in enumerate(chat_responses): + responses[index]['body'] = mp.process_mail(intents[index], response) + + + mp.login_smtp() + tasks = [mp.send_mail(**response) for response in responses] + await asyncio.gather(*tasks) + mp.logout_smtp() + + except Exception as e: + raise AppException(str(e)) + + def get_context_prompt(self) -> str: + context_prompt = "" + for item in self.mail_configs: + context_prompt += f"intent: {item['intent']} \n" + context_prompt += f"entities: {item['entities']} \n" + context_prompt += "\nclassification criteria: \n" + context_prompt += f"subjects: {item['subjects']} \n" + context_prompt += f"rule: {item['classification_prompt']} \n" + context_prompt += "\n\n" + return context_prompt + + + @staticmethod + def process_message_task(bot: str, message_batch: [dict]): + asyncio.run(MailProcessor.process_messages(bot, message_batch)) + + + + @staticmethod + async def process_mails(bot: str, scheduler : BackgroundScheduler = None): + mp = MailProcessor(bot) + time_shift = int(mp.config.get('interval', 5 * 60)) + last_read_timestamp = datetime.now() - timedelta(seconds=time_shift) + messages = [] + try: + mp.login_imap() + msgs = mp.mailbox.fetch(AND(seen=False, date_gte=last_read_timestamp.date())) + print(msgs) + for msg in msgs: + subject = msg.subject + sender_id = msg.from_ + date = msg.date + body = msg.text or msg.html or "" + logger.info(subject, sender_id, date, body) + message_entry = { + 'mail_id': sender_id, + 'subject': subject, + 'date': str(date), + 'body': body + } + messages.append(message_entry) + mp.logout_imap() + + if not messages or len(messages) == 0: + return 0, time_shift + + for batch_id in range(0, len(messages), MailConstants.PROCESS_MESSAGE_BATCH_SIZE): + batch = messages[batch_id: batch_id + MailConstants.PROCESS_MESSAGE_BATCH_SIZE] + scheduler.add_job(MailProcessor.process_message_task, args=[bot, batch], run_date=datetime.now()) + + return len(messages), time_shift + except Exception as e: + logger.exception(e) + return 0, time_shift + + @staticmethod + def extract_jsons_from_text(text): + json_pattern = re.compile(r'(\{.*?\}|\[.*?\])', re.DOTALL) + jsons = [] + for match in json_pattern.findall(text): + try: + json_obj = json.loads(match) + jsons.append(json_obj) + except json.JSONDecodeError: + continue + return jsons diff --git a/kairon/shared/channels/mail/scheduler.py b/kairon/shared/channels/mail/scheduler.py new file mode 100644 index 000000000..971b0338b --- /dev/null +++ b/kairon/shared/channels/mail/scheduler.py @@ -0,0 +1,66 @@ +import asyncio +from datetime import datetime, timedelta + +from apscheduler.jobstores.mongodb import MongoDBJobStore +from apscheduler.schedulers.background import BackgroundScheduler +from pymongo import MongoClient + +from kairon import Utility +from kairon.shared.channels.mail.processor import MailProcessor +from kairon.shared.chat.data_objects import Channels +from kairon.shared.constants import ChannelTypes +from loguru import logger + + +class MailScheduler: + scheduler = None + scheduled_bots = set() + + @staticmethod + def epoch(): + is_initialized = False + if not MailScheduler.scheduler: + is_initialized = True + client = MongoClient(Utility.environment['database']['url']) + events_db = Utility.environment['events']['queue']['mail_queue_name'] + job_store_name = Utility.environment['events']['scheduler']['mail_scheduler_collection'] + + MailScheduler.scheduler = BackgroundScheduler( + jobstores={job_store_name: MongoDBJobStore(events_db, job_store_name, client)}, + job_defaults={'coalesce': True, 'misfire_grace_time': 7200}) + + bots = Channels.objects(connector_type= ChannelTypes.MAIL) + bots = set(list(bots.values_list('bot'))) + + + unscheduled_bots = bots - MailScheduler.scheduled_bots + logger.info(f"MailScheduler: Epoch: {MailScheduler.scheduled_bots}") + for bot in unscheduled_bots: + first_schedule_time = datetime.now() + timedelta(seconds=5) + MailScheduler.scheduler.add_job(MailScheduler.process_mails_task, + 'date', args=[bot, MailScheduler.scheduler], run_date=first_schedule_time) + MailScheduler.scheduled_bots.add(bot) + + MailScheduler.scheduled_bots = MailScheduler.scheduled_bots.intersection(bots) + + + + if is_initialized: + MailScheduler.scheduler.start() + + @staticmethod + def process_mails_task(bot, scheduler: BackgroundScheduler = None): + if scheduler: + asyncio.run(MailScheduler.process_mails(bot, scheduler)) + + @staticmethod + async def process_mails(bot, scheduler: BackgroundScheduler = None): + + if bot not in MailScheduler.scheduled_bots: + return + logger.info(f"MailScheduler: Processing mails for bot {bot}") + responses, next_delay = await MailProcessor.process_mails(bot, scheduler) + logger.info(f"next_delay: {next_delay}") + next_timestamp = datetime.now() + timedelta(seconds=next_delay) + MailScheduler.scheduler.add_job(MailScheduler.process_mails_task, 'date', args=[bot, scheduler], run_date=next_timestamp) + MailScheduler.epoch() diff --git a/kairon/shared/constants.py b/kairon/shared/constants.py index 3068384df..77ae2c805 100644 --- a/kairon/shared/constants.py +++ b/kairon/shared/constants.py @@ -115,7 +115,7 @@ class ChannelTypes(str, Enum): INSTAGRAM = "instagram" BUSINESS_MESSAGES = "business_messages" LINE = "line" - + MAIL = "mail" class ElementTypes(str, Enum): LINK = "link" diff --git a/kairon/shared/data/data_models.py b/kairon/shared/data/data_models.py index 346e0707f..1fcc76791 100644 --- a/kairon/shared/data/data_models.py +++ b/kairon/shared/data/data_models.py @@ -1341,3 +1341,11 @@ def validate_name(cls, values): raise ValueError("Schedule action can not be empty, it is needed to execute on schedule time") return values + + +class MailConfigRequest(BaseModel): + intent: str + entities: list[str] = [] + subjects: list[str] = [] + classification_prompt: str + reply_template: str = None \ No newline at end of file diff --git a/metadata/integrations.yml b/metadata/integrations.yml index c767e5c18..f2792000c 100644 --- a/metadata/integrations.yml +++ b/metadata/integrations.yml @@ -75,6 +75,16 @@ channels: required_fields: - channel_secret - channel_access_token + mail: + required_fields: + - email_account + - email_password + - imap_server + - smtp_server + - smtp_port + optional_fields: + - interval + - llm_type actions: pipedrive: diff --git a/requirements/prod.txt b/requirements/prod.txt index 504571aad..6dec99fc5 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -67,5 +67,6 @@ mongoengine-jsonschema==0.1.3 fernet==1.0.1 google-generativeai huggingface-hub==0.25.2 +imap-tools==1.7.4 more-itertools python-multipart>=0.0.18 \ No newline at end of file diff --git a/system.yaml b/system.yaml index eec7ce823..cb030254f 100644 --- a/system.yaml +++ b/system.yaml @@ -120,13 +120,14 @@ notifications: events: server_url: ${EVENT_SERVER_ENDPOINT:"http://localhost:5056"} executor: - type: ${EVENTS_EXECUTOR_TYPE} + type: ${EVENTS_EXECUTOR_TYPE:"standalone"} region: ${EVENTS_EXECUTOR_REGION} timeout: ${EVENTS_EXECUTOR_TIMEOUT_MINUTES:60} queue: type: ${EVENTS_QUEUE_TYPE:"mongo"} url: ${EVENTS_QUEUE_URL:"mongodb://localhost:27017/events"} name: ${EVENTS_DB_NAME:"kairon_events"} + mail_queue_name: ${EVENTS_MAIL_QUEUE_NAME:"mail_queue"} task_definition: model_training: ${MODEL_TRAINING_TASK_DEFINITION} model_testing: ${MODEL_TESTING_TASK_DEFINITION} @@ -141,6 +142,7 @@ events: content_importer: ${DOC_CONTENT_IMPORTER_TASK_DEFINITION} scheduler: collection: ${EVENT_SCHEDULER_COLLECTION:"kscheduler"} + mail_scheduler_collection: ${MAIL_SCHEDULER_COLLECTION:"mail_scheduler"} type: ${EVENT_SCHEDULER_TYPE:"kscheduler"} min_trigger_interval: ${MIN_SCHDULER_TRIGGER_INTERVAL:86340} audit_logs: @@ -153,7 +155,7 @@ evaluator: url: ${EXPRESSION_EVALUATOR_ENDPOINT:"http://192.168.100.109:8085/evaluate"} pyscript: trigger_task: ${PYSCRIPT_TRIGGER_TASK:false} - url: ${PYSCRIPT_EVALUATOR_ENDPOINT:"http://192.168.100.109:8087/evaluate"} + url: ${PYSCRIPT_EVALUATOR_ENDPOINT:"http://localhost:8087/evaluate"} multilingual: enable: ${ENABLE_MULTILINGUAL_BOTS:false} diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py new file mode 100644 index 000000000..3605721f8 --- /dev/null +++ b/tests/unit_test/channels/mail_channel_test.py @@ -0,0 +1,608 @@ +import asyncio +import os +import shutil + +import bson +import pytest +from apscheduler.schedulers.background import BackgroundScheduler +from imap_tools import MailMessage + +from mongoengine import connect, disconnect +from telebot.types import BotCommandScopeChat + +from kairon import Utility +from kairon.shared.account.data_objects import Bot, Account +from kairon.shared.channels.mail.constants import MailConstants +from kairon.shared.chat.data_objects import Channels +from kairon.shared.chat.processor import ChatDataProcessor +from kairon.shared.data.data_objects import BotSettings + +os.environ["system_file"] = "./tests/testing_data/system.yaml" +Utility.load_environment() +Utility.load_system_metadata() +from kairon.exceptions import AppException +from kairon.shared.channels.mail.data_objects import MailClassificationConfig +from unittest.mock import patch, MagicMock +from datetime import datetime, timedelta +from kairon.exceptions import AppException +from kairon.shared.channels.mail.processor import MailProcessor +from kairon.shared.constants import ChannelTypes +from kairon.shared.data.constant import EVENT_STATUS +from kairon.shared.data.model_processor import ModelProcessor + + + +pytest_bot_trained = False +model_path = "" + + +def init_bot_model(bot): + global pytest_bot_trained + if pytest_bot_trained: + return + from rasa import train + model_path = os.path.join('models', bot) + if not os.path.exists(model_path): + os.mkdir(model_path) + model_file = train( + domain='tests/testing_data/model_tester/domain.yml', + config='tests/testing_data/model_tester/config.yml', + training_files=['tests/testing_data/model_tester/nlu_with_entities/nlu.yml', + 'tests/testing_data/model_tester/training_stories_success/stories.yml'], + output=model_path, + core_additional_arguments={"augmentation_factor": 100}, + force_training=True + ).model + ModelProcessor.set_training_status( + bot=bot, + user="test", + status=EVENT_STATUS.DONE.value, + model_path=model_file, + ) + pytest_bot_trained = True + return bot + +@pytest.fixture(autouse=True, scope='class') +def setup(): + connect(**Utility.mongoengine_connection(Utility.environment['database']["url"])) + a = Account.objects.create(name="test_user", user="test_user") + bot = Bot.objects.create(name="test_bot", user="test_user", status=True, account=a.id) + pytest.bot = str(bot.id) + b = BotSettings.objects.create(bot=pytest.bot, user="test_user") + b.llm_settings.enable_faq = True + b.save() + ChatDataProcessor.save_channel_config( + { + "connector_type": ChannelTypes.MAIL.value, + "config": { + 'email_account': "testuser@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com", + 'smtp_server': "smtp.testuser.com", + 'smtp_port': "587", + } + }, + pytest.bot, + user="test_user", + ) + yield + disconnect() + if len(model_path) > 0: + shutil.rmtree(model_path) + + + +def test_create_doc_new_entry(): + print(pytest.bot) + doc = MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.bot, + user="test_user" + ) + assert doc.intent == "greeting" + assert doc.bot == pytest.bot + assert doc.status is True + MailClassificationConfig.objects.delete() + + + +def test_create_doc_existing_active_entry(): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.bot, + user="test_user" + ) + with pytest.raises(AppException, match=r"Mail configuration already exists for intent \[greeting\]"): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_email"], + subjects=["hi"], + classification_prompt="Another greeting.", + reply_template="Hello!", + bot=pytest.bot, + user="test_user" + ) + MailClassificationConfig.objects.delete() + + + +def test_get_docs(): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.bot, + user="test_user" + ) + MailClassificationConfig.create_doc( + intent="goodbye", + entities=["farewell"], + subjects=["bye"], + classification_prompt="Classify this email as a goodbye.", + reply_template="Goodbye!", + bot=pytest.bot, + user="test_user" + ) + docs = MailClassificationConfig.get_docs(bot=pytest.bot) + assert len(docs) == 2 + assert docs[0]["intent"] == "greeting" + assert docs[1]["intent"] == "goodbye" + MailClassificationConfig.objects.delete() + + + +def test_get_doc(): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.bot, + user="test_user" + ) + doc = MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") + assert doc["intent"] == "greeting" + assert doc["classification_prompt"] == "Classify this email as a greeting." + MailClassificationConfig.objects.delete() + + +def test_get_doc_nonexistent(): + """Test retrieving a non-existent document.""" + with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): + MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") + + MailClassificationConfig.objects.delete() + + +def test_delete_doc(): + """Test deleting a document.""" + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.bot, + user="test_user" + ) + MailClassificationConfig.delete_doc(bot=pytest.bot, intent="greeting") + with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): + MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") + + MailClassificationConfig.objects.delete() + + +def test_soft_delete_doc(): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.bot, + user="test_user" + ) + MailClassificationConfig.soft_delete_doc(bot=pytest.bot, intent="greeting") + with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): + MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") + + MailClassificationConfig.objects.delete() + + + +def test_update_doc(): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.bot, + user="test_user" + ) + MailClassificationConfig.update_doc( + bot=pytest.bot, + intent="greeting", + entities=["user_name", "greeting"], + reply_template="Hello there!" + ) + doc = MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") + assert doc["entities"] == ["user_name", "greeting"] + assert doc["reply_template"] == "Hello there!" + + MailClassificationConfig.objects.delete() + +def test_update_doc_invalid_key(): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.bot, + user="test_user" + ) + with pytest.raises(AppException, match=r"Invalid key \[invalid_key\] provided for updating mail config"): + MailClassificationConfig.update_doc( + bot=pytest.bot, + intent="greeting", + invalid_key="value" + ) + + MailClassificationConfig.objects.delete() + + +@patch("kairon.shared.channels.mail.processor.LLMProcessor") +@patch("kairon.shared.channels.mail.processor.MailBox") +@patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") +def test_login_imap(mock_get_channel_config, mock_mailbox, mock_llm_processor): + mock_mailbox_instance = MagicMock() + mock_mailbox.return_value = mock_mailbox_instance + mock_mailbox_instance.login.return_value = ("OK", ["Logged in"]) + mock_mailbox_instance._simple_command.return_value = ("OK", ["Logged in"]) + mock_mailbox_instance.select.return_value = ("OK", ["INBOX"]) + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "testuser@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com" + } + } + + bot_id = pytest.bot + mp = MailProcessor(bot=bot_id) + + mp.login_imap() + + mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False) + mock_mailbox.assert_called_once_with("imap.testuser.com") + mock_mailbox_instance.login.assert_called_once_with("testuser@testuser.com", "password") + mock_llm_processor.assert_called_once_with(bot_id, mp.llm_type) + + + +@patch("kairon.shared.channels.mail.processor.LLMProcessor") +@patch("kairon.shared.channels.mail.processor.MailBox") +@patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") +def test_login_imap_logout(mock_get_channel_config, mock_mailbox, mock_llm_processor): + mock_mailbox_instance = MagicMock() + mock_mailbox.return_value = mock_mailbox_instance + mock_mailbox_instance.login.return_value = mock_mailbox_instance # Ensure login returns the instance + mock_mailbox_instance._simple_command.return_value = ("OK", ["Logged in"]) + mock_mailbox_instance.select.return_value = ("OK", ["INBOX"]) + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "testuser@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com" + } + } + + bot_id = pytest.bot + mp = MailProcessor(bot=bot_id) + + mp.login_imap() + mp.logout_imap() + + mock_mailbox_instance.logout.assert_called_once() + + +@patch("kairon.shared.channels.mail.processor.smtplib.SMTP") +@patch("kairon.shared.channels.mail.processor.LLMProcessor") +@patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") +def test_login_smtp(mock_get_channel_config, mock_llm_processor, mock_smtp): + # Arrange + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "testuser@testuser.com", + 'email_password': "password", + 'smtp_server': "smtp.testuser.com", + 'smtp_port': 587 + } + } + + bot_id = pytest.bot + mp = MailProcessor(bot=bot_id) + + mp.login_smtp() + + mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False) + mock_smtp.assert_called_once_with("smtp.testuser.com", 587) + mock_smtp_instance.starttls.assert_called_once() + mock_smtp_instance.login.assert_called_once_with("testuser@testuser.com", "password") + + +@patch("kairon.shared.channels.mail.processor.smtplib.SMTP") +@patch("kairon.shared.channels.mail.processor.LLMProcessor") +@patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") +def test_logout_smtp(mock_get_channel_config, mock_llm_processor, mock_smtp): + # Arrange + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "testuser@testuser.com", + 'email_password': "password", + 'smtp_server': "smtp.testuser.com", + 'smtp_port': 587 + } + } + + bot_id = pytest.bot + mp = MailProcessor(bot=bot_id) + + # Act + mp.login_smtp() + mp.logout_smtp() + + # Assert + mock_smtp_instance.quit.assert_called_once() + assert mp.smtp is None + +@patch("kairon.shared.channels.mail.processor.smtplib.SMTP") +@patch("kairon.shared.channels.mail.processor.LLMProcessor") +@patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") +@pytest.mark.asyncio +async def test_send_mail(mock_get_channel_config, mock_llm_processor, mock_smtp): + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "testuser@testuser.com", + 'email_password': "password", + 'smtp_server': "smtp.testuser.com", + 'smtp_port': 587 + } + } + + bot_id = pytest.bot + mp = MailProcessor(bot=bot_id) + mp.login_smtp() + + await mp.send_mail("recipient@test.com", "Test Subject", "Test Body") + + mock_smtp_instance.sendmail.assert_called_once() + assert mock_smtp_instance.sendmail.call_args[0][0] == "testuser@testuser.com" + assert mock_smtp_instance.sendmail.call_args[0][1] == "recipient@test.com" + assert "Test Subject" in mock_smtp_instance.sendmail.call_args[0][2] + assert "Test Body" in mock_smtp_instance.sendmail.call_args[0][2] + + +@patch("kairon.shared.channels.mail.processor.MailClassificationConfig") +@patch("kairon.shared.channels.mail.processor.LLMProcessor") +@patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config") +def test_process_mail( mock_get_channel_config, llm_processor, mock_mail_classification_config): + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "testuser@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com" + } + } + + bot_id = pytest.bot + mp = MailProcessor(bot=bot_id) + mp.mail_configs_dict = { + "greeting": MagicMock(reply_template="Hello {name}, {bot_response}") + } + + rasa_chat_response = { + "slots": ["name: John Doe"], + "response": [{"text": "How can I help you today?"}] + } + + result = mp.process_mail("greeting", rasa_chat_response) + + assert result == "Hello John Doe, How can I help you today?" + + rasa_chat_response = { + "slots": ["name: John Doe"], + "response": [{"text": "How can I help you today?"}] + } + mp.mail_configs_dict = {} # No template for the intent + result = mp.process_mail("greeting", rasa_chat_response) + assert result == MailConstants.DEFAULT_TEMPLATE.format(name="John Doe", bot_response="How can I help you today?") + + + +@patch("kairon.shared.channels.mail.processor.LLMProcessor") +@patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config") +@patch("kairon.shared.channels.mail.processor.BotSettings.objects") +@patch("kairon.shared.channels.mail.processor.MailClassificationConfig.objects") +@patch("kairon.shared.channels.mail.processor.Bot.objects") +@pytest.mark.asyncio +async def test_classify_messages(mock_bot_objects, mock_mail_classification_config_objects, + mock_bot_settings_objects, mock_get_channel_config, mock_llm_processor): + # Arrange + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "testuser@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com", + 'llm_type': "openai", + 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, + 'system_prompt': "Test system prompt" + } + } + + mock_bot_settings = MagicMock() + mock_bot_settings.llm_settings = {'enable_faq': True} + mock_bot_settings_objects.get.return_value = mock_bot_settings + + mock_bot = MagicMock() + mock_bot_objects.get.return_value = mock_bot + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + future = asyncio.Future() + future.set_result({"content": '[{"intent": "greeting", "entities": {"name": "John Doe"}, "mail_id": "123", "subject": "Hello"}]'}) + mock_llm_processor_instance.predict.return_value = future + + bot_id = pytest.bot + mp = MailProcessor(bot=bot_id) + + messages = [{"mail_id": "123", "subject": "Hello", "body": "Hi there"}] + + result = await mp.classify_messages(messages) + + assert result == [{"intent": "greeting", "entities": {"name": "John Doe"}, "mail_id": "123", "subject": "Hello"}] + mock_llm_processor_instance.predict.assert_called_once() + + +@patch("kairon.shared.channels.mail.processor.LLMProcessor") +def test_get_context_prompt(llm_processor): + bot_id = pytest.bot + mail_configs = [ + { + 'intent': 'greeting', + 'entities': 'name', + 'subjects': 'Hello', + 'classification_prompt': 'If the email says hello, classify it as greeting' + }, + { + 'intent': 'farewell', + 'entities': 'name', + 'subjects': 'Goodbye', + 'classification_prompt': 'If the email says goodbye, classify it as farewell' + } + ] + + mp = MailProcessor(bot=bot_id) + mp.mail_configs = mail_configs + + expected_context_prompt = ( + "intent: greeting \n" + "entities: name \n" + "\nclassification criteria: \n" + "subjects: Hello \n" + "rule: If the email says hello, classify it as greeting \n\n\n" + "intent: farewell \n" + "entities: name \n" + "\nclassification criteria: \n" + "subjects: Goodbye \n" + "rule: If the email says goodbye, classify it as farewell \n\n\n" + ) + + context_prompt = mp.get_context_prompt() + + assert context_prompt == expected_context_prompt + + +def test_extract_jsons_from_text(): + # Arrange + text = ''' + Here is some text with JSON objects. + {"key1": "value1", "key2": "value2"} + Some more text. + [{"key3": "value3"}, {"key4": "value4"}] + And some final text. + ''' + expected_output = [ + {"key1": "value1", "key2": "value2"}, + [{"key3": "value3"}, {"key4": "value4"}] + ] + + # Act + result = MailProcessor.extract_jsons_from_text(text) + + # Assert + assert result == expected_output + + +# @patch("kairon.shared.channels.mail.processor.MailProcessor.login_imap") +@patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap") +@patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task") +@patch("kairon.shared.channels.mail.processor.MailBox") +@patch("kairon.shared.channels.mail.processor.BackgroundScheduler") +@patch("kairon.shared.channels.mail.processor.LLMProcessor") +@patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") +@pytest.mark.asyncio +async def test_process_mails(mock_get_channel_config, mock_llm_processor, + mock_scheduler, mock_mailbox, mock_process_message_task, + mock_logout_imap): + # Arrange + bot_id = pytest.bot + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "testuser@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com", + 'llm_type': "openai", + 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, + } + } + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + scheduler_instance = MagicMock() + mock_scheduler.return_value = scheduler_instance + + mock_mailbox_instance = MagicMock() + mock_mailbox.return_value = mock_mailbox_instance + mock_mailbox.fetch.return_value = [ + MagicMock(subject="Test Subject", from_="test@example.com", date="2023-10-10", text="Test Body", html=None) + ] + + # Act + message_count, time_shift = await MailProcessor.process_mails(bot_id, scheduler_instance) + + # Assert + scheduler_instance.add_job.assert_called_once() + assert message_count == 1 + assert time_shift == 300 # 5 minutes in seconds \ No newline at end of file From 02391b053b1aa9c87c86d6d9caec0e5588369223 Mon Sep 17 00:00:00 2001 From: hasinaxp Date: Wed, 20 Nov 2024 21:14:34 +0530 Subject: [PATCH 02/27] mahesh_mailbox_mock_issue --- kairon/shared/channels/mail/processor.py | 1 - tests/unit_test/channels/mail_channel_test.py | 83 +++++++++++++++++-- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index d5d07e1ce..40155867f 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -200,7 +200,6 @@ async def process_mails(bot: str, scheduler : BackgroundScheduler = None): try: mp.login_imap() msgs = mp.mailbox.fetch(AND(seen=False, date_gte=last_read_timestamp.date())) - print(msgs) for msg in msgs: subject = msg.subject sender_id = msg.from_ diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py index 3605721f8..0153a1a90 100644 --- a/tests/unit_test/channels/mail_channel_test.py +++ b/tests/unit_test/channels/mail_channel_test.py @@ -563,7 +563,9 @@ def test_extract_jsons_from_text(): assert result == expected_output -# @patch("kairon.shared.channels.mail.processor.MailProcessor.login_imap") + + + @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap") @patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task") @patch("kairon.shared.channels.mail.processor.MailBox") @@ -595,9 +597,16 @@ async def test_process_mails(mock_get_channel_config, mock_llm_processor, mock_mailbox_instance = MagicMock() mock_mailbox.return_value = mock_mailbox_instance - mock_mailbox.fetch.return_value = [ - MagicMock(subject="Test Subject", from_="test@example.com", date="2023-10-10", text="Test Body", html=None) - ] + + mock_mail_message = MagicMock(spec=MailMessage) + mock_mail_message.subject = "Test Subject" + mock_mail_message.from_ = "test@example.com" + mock_mail_message.date = "2023-10-10" + mock_mail_message.text = "Test Body" + mock_mail_message.html = None + + mock_mailbox_instance.login.return_value = mock_mailbox_instance + mock_mailbox_instance.fetch.return_value = [mock_mail_message] # Act message_count, time_shift = await MailProcessor.process_mails(bot_id, scheduler_instance) @@ -605,4 +614,68 @@ async def test_process_mails(mock_get_channel_config, mock_llm_processor, # Assert scheduler_instance.add_job.assert_called_once() assert message_count == 1 - assert time_shift == 300 # 5 minutes in seconds \ No newline at end of file + assert time_shift == 300 # 5 minutes in seconds + + + +import pytest +from unittest.mock import patch, MagicMock +from kairon.shared.channels.mail.processor import MailProcessor, MailConstants +from imap_tools import MailMessage + +@patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap") +@patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task") +@patch("kairon.shared.channels.mail.processor.MailBox") +@patch("kairon.shared.channels.mail.processor.BackgroundScheduler") +@patch("kairon.shared.channels.mail.processor.LLMProcessor") +@patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") +@pytest.mark.asyncio +async def test_process_mails_no_messages(mock_get_channel_config, mock_llm_processor, + mock_scheduler, mock_mailbox, mock_process_message_task, + mock_logout_imap): + # Arrange + bot_id = pytest.bot + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "testuser@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com", + 'llm_type': "openai", + 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, + } + } + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + scheduler_instance = MagicMock() + mock_scheduler.return_value = scheduler_instance + + mock_mailbox_instance = MagicMock() + mock_mailbox.return_value = mock_mailbox_instance + + mock_mailbox_instance.login.return_value = mock_mailbox_instance + mock_mailbox_instance.fetch.return_value = [] + + # Act + message_count, time_shift = await MailProcessor.process_mails(bot_id, scheduler_instance) + + # Assert + assert message_count == 0 + assert time_shift == 300 + + mock_logout_imap.assert_called_once() + + + + + + + + + + + + + From 50fd4a1d3ff42e2def6a0ec2f0f3178173d4a129 Mon Sep 17 00:00:00 2001 From: hasinaxp Date: Thu, 21 Nov 2024 19:46:21 +0530 Subject: [PATCH 03/27] test cases --- kairon/api/app/routers/bot/bot.py | 151 +++++++----------- kairon/shared/channels/mail/scheduler.py | 7 +- system.yaml | 2 +- tests/integration_test/services_test.py | 127 +++++++++++++++ tests/testing_data/system.yaml | 2 + .../unit_test/channels/mail_scheduler_test.py | 84 ++++++++++ 6 files changed, 276 insertions(+), 97 deletions(-) create mode 100644 tests/unit_test/channels/mail_scheduler_test.py diff --git a/kairon/api/app/routers/bot/bot.py b/kairon/api/app/routers/bot/bot.py index d927b667d..d267a3b84 100644 --- a/kairon/api/app/routers/bot/bot.py +++ b/kairon/api/app/routers/bot/bot.py @@ -13,7 +13,7 @@ Response, Endpoint, RasaConfig, - BulkTrainingDataAddRequest, TrainingDataGeneratorStatusModel, StoryRequest, + StoryRequest, SynonymRequest, RegexRequest, StoryType, ComponentConfig, SlotRequest, DictData, LookupTablesRequest, Forms, TextDataLowerCase, SlotMappingRequest, EventConfig, MultiFlowStoryRequest, BotSettingsRequest @@ -32,16 +32,12 @@ from kairon.shared.content_importer.data_objects import ContentValidationLogs from kairon.shared.data.assets_processor import AssetsProcessor from kairon.shared.data.audit.processor import AuditDataProcessor -from kairon.shared.data.constant import EVENT_STATUS, ENDPOINT_TYPE, TOKEN_TYPE, ModelTestType, \ - TrainingDataSourceType, AuditlogActions -from kairon.shared.data.data_models import MailConfigRequest from kairon.shared.data.constant import ENDPOINT_TYPE, ModelTestType, \ AuditlogActions +from kairon.shared.data.data_models import MailConfigRequest from kairon.shared.data.data_objects import TrainingExamples, ModelTraining, Rules from kairon.shared.data.model_processor import ModelProcessor from kairon.shared.data.processor import MongoProcessor -from kairon.shared.data.training_data_generation_processor import TrainingDataGenerationProcessor -from kairon.shared.data.utils import DataUtility from kairon.shared.events.processor import ExecutorProcessor from kairon.shared.importer.data_objects import ValidationLogs from kairon.shared.importer.processor import DataImporterLogProcessor @@ -612,30 +608,6 @@ def upload_files( return {"message": "Upload in progress! Check logs."} -@router.post("/upload/data_generation/file", response_model=Response) -async def upload_data_generation_file( - background_tasks: BackgroundTasks, - doc: UploadFile, - current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) -): - """ - Uploads document for training data generation and triggers event for intent creation - """ - TrainingDataGenerationProcessor.is_in_progress(current_user.get_bot()) - TrainingDataGenerationProcessor.check_data_generation_limit(current_user.get_bot()) - file_path = await Utility.upload_document(doc) - TrainingDataGenerationProcessor.set_status(bot=current_user.get_bot(), - user=current_user.get_user(), status=EVENT_STATUS.INITIATED.value, - document_path=file_path) - token, _ = Authentication.generate_integration_token( - current_user.get_bot(), current_user.email, token_type=TOKEN_TYPE.DYNAMIC.value - ) - background_tasks.add_task( - DataUtility.trigger_data_generation_event, current_user.get_bot(), current_user.get_user(), token - ) - return {"message": "File uploaded successfully and training data generation has begun"} - - @router.get("/download/data") async def download_data( background_tasks: BackgroundTasks, @@ -866,68 +838,6 @@ async def get_action_server_logs(start_idx: int = 0, page_size: int = 10, return Response(data=data) -@router.post("/data/bulk", response_model=Response) -async def add_training_data( - request_data: BulkTrainingDataAddRequest, - current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) -): - """ - Adds intents, training examples and responses along with story against the responses - """ - try: - TrainingDataGenerationProcessor.validate_history_id(request_data.history_id) - status, training_data_added = mongo_processor.add_training_data( - training_data=request_data.training_data, - bot=current_user.get_bot(), - user=current_user.get_user(), - is_integration=current_user.get_integration_status() - ) - TrainingDataGenerationProcessor.update_is_persisted_flag(request_data.history_id, training_data_added) - except Exception as e: - raise AppException(e) - return {"message": "Training data added successfully!", "data": status} - - -@router.put("/update/data/generator/status", response_model=Response) -async def update_training_data_generator_status( - request_data: TrainingDataGeneratorStatusModel, - current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) -): - """ - Update training data generator status - """ - try: - TrainingDataGenerationProcessor.retrieve_response_and_set_status(request_data, current_user.get_bot(), - current_user.get_user()) - except Exception as e: - raise AppException(e) - return {"message": "Status updated successfully!"} - - -@router.get("/data/generation/history", response_model=Response) -async def get_train_data_history( - log_type: TrainingDataSourceType = TrainingDataSourceType.document.value, - current_user: User = Security(Authentication.get_current_user_and_bot, scopes=TESTER_ACCESS), -): - """ - Fetches File Data Generation history, when and who initiated the process - """ - file_history = TrainingDataGenerationProcessor.get_training_data_generator_history(current_user.get_bot(), log_type) - return {"data": {"training_history": file_history}} - - -@router.get("/data/generation/latest", response_model=Response) -async def get_latest_data_generation_status( - current_user: User = Security(Authentication.get_current_user_and_bot, scopes=TESTER_ACCESS), -): - """ - Fetches status for latest data generation request - """ - latest_data_generation_status = TrainingDataGenerationProcessor.fetch_latest_workload(current_user.get_bot(), - current_user.get_user()) - return {"data": latest_data_generation_status} - - @router.get("/slots", response_model=Response) async def get_slots( current_user: User = Security(Authentication.get_current_user_and_bot, scopes=TESTER_ACCESS), @@ -1750,3 +1660,60 @@ async def get_slot_actions( llm_models = MongoProcessor.get_slot_mapped_actions(current_user.get_bot(), slot_name) return Response(data=llm_models) + +@router.get("/mail/config", response_model=Response) +async def get_all_mail_configs( + current_user: User = Security(Authentication.get_current_user_and_bot, scopes=TESTER_ACCESS)): + """ + Fetches mail config + """ + data = MailClassificationConfig.objects(bot=current_user.get_bot(), status=True) + formatted_data = [ + {key: value for key, value in item.to_mongo().items() if key not in {"_id", "user"}} + for item in data + ] + + return {"data": formatted_data} + + + +@router.post("/mail/config", response_model=Response) +async def set_mail_config( + request_data: MailConfigRequest, + current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) +): + """ + Applies the mail config + """ + request_dict = request_data.dict() + MailClassificationConfig.create_doc(**request_dict, bot=current_user.get_bot(), user=current_user.get_user()) + return {"message": "Config applied!"} + + +@router.put("/mail/config", response_model=Response) +async def update_mail_config( + request_data: MailConfigRequest, + current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) +): + """ + update the mail config + """ + request_dict = request_data.dict() + MailClassificationConfig.update_doc(**request_dict, bot=current_user.get_bot()) + return {"message": "Config updated!"} + + + +@router.delete("/mail/config/{intent}", response_model=Response) +async def del_soft_mail_config( + intent: str, + current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) +): + """ + delete the mail config + """ + MailClassificationConfig.soft_delete_doc(current_user.get_bot(), intent) + return {"message": "Config deleted!"} + + + diff --git a/kairon/shared/channels/mail/scheduler.py b/kairon/shared/channels/mail/scheduler.py index 971b0338b..5e16cbcf5 100644 --- a/kairon/shared/channels/mail/scheduler.py +++ b/kairon/shared/channels/mail/scheduler.py @@ -30,7 +30,7 @@ def epoch(): job_defaults={'coalesce': True, 'misfire_grace_time': 7200}) bots = Channels.objects(connector_type= ChannelTypes.MAIL) - bots = set(list(bots.values_list('bot'))) + bots = set(bot['bot'] for bot in bots.values_list('bot')) unscheduled_bots = bots - MailScheduler.scheduled_bots @@ -42,11 +42,10 @@ def epoch(): MailScheduler.scheduled_bots.add(bot) MailScheduler.scheduled_bots = MailScheduler.scheduled_bots.intersection(bots) - - - if is_initialized: MailScheduler.scheduler.start() + return True + return False @staticmethod def process_mails_task(bot, scheduler: BackgroundScheduler = None): diff --git a/system.yaml b/system.yaml index cb030254f..f4e7e4a60 100644 --- a/system.yaml +++ b/system.yaml @@ -120,7 +120,7 @@ notifications: events: server_url: ${EVENT_SERVER_ENDPOINT:"http://localhost:5056"} executor: - type: ${EVENTS_EXECUTOR_TYPE:"standalone"} + type: ${EVENTS_EXECUTOR_TYPE} region: ${EVENTS_EXECUTOR_REGION} timeout: ${EVENTS_EXECUTOR_TIMEOUT_MINUTES:60} queue: diff --git a/tests/integration_test/services_test.py b/tests/integration_test/services_test.py index f9be29a12..d4af7f20e 100644 --- a/tests/integration_test/services_test.py +++ b/tests/integration_test/services_test.py @@ -4406,6 +4406,129 @@ def test_get_live_agent_after_disabled(): assert actual["success"] + + +def test_add_mail_config(): + data = { + "intent": "greet", + "entities": ["name", "subject", "summery"], + "classification_prompt": "any personal mail of greeting" + } + + response = client.post( + f"/api/bot/{pytest.bot}/mail/config", + json=data, + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + actual = response.json() + print(actual) + assert actual["success"] + assert actual['message'] == 'Config applied!' + assert actual['error_code'] == 0 + +def test_add_mail_config_missing_field(): + data = { + "entities": ["name", "subject", "summery"], + } + + response = client.post( + f"/api/bot/{pytest.bot}/mail/config", + json=data, + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + actual = response.json() + print(actual) + assert not actual["success"] + assert len(actual['message']) + assert actual['error_code'] == 422 + +def test_add_mail_config_same_intent(): + data = { + "intent": "greet", + "entities": ["name", "subject", "summery"], + "classification_prompt": "any personal mail of greeting" + } + + response = client.post( + f"/api/bot/{pytest.bot}/mail/config", + json=data, + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + actual = response.json() + print(actual) + assert not actual["success"] + assert actual['message'] == 'Mail configuration already exists for intent [greet]' + assert actual['error_code'] == 422 + + +def test_get_mail_config(): + + response = client.get( + f"/api/bot/{pytest.bot}/mail/config", + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + actual = response.json() + print(actual) + assert actual["success"] + assert len(actual['data']) == 1 + assert actual['data'][0]['intent'] == 'greet' + assert actual['error_code'] == 0 + + + +def test_update_mail_config(): + data = { + "intent": "greet", + "entities": ["name", "subject", "summery"], + "classification_prompt": "any personal email of greeting" + } + + response = client.put( + f"/api/bot/{pytest.bot}/mail/config", + json=data, + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + actual = response.json() + print(actual) + assert actual["success"] + assert actual['message'] == 'Config updated!' + assert actual['error_code'] == 0 + + response = client.get( + f"/api/bot/{pytest.bot}/mail/config", + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + actual = response.json() + print(actual) + assert actual["success"] + assert len(actual['data']) == 1 + assert actual['data'][0]['intent'] == 'greet' + assert actual['data'][0]['classification_prompt'] == 'any personal email of greeting' + assert actual['error_code'] == 0 + + +def test_delete_mail_config(): + response = client.delete( + f"/api/bot/{pytest.bot}/mail/config/greet", + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + + actual = response.json() + assert actual['success'] + assert actual['message'] == 'Config deleted!' + + response = client.get( + f"/api/bot/{pytest.bot}/mail/config", + headers={"Authorization": pytest.token_type + " " + pytest.access_token}, + ) + actual = response.json() + print(actual) + assert actual["success"] + assert len(actual['data']) == 0 + + + + def test_callback_config_add_syntax_error(): request_body = { "name": "callback_1", @@ -4746,6 +4869,7 @@ def test_callback_action_delete(): assert actual == {'success': True, 'message': 'Callback action deleted successfully!', 'data': None, 'error_code': 0} + def test_add_pyscript_action_empty_name(): script = "bot_response='hello world'" request_body = { @@ -23862,6 +23986,9 @@ def test_add_channel_config_error(): ) + + + def test_add_bot_with_template_name(monkeypatch): from kairon.shared.admin.data_objects import BotSecrets diff --git a/tests/testing_data/system.yaml b/tests/testing_data/system.yaml index 25ad50573..5baaf8daf 100644 --- a/tests/testing_data/system.yaml +++ b/tests/testing_data/system.yaml @@ -121,6 +121,7 @@ events: type: ${EVENTS_QUEUE_TYPE:"mongo"} url: ${EVENTS_QUEUE_URL:"mongodb://localhost:27017/events"} name: ${EVENTS_DB_NAME:"kairon_events"} + mail_queue_name: ${EVENTS_MAIL_QUEUE_NAME:"mail_queue"} task_definition: model_training: ${MODEL_TRAINING_TASK_DEFINITION} model_testing: ${MODEL_TESTING_TASK_DEFINITION} @@ -140,6 +141,7 @@ events: - bot scheduler: collection: ${EVENT_SCHEDULER_COLLECTION:"kscheduler"} + mail_scheduler_collection: ${MAIL_SCHEDULER_COLLECTION:"mail_scheduler"} type: ${EVENT_SCHEDULER_TYPE:"kscheduler"} min_trigger_interval: ${MIN_SCHDULER_TRIGGER_INTERVAL:86340} diff --git a/tests/unit_test/channels/mail_scheduler_test.py b/tests/unit_test/channels/mail_scheduler_test.py new file mode 100644 index 000000000..f5504d2f6 --- /dev/null +++ b/tests/unit_test/channels/mail_scheduler_test.py @@ -0,0 +1,84 @@ +import pytest +from unittest.mock import patch, MagicMock, AsyncMock +import os +from kairon import Utility +os.environ["system_file"] = "./tests/testing_data/system.yaml" +Utility.load_environment() +Utility.load_system_metadata() + +from kairon.shared.channels.mail.scheduler import MailScheduler + +@pytest.fixture +def setup_environment(): + with patch("pymongo.MongoClient") as mock_client, \ + patch("kairon.shared.chat.data_objects.Channels.objects") as mock_channels, \ + patch("kairon.shared.channels.mail.processor.MailProcessor.process_mails", new_callable=AsyncMock) as mock_process_mails, \ + patch("apscheduler.schedulers.background.BackgroundScheduler", autospec=True) as mock_scheduler: + + mock_client_instance = mock_client.return_value + mock_channels.return_value = MagicMock(values_list=MagicMock(return_value=[{'bot': 'test_bot_1'}, {'bot': 'test_bot_2'}])) + mock_process_mails.return_value = ([], 60) # Mock responses and next_delay + mock_scheduler_instance = mock_scheduler.return_value + yield { + 'mock_client': mock_client_instance, + 'mock_channels': mock_channels, + 'mock_process_mails': mock_process_mails, + 'mock_scheduler': mock_scheduler_instance + } + +@pytest.mark.asyncio +async def test_mail_scheduler_epoch(setup_environment): + # Arrange + mock_scheduler = setup_environment['mock_scheduler'] + MailScheduler.mail_queue_name = "test_queue" + MailScheduler.scheduler = mock_scheduler + + # Act + MailScheduler.epoch() + + # Assert + mock_scheduler.add_job.assert_called() + +@pytest.mark.asyncio +async def test_mail_scheduler_process_mails(setup_environment): + mock_process_mails = setup_environment['mock_process_mails'] + mock_scheduler = setup_environment['mock_scheduler'] + MailScheduler.scheduled_bots.add("test_bot_1") + MailScheduler.scheduler = mock_scheduler + + await MailScheduler.process_mails("test_bot_1", mock_scheduler) + + mock_process_mails.assert_awaited_once_with("test_bot_1", mock_scheduler) + assert "test_bot_1" in MailScheduler.scheduled_bots + + +@pytest.fixture +def setup_environment2(): + with patch("pymongo.MongoClient") as mock_client, \ + patch("kairon.shared.chat.data_objects.Channels.objects") as mock_channels, \ + patch("kairon.shared.channels.mail.processor.MailProcessor.process_mails", new_callable=AsyncMock) as mock_process_mails, \ + patch("apscheduler.jobstores.mongodb.MongoDBJobStore.__init__", return_value=None) as mock_jobstore_init: + + mock_client_instance = mock_client.return_value + mock_channels.return_value = MagicMock(values_list=MagicMock(return_value=[{'bot': 'test_bot_1'}, {'bot': 'test_bot_2'}])) + mock_process_mails.return_value = ([], 60) + + yield { + 'mock_client': mock_client_instance, + 'mock_channels': mock_channels, + 'mock_process_mails': mock_process_mails, + 'mock_jobstore_init': mock_jobstore_init, + } + + +@pytest.mark.asyncio +async def test_mail_scheduler_epoch_creates_scheduler(setup_environment2): + with patch("apscheduler.schedulers.background.BackgroundScheduler.start", autospec=True) as mock_start, \ + patch("apscheduler.schedulers.background.BackgroundScheduler.add_job", autospec=True) as mock_add_job: + MailScheduler.scheduler = None + + started = MailScheduler.epoch() + + assert started + assert MailScheduler.scheduler is not None + mock_start.assert_called_once() From fb4c4f6ea40e2961b515d0205936b41dcb3d7a41 Mon Sep 17 00:00:00 2001 From: hasinaxp Date: Thu, 21 Nov 2024 21:22:15 +0530 Subject: [PATCH 04/27] test cases --- kairon/shared/channels/mail/data_objects.py | 4 +- kairon/shared/channels/mail/processor.py | 5 ++ kairon/shared/channels/mail/scheduler.py | 2 +- tests/unit_test/channels/mail_channel_test.py | 66 +++++++++++-------- .../data_processor/agent_processor_test.py | 7 ++ 5 files changed, 53 insertions(+), 31 deletions(-) diff --git a/kairon/shared/channels/mail/data_objects.py b/kairon/shared/channels/mail/data_objects.py index bccec5466..513c95c43 100644 --- a/kairon/shared/channels/mail/data_objects.py +++ b/kairon/shared/channels/mail/data_objects.py @@ -1,7 +1,5 @@ import time -from mongoengine import Document, StringField, DictField, ListField, FloatField, BooleanField - -from kairon import Utility +from mongoengine import Document, StringField, ListField, FloatField, BooleanField from kairon.exceptions import AppException diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index 40155867f..52e728c4c 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -197,8 +197,10 @@ async def process_mails(bot: str, scheduler : BackgroundScheduler = None): time_shift = int(mp.config.get('interval', 5 * 60)) last_read_timestamp = datetime.now() - timedelta(seconds=time_shift) messages = [] + is_logged_in = False try: mp.login_imap() + is_logged_in = True msgs = mp.mailbox.fetch(AND(seen=False, date_gte=last_read_timestamp.date())) for msg in msgs: subject = msg.subject @@ -214,6 +216,7 @@ async def process_mails(bot: str, scheduler : BackgroundScheduler = None): } messages.append(message_entry) mp.logout_imap() + is_logged_in = False if not messages or len(messages) == 0: return 0, time_shift @@ -225,6 +228,8 @@ async def process_mails(bot: str, scheduler : BackgroundScheduler = None): return len(messages), time_shift except Exception as e: logger.exception(e) + if is_logged_in: + mp.logout_imap() return 0, time_shift @staticmethod diff --git a/kairon/shared/channels/mail/scheduler.py b/kairon/shared/channels/mail/scheduler.py index 5e16cbcf5..44dce7f70 100644 --- a/kairon/shared/channels/mail/scheduler.py +++ b/kairon/shared/channels/mail/scheduler.py @@ -58,7 +58,7 @@ async def process_mails(bot, scheduler: BackgroundScheduler = None): if bot not in MailScheduler.scheduled_bots: return logger.info(f"MailScheduler: Processing mails for bot {bot}") - responses, next_delay = await MailProcessor.process_mails(bot, scheduler) + _, next_delay = await MailProcessor.process_mails(bot, scheduler) logger.info(f"next_delay: {next_delay}") next_timestamp = datetime.now() + timedelta(seconds=next_delay) MailScheduler.scheduler.add_job(MailScheduler.process_mails_task, 'date', args=[bot, scheduler], run_date=next_timestamp) diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py index 0153a1a90..48695b485 100644 --- a/tests/unit_test/channels/mail_channel_test.py +++ b/tests/unit_test/channels/mail_channel_test.py @@ -65,17 +65,23 @@ def init_bot_model(bot): @pytest.fixture(autouse=True, scope='class') def setup(): connect(**Utility.mongoengine_connection(Utility.environment['database']["url"])) - a = Account.objects.create(name="test_user", user="test_user") - bot = Bot.objects.create(name="test_bot", user="test_user", status=True, account=a.id) + + Account.objects(user="mail_channel_test_user").delete() + Bot.objects(user="mail_channel_test_user").delete() + BotSettings.objects(user="mail_channel_test_user").delete() + MailClassificationConfig.objects.delete() + + a = Account.objects.create(name="mail_channel_test_user", user="mail_channel_test_user") + bot = Bot.objects.create(name="mail_channel_test_bot", user="mail_channel_test_user", status=True, account=a.id) pytest.bot = str(bot.id) - b = BotSettings.objects.create(bot=pytest.bot, user="test_user") + b = BotSettings.objects.create(bot=pytest.bot, user="mail_channel_test_user") b.llm_settings.enable_faq = True b.save() ChatDataProcessor.save_channel_config( { "connector_type": ChannelTypes.MAIL.value, "config": { - 'email_account': "testuser@testuser.com", + 'email_account': "mail_channel_test_user@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com", 'smtp_server': "smtp.testuser.com", @@ -83,9 +89,15 @@ def setup(): } }, pytest.bot, - user="test_user", + user="mail_channel_test_user", ) yield + + MailClassificationConfig.objects.delete() + BotSettings.objects(user="mail_channel_test_user").delete() + Bot.objects(user="mail_channel_test_user").delete() + Account.objects(user="mail_channel_test_user").delete() + disconnect() if len(model_path) > 0: shutil.rmtree(model_path) @@ -101,7 +113,7 @@ def test_create_doc_new_entry(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="test_user" + user="mail_channel_test_user" ) assert doc.intent == "greeting" assert doc.bot == pytest.bot @@ -118,7 +130,7 @@ def test_create_doc_existing_active_entry(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="test_user" + user="mail_channel_test_user" ) with pytest.raises(AppException, match=r"Mail configuration already exists for intent \[greeting\]"): MailClassificationConfig.create_doc( @@ -128,7 +140,7 @@ def test_create_doc_existing_active_entry(): classification_prompt="Another greeting.", reply_template="Hello!", bot=pytest.bot, - user="test_user" + user="mail_channel_test_user" ) MailClassificationConfig.objects.delete() @@ -142,7 +154,7 @@ def test_get_docs(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="test_user" + user="mail_channel_test_user" ) MailClassificationConfig.create_doc( intent="goodbye", @@ -151,7 +163,7 @@ def test_get_docs(): classification_prompt="Classify this email as a goodbye.", reply_template="Goodbye!", bot=pytest.bot, - user="test_user" + user="mail_channel_test_user" ) docs = MailClassificationConfig.get_docs(bot=pytest.bot) assert len(docs) == 2 @@ -169,7 +181,7 @@ def test_get_doc(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="test_user" + user="mail_channel_test_user" ) doc = MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") assert doc["intent"] == "greeting" @@ -194,7 +206,7 @@ def test_delete_doc(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="test_user" + user="mail_channel_test_user" ) MailClassificationConfig.delete_doc(bot=pytest.bot, intent="greeting") with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): @@ -211,7 +223,7 @@ def test_soft_delete_doc(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="test_user" + user="mail_channel_test_user" ) MailClassificationConfig.soft_delete_doc(bot=pytest.bot, intent="greeting") with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): @@ -229,7 +241,7 @@ def test_update_doc(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="test_user" + user="mail_channel_test_user" ) MailClassificationConfig.update_doc( bot=pytest.bot, @@ -251,7 +263,7 @@ def test_update_doc_invalid_key(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="test_user" + user="mail_channel_test_user" ) with pytest.raises(AppException, match=r"Invalid key \[invalid_key\] provided for updating mail config"): MailClassificationConfig.update_doc( @@ -278,7 +290,7 @@ def test_login_imap(mock_get_channel_config, mock_mailbox, mock_llm_processor): mock_get_channel_config.return_value = { 'config': { - 'email_account': "testuser@testuser.com", + 'email_account': "mail_channel_test_user@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com" } @@ -291,7 +303,7 @@ def test_login_imap(mock_get_channel_config, mock_mailbox, mock_llm_processor): mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False) mock_mailbox.assert_called_once_with("imap.testuser.com") - mock_mailbox_instance.login.assert_called_once_with("testuser@testuser.com", "password") + mock_mailbox_instance.login.assert_called_once_with("mail_channel_test_user@testuser.com", "password") mock_llm_processor.assert_called_once_with(bot_id, mp.llm_type) @@ -311,7 +323,7 @@ def test_login_imap_logout(mock_get_channel_config, mock_mailbox, mock_llm_proce mock_get_channel_config.return_value = { 'config': { - 'email_account': "testuser@testuser.com", + 'email_account': "mail_channel_test_user@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com" } @@ -339,7 +351,7 @@ def test_login_smtp(mock_get_channel_config, mock_llm_processor, mock_smtp): mock_get_channel_config.return_value = { 'config': { - 'email_account': "testuser@testuser.com", + 'email_account': "mail_channel_test_user@testuser.com", 'email_password': "password", 'smtp_server': "smtp.testuser.com", 'smtp_port': 587 @@ -354,7 +366,7 @@ def test_login_smtp(mock_get_channel_config, mock_llm_processor, mock_smtp): mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False) mock_smtp.assert_called_once_with("smtp.testuser.com", 587) mock_smtp_instance.starttls.assert_called_once() - mock_smtp_instance.login.assert_called_once_with("testuser@testuser.com", "password") + mock_smtp_instance.login.assert_called_once_with("mail_channel_test_user@testuser.com", "password") @patch("kairon.shared.channels.mail.processor.smtplib.SMTP") @@ -370,7 +382,7 @@ def test_logout_smtp(mock_get_channel_config, mock_llm_processor, mock_smtp): mock_get_channel_config.return_value = { 'config': { - 'email_account': "testuser@testuser.com", + 'email_account': "mail_channel_test_user@testuser.com", 'email_password': "password", 'smtp_server': "smtp.testuser.com", 'smtp_port': 587 @@ -401,7 +413,7 @@ async def test_send_mail(mock_get_channel_config, mock_llm_processor, mock_smtp) mock_get_channel_config.return_value = { 'config': { - 'email_account': "testuser@testuser.com", + 'email_account': "mail_channel_test_user@testuser.com", 'email_password': "password", 'smtp_server': "smtp.testuser.com", 'smtp_port': 587 @@ -415,7 +427,7 @@ async def test_send_mail(mock_get_channel_config, mock_llm_processor, mock_smtp) await mp.send_mail("recipient@test.com", "Test Subject", "Test Body") mock_smtp_instance.sendmail.assert_called_once() - assert mock_smtp_instance.sendmail.call_args[0][0] == "testuser@testuser.com" + assert mock_smtp_instance.sendmail.call_args[0][0] == "mail_channel_test_user@testuser.com" assert mock_smtp_instance.sendmail.call_args[0][1] == "recipient@test.com" assert "Test Subject" in mock_smtp_instance.sendmail.call_args[0][2] assert "Test Body" in mock_smtp_instance.sendmail.call_args[0][2] @@ -427,7 +439,7 @@ async def test_send_mail(mock_get_channel_config, mock_llm_processor, mock_smtp) def test_process_mail( mock_get_channel_config, llm_processor, mock_mail_classification_config): mock_get_channel_config.return_value = { 'config': { - 'email_account': "testuser@testuser.com", + 'email_account': "mail_channel_test_user@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com" } @@ -469,7 +481,7 @@ async def test_classify_messages(mock_bot_objects, mock_mail_classification_conf # Arrange mock_get_channel_config.return_value = { 'config': { - 'email_account': "testuser@testuser.com", + 'email_account': "mail_channel_test_user@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com", 'llm_type': "openai", @@ -581,7 +593,7 @@ async def test_process_mails(mock_get_channel_config, mock_llm_processor, mock_get_channel_config.return_value = { 'config': { - 'email_account': "testuser@testuser.com", + 'email_account': "mail_channel_test_user@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com", 'llm_type': "openai", @@ -638,7 +650,7 @@ async def test_process_mails_no_messages(mock_get_channel_config, mock_llm_proce mock_get_channel_config.return_value = { 'config': { - 'email_account': "testuser@testuser.com", + 'email_account': "mail_channel_test_user@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com", 'llm_type': "openai", diff --git a/tests/unit_test/data_processor/agent_processor_test.py b/tests/unit_test/data_processor/agent_processor_test.py index 8d37b1541..7d53cb630 100644 --- a/tests/unit_test/data_processor/agent_processor_test.py +++ b/tests/unit_test/data_processor/agent_processor_test.py @@ -135,6 +135,13 @@ def test_get_agent(self): assert len(list(ModelProcessor.get_training_history(pytest.bot))) == 1 assert not Utility.check_empty_string(model.model_ver) + def test_get_agent_no_cache(self): + model = AgentProcessor.get_agent_without_cache(pytest.bot, False) + assert model + assert len(list(ModelProcessor.get_training_history(pytest.bot))) == 1 + assert not Utility.check_empty_string(model.model_ver) + + def test_get_agent_not_cached(self): assert AgentProcessor.get_agent(pytest.bot) From 19d3c88299f4051ebf4c014ee2b299f5413dbc5e Mon Sep 17 00:00:00 2001 From: hasinaxp Date: Fri, 22 Nov 2024 13:27:46 +0530 Subject: [PATCH 05/27] test cases --- tests/unit_test/channels/mail_channel_test.py | 73 ++++++++++--------- tests/unit_test/utility_test.py | 3 +- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py index 48695b485..4f096dc1f 100644 --- a/tests/unit_test/channels/mail_channel_test.py +++ b/tests/unit_test/channels/mail_channel_test.py @@ -62,26 +62,29 @@ def init_bot_model(bot): pytest_bot_trained = True return bot -@pytest.fixture(autouse=True, scope='class') + + +@pytest.fixture(autouse=True, scope='function') def setup(): connect(**Utility.mongoengine_connection(Utility.environment['database']["url"])) - Account.objects(user="mail_channel_test_user").delete() - Bot.objects(user="mail_channel_test_user").delete() - BotSettings.objects(user="mail_channel_test_user").delete() + # Clear collections before running tests + Account.objects.delete() + Bot.objects.delete() + BotSettings.objects.delete() MailClassificationConfig.objects.delete() - a = Account.objects.create(name="mail_channel_test_user", user="mail_channel_test_user") - bot = Bot.objects.create(name="mail_channel_test_bot", user="mail_channel_test_user", status=True, account=a.id) + a = Account.objects.create(name="mail_channel_test_user_acc", user="mail_channel_test_user_acc") + bot = Bot.objects.create(name="mail_channel_test_bot", user="mail_channel_test_user_acc", status=True, account=a.id) pytest.bot = str(bot.id) - b = BotSettings.objects.create(bot=pytest.bot, user="mail_channel_test_user") + b = BotSettings.objects.create(bot=pytest.bot, user="mail_channel_test_user_acc") b.llm_settings.enable_faq = True b.save() ChatDataProcessor.save_channel_config( { "connector_type": ChannelTypes.MAIL.value, "config": { - 'email_account': "mail_channel_test_user@testuser.com", + 'email_account': "mail_channel_test_user_acc@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com", 'smtp_server': "smtp.testuser.com", @@ -89,21 +92,21 @@ def setup(): } }, pytest.bot, - user="mail_channel_test_user", + user="mail_channel_test_user_acc", ) yield + # Clear collections after running tests MailClassificationConfig.objects.delete() - BotSettings.objects(user="mail_channel_test_user").delete() - Bot.objects(user="mail_channel_test_user").delete() - Account.objects(user="mail_channel_test_user").delete() + BotSettings.objects(user="mail_channel_test_user_acc").delete() + Bot.objects(user="mail_channel_test_user_acc").delete() + Account.objects(user="mail_channel_test_user_acc").delete() disconnect() if len(model_path) > 0: shutil.rmtree(model_path) - def test_create_doc_new_entry(): print(pytest.bot) doc = MailClassificationConfig.create_doc( @@ -113,7 +116,7 @@ def test_create_doc_new_entry(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="mail_channel_test_user" + user="mail_channel_test_user_acc" ) assert doc.intent == "greeting" assert doc.bot == pytest.bot @@ -130,7 +133,7 @@ def test_create_doc_existing_active_entry(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="mail_channel_test_user" + user="mail_channel_test_user_acc" ) with pytest.raises(AppException, match=r"Mail configuration already exists for intent \[greeting\]"): MailClassificationConfig.create_doc( @@ -140,7 +143,7 @@ def test_create_doc_existing_active_entry(): classification_prompt="Another greeting.", reply_template="Hello!", bot=pytest.bot, - user="mail_channel_test_user" + user="mail_channel_test_user_acc" ) MailClassificationConfig.objects.delete() @@ -154,7 +157,7 @@ def test_get_docs(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="mail_channel_test_user" + user="mail_channel_test_user_acc" ) MailClassificationConfig.create_doc( intent="goodbye", @@ -163,7 +166,7 @@ def test_get_docs(): classification_prompt="Classify this email as a goodbye.", reply_template="Goodbye!", bot=pytest.bot, - user="mail_channel_test_user" + user="mail_channel_test_user_acc" ) docs = MailClassificationConfig.get_docs(bot=pytest.bot) assert len(docs) == 2 @@ -181,7 +184,7 @@ def test_get_doc(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="mail_channel_test_user" + user="mail_channel_test_user_acc" ) doc = MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") assert doc["intent"] == "greeting" @@ -206,7 +209,7 @@ def test_delete_doc(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="mail_channel_test_user" + user="mail_channel_test_user_acc" ) MailClassificationConfig.delete_doc(bot=pytest.bot, intent="greeting") with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): @@ -223,7 +226,7 @@ def test_soft_delete_doc(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="mail_channel_test_user" + user="mail_channel_test_user_acc" ) MailClassificationConfig.soft_delete_doc(bot=pytest.bot, intent="greeting") with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): @@ -241,7 +244,7 @@ def test_update_doc(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="mail_channel_test_user" + user="mail_channel_test_user_acc" ) MailClassificationConfig.update_doc( bot=pytest.bot, @@ -263,7 +266,7 @@ def test_update_doc_invalid_key(): classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", bot=pytest.bot, - user="mail_channel_test_user" + user="mail_channel_test_user_acc" ) with pytest.raises(AppException, match=r"Invalid key \[invalid_key\] provided for updating mail config"): MailClassificationConfig.update_doc( @@ -290,7 +293,7 @@ def test_login_imap(mock_get_channel_config, mock_mailbox, mock_llm_processor): mock_get_channel_config.return_value = { 'config': { - 'email_account': "mail_channel_test_user@testuser.com", + 'email_account': "mail_channel_test_user_acc@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com" } @@ -303,7 +306,7 @@ def test_login_imap(mock_get_channel_config, mock_mailbox, mock_llm_processor): mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False) mock_mailbox.assert_called_once_with("imap.testuser.com") - mock_mailbox_instance.login.assert_called_once_with("mail_channel_test_user@testuser.com", "password") + mock_mailbox_instance.login.assert_called_once_with("mail_channel_test_user_acc@testuser.com", "password") mock_llm_processor.assert_called_once_with(bot_id, mp.llm_type) @@ -323,7 +326,7 @@ def test_login_imap_logout(mock_get_channel_config, mock_mailbox, mock_llm_proce mock_get_channel_config.return_value = { 'config': { - 'email_account': "mail_channel_test_user@testuser.com", + 'email_account': "mail_channel_test_user_acc@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com" } @@ -351,7 +354,7 @@ def test_login_smtp(mock_get_channel_config, mock_llm_processor, mock_smtp): mock_get_channel_config.return_value = { 'config': { - 'email_account': "mail_channel_test_user@testuser.com", + 'email_account': "mail_channel_test_user_acc@testuser.com", 'email_password': "password", 'smtp_server': "smtp.testuser.com", 'smtp_port': 587 @@ -366,7 +369,7 @@ def test_login_smtp(mock_get_channel_config, mock_llm_processor, mock_smtp): mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False) mock_smtp.assert_called_once_with("smtp.testuser.com", 587) mock_smtp_instance.starttls.assert_called_once() - mock_smtp_instance.login.assert_called_once_with("mail_channel_test_user@testuser.com", "password") + mock_smtp_instance.login.assert_called_once_with("mail_channel_test_user_acc@testuser.com", "password") @patch("kairon.shared.channels.mail.processor.smtplib.SMTP") @@ -382,7 +385,7 @@ def test_logout_smtp(mock_get_channel_config, mock_llm_processor, mock_smtp): mock_get_channel_config.return_value = { 'config': { - 'email_account': "mail_channel_test_user@testuser.com", + 'email_account': "mail_channel_test_user_acc@testuser.com", 'email_password': "password", 'smtp_server': "smtp.testuser.com", 'smtp_port': 587 @@ -413,7 +416,7 @@ async def test_send_mail(mock_get_channel_config, mock_llm_processor, mock_smtp) mock_get_channel_config.return_value = { 'config': { - 'email_account': "mail_channel_test_user@testuser.com", + 'email_account': "mail_channel_test_user_acc@testuser.com", 'email_password': "password", 'smtp_server': "smtp.testuser.com", 'smtp_port': 587 @@ -427,7 +430,7 @@ async def test_send_mail(mock_get_channel_config, mock_llm_processor, mock_smtp) await mp.send_mail("recipient@test.com", "Test Subject", "Test Body") mock_smtp_instance.sendmail.assert_called_once() - assert mock_smtp_instance.sendmail.call_args[0][0] == "mail_channel_test_user@testuser.com" + assert mock_smtp_instance.sendmail.call_args[0][0] == "mail_channel_test_user_acc@testuser.com" assert mock_smtp_instance.sendmail.call_args[0][1] == "recipient@test.com" assert "Test Subject" in mock_smtp_instance.sendmail.call_args[0][2] assert "Test Body" in mock_smtp_instance.sendmail.call_args[0][2] @@ -439,7 +442,7 @@ async def test_send_mail(mock_get_channel_config, mock_llm_processor, mock_smtp) def test_process_mail( mock_get_channel_config, llm_processor, mock_mail_classification_config): mock_get_channel_config.return_value = { 'config': { - 'email_account': "mail_channel_test_user@testuser.com", + 'email_account': "mail_channel_test_user_acc@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com" } @@ -481,7 +484,7 @@ async def test_classify_messages(mock_bot_objects, mock_mail_classification_conf # Arrange mock_get_channel_config.return_value = { 'config': { - 'email_account': "mail_channel_test_user@testuser.com", + 'email_account': "mail_channel_test_user_acc@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com", 'llm_type': "openai", @@ -593,7 +596,7 @@ async def test_process_mails(mock_get_channel_config, mock_llm_processor, mock_get_channel_config.return_value = { 'config': { - 'email_account': "mail_channel_test_user@testuser.com", + 'email_account': "mail_channel_test_user_acc@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com", 'llm_type': "openai", @@ -650,7 +653,7 @@ async def test_process_mails_no_messages(mock_get_channel_config, mock_llm_proce mock_get_channel_config.return_value = { 'config': { - 'email_account': "mail_channel_test_user@testuser.com", + 'email_account': "mail_channel_test_user_acc@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com", 'llm_type': "openai", diff --git a/tests/unit_test/utility_test.py b/tests/unit_test/utility_test.py index 7b7bf4c47..5427328aa 100644 --- a/tests/unit_test/utility_test.py +++ b/tests/unit_test/utility_test.py @@ -2848,7 +2848,8 @@ def test_get_channels(self): "messenger", "instagram", "whatsapp", - "line" + "line", + "mail" ] channels = Utility.get_channels() assert channels == expected_channels From 0b3ec480f31665c5d9fa01572da0daa6d9729e16 Mon Sep 17 00:00:00 2001 From: hasinaxp Date: Mon, 25 Nov 2024 10:10:58 +0530 Subject: [PATCH 06/27] test cases --- tests/unit_test/channels/mail_channel_test.py | 1136 +++++++++-------- 1 file changed, 571 insertions(+), 565 deletions(-) diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py index 4f096dc1f..8b625dd4e 100644 --- a/tests/unit_test/channels/mail_channel_test.py +++ b/tests/unit_test/channels/mail_channel_test.py @@ -23,7 +23,6 @@ from kairon.exceptions import AppException from kairon.shared.channels.mail.data_objects import MailClassificationConfig from unittest.mock import patch, MagicMock -from datetime import datetime, timedelta from kairon.exceptions import AppException from kairon.shared.channels.mail.processor import MailProcessor from kairon.shared.constants import ChannelTypes @@ -35,652 +34,659 @@ pytest_bot_trained = False model_path = "" +# +# def init_bot_model(bot): +# global pytest_bot_trained +# if pytest_bot_trained: +# return +# from rasa import train +# model_path = os.path.join('models', bot) +# if not os.path.exists(model_path): +# os.mkdir(model_path) +# model_file = train( +# domain='tests/testing_data/model_tester/domain.yml', +# config='tests/testing_data/model_tester/config.yml', +# training_files=['tests/testing_data/model_tester/nlu_with_entities/nlu.yml', +# 'tests/testing_data/model_tester/training_stories_success/stories.yml'], +# output=model_path, +# core_additional_arguments={"augmentation_factor": 100}, +# force_training=True +# ).model +# ModelProcessor.set_training_status( +# bot=bot, +# user="test", +# status=EVENT_STATUS.DONE.value, +# model_path=model_file, +# ) +# pytest_bot_trained = True +# return bot + + +class TestMailChannel: + @pytest.fixture(autouse=True, scope='class') + def setup(self): + connect(**Utility.mongoengine_connection(Utility.environment['database']["url"])) + + # Clear collections before running tests + # Account.objects.delete() + # Bot.objects.delete() + # BotSettings.objects.delete() + # MailClassificationConfig.objects.delete() + + + yield + + # Clear collections after running tests + # MailClassificationConfig.objects.delete() + # BotSettings.objects(user="mail_channel_test_user_acc").delete() + # Bot.objects(user="mail_channel_test_user_acc").delete() + # Account.objects(user="mail_channel_test_user_acc").delete() + + disconnect() + # if len(model_path) > 0: + # shutil.rmtree(model_path) + + + def create_basic_data(self): + a = Account.objects.create(name="mail_channel_test_user_acc", user="mail_channel_test_user_acc") + bot = Bot.objects.create(name="mail_channel_test_bot", user="mail_channel_test_user_acc", status=True, account=a.id) + print(bot) + pytest.bot = str(bot.id) + b = BotSettings.objects.create(bot=pytest.bot, user="mail_channel_test_user_acc") + b.llm_settings.enable_faq = True + b.save() + ChatDataProcessor.save_channel_config( + { + "connector_type": ChannelTypes.MAIL.value, + "config": { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com", + 'smtp_server': "smtp.testuser.com", + 'smtp_port': "587", + } + }, + pytest.bot, + user="mail_channel_test_user_acc", + ) -def init_bot_model(bot): - global pytest_bot_trained - if pytest_bot_trained: - return - from rasa import train - model_path = os.path.join('models', bot) - if not os.path.exists(model_path): - os.mkdir(model_path) - model_file = train( - domain='tests/testing_data/model_tester/domain.yml', - config='tests/testing_data/model_tester/config.yml', - training_files=['tests/testing_data/model_tester/nlu_with_entities/nlu.yml', - 'tests/testing_data/model_tester/training_stories_success/stories.yml'], - output=model_path, - core_additional_arguments={"augmentation_factor": 100}, - force_training=True - ).model - ModelProcessor.set_training_status( - bot=bot, - user="test", - status=EVENT_STATUS.DONE.value, - model_path=model_file, - ) - pytest_bot_trained = True - return bot - - - -@pytest.fixture(autouse=True, scope='function') -def setup(): - connect(**Utility.mongoengine_connection(Utility.environment['database']["url"])) - - # Clear collections before running tests - Account.objects.delete() - Bot.objects.delete() - BotSettings.objects.delete() - MailClassificationConfig.objects.delete() - - a = Account.objects.create(name="mail_channel_test_user_acc", user="mail_channel_test_user_acc") - bot = Bot.objects.create(name="mail_channel_test_bot", user="mail_channel_test_user_acc", status=True, account=a.id) - pytest.bot = str(bot.id) - b = BotSettings.objects.create(bot=pytest.bot, user="mail_channel_test_user_acc") - b.llm_settings.enable_faq = True - b.save() - ChatDataProcessor.save_channel_config( - { - "connector_type": ChannelTypes.MAIL.value, - "config": { - 'email_account': "mail_channel_test_user_acc@testuser.com", - 'email_password': "password", - 'imap_server': "imap.testuser.com", - 'smtp_server': "smtp.testuser.com", - 'smtp_port': "587", - } - }, - pytest.bot, - user="mail_channel_test_user_acc", - ) - yield - - # Clear collections after running tests - MailClassificationConfig.objects.delete() - BotSettings.objects(user="mail_channel_test_user_acc").delete() - Bot.objects(user="mail_channel_test_user_acc").delete() - Account.objects(user="mail_channel_test_user_acc").delete() - - disconnect() - if len(model_path) > 0: - shutil.rmtree(model_path) - - -def test_create_doc_new_entry(): - print(pytest.bot) - doc = MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.bot, - user="mail_channel_test_user_acc" - ) - assert doc.intent == "greeting" - assert doc.bot == pytest.bot - assert doc.status is True - MailClassificationConfig.objects.delete() - - - -def test_create_doc_existing_active_entry(): - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.bot, - user="mail_channel_test_user_acc" - ) - with pytest.raises(AppException, match=r"Mail configuration already exists for intent \[greeting\]"): + def remove_basic_data(self): + MailClassificationConfig.objects.delete() + BotSettings.objects(user="mail_channel_test_user_acc").delete() + Bot.objects(user="mail_channel_test_user_acc").delete() + Account.objects(user="mail_channel_test_user_acc").delete() + + def test_create_doc_new_entry(self): + self.create_basic_data() + print(pytest.bot) + doc = MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.bot, + user="mail_channel_test_user_acc" + ) + assert doc.intent == "greeting" + assert doc.bot == pytest.bot + assert doc.status is True + MailClassificationConfig.objects.delete() + + + + def test_create_doc_existing_active_entry(self): MailClassificationConfig.create_doc( intent="greeting", - entities=["user_email"], - subjects=["hi"], - classification_prompt="Another greeting.", - reply_template="Hello!", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", bot=pytest.bot, user="mail_channel_test_user_acc" ) - MailClassificationConfig.objects.delete() - - - -def test_get_docs(): - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.bot, - user="mail_channel_test_user_acc" - ) - MailClassificationConfig.create_doc( - intent="goodbye", - entities=["farewell"], - subjects=["bye"], - classification_prompt="Classify this email as a goodbye.", - reply_template="Goodbye!", - bot=pytest.bot, - user="mail_channel_test_user_acc" - ) - docs = MailClassificationConfig.get_docs(bot=pytest.bot) - assert len(docs) == 2 - assert docs[0]["intent"] == "greeting" - assert docs[1]["intent"] == "goodbye" - MailClassificationConfig.objects.delete() - - - -def test_get_doc(): - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.bot, - user="mail_channel_test_user_acc" - ) - doc = MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") - assert doc["intent"] == "greeting" - assert doc["classification_prompt"] == "Classify this email as a greeting." - MailClassificationConfig.objects.delete() - - -def test_get_doc_nonexistent(): - """Test retrieving a non-existent document.""" - with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): - MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") - - MailClassificationConfig.objects.delete() - - -def test_delete_doc(): - """Test deleting a document.""" - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.bot, - user="mail_channel_test_user_acc" - ) - MailClassificationConfig.delete_doc(bot=pytest.bot, intent="greeting") - with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): - MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") - - MailClassificationConfig.objects.delete() - - -def test_soft_delete_doc(): - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.bot, - user="mail_channel_test_user_acc" - ) - MailClassificationConfig.soft_delete_doc(bot=pytest.bot, intent="greeting") - with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): - MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") - - MailClassificationConfig.objects.delete() - - - -def test_update_doc(): - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.bot, - user="mail_channel_test_user_acc" - ) - MailClassificationConfig.update_doc( - bot=pytest.bot, - intent="greeting", - entities=["user_name", "greeting"], - reply_template="Hello there!" - ) - doc = MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") - assert doc["entities"] == ["user_name", "greeting"] - assert doc["reply_template"] == "Hello there!" - - MailClassificationConfig.objects.delete() - -def test_update_doc_invalid_key(): - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.bot, - user="mail_channel_test_user_acc" - ) - with pytest.raises(AppException, match=r"Invalid key \[invalid_key\] provided for updating mail config"): - MailClassificationConfig.update_doc( + with pytest.raises(AppException, match=r"Mail configuration already exists for intent \[greeting\]"): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_email"], + subjects=["hi"], + classification_prompt="Another greeting.", + reply_template="Hello!", + bot=pytest.bot, + user="mail_channel_test_user_acc" + ) + MailClassificationConfig.objects.delete() + + + + def test_get_docs(self): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.bot, + user="mail_channel_test_user_acc" + ) + MailClassificationConfig.create_doc( + intent="goodbye", + entities=["farewell"], + subjects=["bye"], + classification_prompt="Classify this email as a goodbye.", + reply_template="Goodbye!", + bot=pytest.bot, + user="mail_channel_test_user_acc" + ) + docs = MailClassificationConfig.get_docs(bot=pytest.bot) + assert len(docs) == 2 + assert docs[0]["intent"] == "greeting" + assert docs[1]["intent"] == "goodbye" + MailClassificationConfig.objects.delete() + + + + def test_get_doc(self): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", bot=pytest.bot, + user="mail_channel_test_user_acc" + ) + doc = MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") + assert doc["intent"] == "greeting" + assert doc["classification_prompt"] == "Classify this email as a greeting." + MailClassificationConfig.objects.delete() + + + def test_get_doc_nonexistent(self): + """Test retrieving a non-existent document.""" + with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): + MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") + + MailClassificationConfig.objects.delete() + + + def test_delete_doc(self): + """Test deleting a document.""" + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.bot, + user="mail_channel_test_user_acc" + ) + MailClassificationConfig.delete_doc(bot=pytest.bot, intent="greeting") + with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): + MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") + + MailClassificationConfig.objects.delete() + + + def test_soft_delete_doc(self): + MailClassificationConfig.create_doc( intent="greeting", - invalid_key="value" + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.bot, + user="mail_channel_test_user_acc" ) + MailClassificationConfig.soft_delete_doc(bot=pytest.bot, intent="greeting") + with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): + MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") - MailClassificationConfig.objects.delete() + MailClassificationConfig.objects.delete() -@patch("kairon.shared.channels.mail.processor.LLMProcessor") -@patch("kairon.shared.channels.mail.processor.MailBox") -@patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") -def test_login_imap(mock_get_channel_config, mock_mailbox, mock_llm_processor): - mock_mailbox_instance = MagicMock() - mock_mailbox.return_value = mock_mailbox_instance - mock_mailbox_instance.login.return_value = ("OK", ["Logged in"]) - mock_mailbox_instance._simple_command.return_value = ("OK", ["Logged in"]) - mock_mailbox_instance.select.return_value = ("OK", ["INBOX"]) - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance + def test_update_doc(self): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.bot, + user="mail_channel_test_user_acc" + ) + MailClassificationConfig.update_doc( + bot=pytest.bot, + intent="greeting", + entities=["user_name", "greeting"], + reply_template="Hello there!" + ) + doc = MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") + assert doc["entities"] == ["user_name", "greeting"] + assert doc["reply_template"] == "Hello there!" + + MailClassificationConfig.objects.delete() - mock_get_channel_config.return_value = { - 'config': { - 'email_account': "mail_channel_test_user_acc@testuser.com", - 'email_password': "password", - 'imap_server': "imap.testuser.com" + def test_update_doc_invalid_key(self): + MailClassificationConfig.create_doc( + intent="greeting", + entities=["user_name"], + subjects=["hello"], + classification_prompt="Classify this email as a greeting.", + reply_template="Hi, how can I help?", + bot=pytest.bot, + user="mail_channel_test_user_acc" + ) + with pytest.raises(AppException, match=r"Invalid key \[invalid_key\] provided for updating mail config"): + MailClassificationConfig.update_doc( + bot=pytest.bot, + intent="greeting", + invalid_key="value" + ) + + MailClassificationConfig.objects.delete() + + + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.channels.mail.processor.MailBox") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + def test_login_imap(self, mock_get_channel_config, mock_mailbox, mock_llm_processor): + self.create_basic_data() + mock_mailbox_instance = MagicMock() + mock_mailbox.return_value = mock_mailbox_instance + mock_mailbox_instance.login.return_value = ("OK", ["Logged in"]) + mock_mailbox_instance._simple_command.return_value = ("OK", ["Logged in"]) + mock_mailbox_instance.select.return_value = ("OK", ["INBOX"]) + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com" + } } - } - bot_id = pytest.bot - mp = MailProcessor(bot=bot_id) + bot_id = pytest.bot + mp = MailProcessor(bot=bot_id) + + mp.login_imap() + + mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False) + mock_mailbox.assert_called_once_with("imap.testuser.com") + mock_mailbox_instance.login.assert_called_once_with("mail_channel_test_user_acc@testuser.com", "password") + mock_llm_processor.assert_called_once_with(bot_id, mp.llm_type) + + - mp.login_imap() + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.channels.mail.processor.MailBox") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + def test_login_imap_logout(self, mock_get_channel_config, mock_mailbox, mock_llm_processor): + self.create_basic_data() + mock_mailbox_instance = MagicMock() + mock_mailbox.return_value = mock_mailbox_instance + mock_mailbox_instance.login.return_value = mock_mailbox_instance # Ensure login returns the instance + mock_mailbox_instance._simple_command.return_value = ("OK", ["Logged in"]) + mock_mailbox_instance.select.return_value = ("OK", ["INBOX"]) - mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False) - mock_mailbox.assert_called_once_with("imap.testuser.com") - mock_mailbox_instance.login.assert_called_once_with("mail_channel_test_user_acc@testuser.com", "password") - mock_llm_processor.assert_called_once_with(bot_id, mp.llm_type) + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com" + } + } + bot_id = pytest.bot + mp = MailProcessor(bot=bot_id) -@patch("kairon.shared.channels.mail.processor.LLMProcessor") -@patch("kairon.shared.channels.mail.processor.MailBox") -@patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") -def test_login_imap_logout(mock_get_channel_config, mock_mailbox, mock_llm_processor): - mock_mailbox_instance = MagicMock() - mock_mailbox.return_value = mock_mailbox_instance - mock_mailbox_instance.login.return_value = mock_mailbox_instance # Ensure login returns the instance - mock_mailbox_instance._simple_command.return_value = ("OK", ["Logged in"]) - mock_mailbox_instance.select.return_value = ("OK", ["INBOX"]) + mp.login_imap() + mp.logout_imap() - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance + mock_mailbox_instance.logout.assert_called_once() - mock_get_channel_config.return_value = { - 'config': { - 'email_account': "mail_channel_test_user_acc@testuser.com", - 'email_password': "password", - 'imap_server': "imap.testuser.com" + + @patch("kairon.shared.channels.mail.processor.smtplib.SMTP") + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + def test_login_smtp(self, mock_get_channel_config, mock_llm_processor, mock_smtp): + # Arrange + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'smtp_server': "smtp.testuser.com", + 'smtp_port': 587 + } } - } - bot_id = pytest.bot - mp = MailProcessor(bot=bot_id) + bot_id = pytest.bot + mp = MailProcessor(bot=bot_id) - mp.login_imap() - mp.logout_imap() + mp.login_smtp() - mock_mailbox_instance.logout.assert_called_once() + mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False) + mock_smtp.assert_called_once_with("smtp.testuser.com", 587) + mock_smtp_instance.starttls.assert_called_once() + mock_smtp_instance.login.assert_called_once_with("mail_channel_test_user_acc@testuser.com", "password") -@patch("kairon.shared.channels.mail.processor.smtplib.SMTP") -@patch("kairon.shared.channels.mail.processor.LLMProcessor") -@patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") -def test_login_smtp(mock_get_channel_config, mock_llm_processor, mock_smtp): - # Arrange - mock_smtp_instance = MagicMock() - mock_smtp.return_value = mock_smtp_instance + @patch("kairon.shared.channels.mail.processor.smtplib.SMTP") + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + def test_logout_smtp(self, mock_get_channel_config, mock_llm_processor, mock_smtp): + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance - mock_get_channel_config.return_value = { - 'config': { - 'email_account': "mail_channel_test_user_acc@testuser.com", - 'email_password': "password", - 'smtp_server': "smtp.testuser.com", - 'smtp_port': 587 + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'smtp_server': "smtp.testuser.com", + 'smtp_port': 587 + } } - } - bot_id = pytest.bot - mp = MailProcessor(bot=bot_id) + bot_id = pytest.bot + mp = MailProcessor(bot=bot_id) + + mp.login_smtp() + mp.logout_smtp() + + mock_smtp_instance.quit.assert_called_once() + assert mp.smtp is None + + @patch("kairon.shared.channels.mail.processor.smtplib.SMTP") + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @pytest.mark.asyncio + async def test_send_mail(self, mock_get_channel_config, mock_llm_processor, mock_smtp): + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'smtp_server': "smtp.testuser.com", + 'smtp_port': 587 + } + } - mp.login_smtp() + bot_id = pytest.bot + mp = MailProcessor(bot=bot_id) + mp.login_smtp() - mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False) - mock_smtp.assert_called_once_with("smtp.testuser.com", 587) - mock_smtp_instance.starttls.assert_called_once() - mock_smtp_instance.login.assert_called_once_with("mail_channel_test_user_acc@testuser.com", "password") + await mp.send_mail("recipient@test.com", "Test Subject", "Test Body") + mock_smtp_instance.sendmail.assert_called_once() + assert mock_smtp_instance.sendmail.call_args[0][0] == "mail_channel_test_user_acc@testuser.com" + assert mock_smtp_instance.sendmail.call_args[0][1] == "recipient@test.com" + assert "Test Subject" in mock_smtp_instance.sendmail.call_args[0][2] + assert "Test Body" in mock_smtp_instance.sendmail.call_args[0][2] -@patch("kairon.shared.channels.mail.processor.smtplib.SMTP") -@patch("kairon.shared.channels.mail.processor.LLMProcessor") -@patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") -def test_logout_smtp(mock_get_channel_config, mock_llm_processor, mock_smtp): - # Arrange - mock_smtp_instance = MagicMock() - mock_smtp.return_value = mock_smtp_instance - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance + @patch("kairon.shared.channels.mail.processor.MailClassificationConfig") + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config") + def test_process_mail(self, mock_get_channel_config, llm_processor, mock_mail_classification_config): + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com" + } + } - mock_get_channel_config.return_value = { - 'config': { - 'email_account': "mail_channel_test_user_acc@testuser.com", - 'email_password': "password", - 'smtp_server': "smtp.testuser.com", - 'smtp_port': 587 + bot_id = pytest.bot + mp = MailProcessor(bot=bot_id) + mp.mail_configs_dict = { + "greeting": MagicMock(reply_template="Hello {name}, {bot_response}") } - } - - bot_id = pytest.bot - mp = MailProcessor(bot=bot_id) - - # Act - mp.login_smtp() - mp.logout_smtp() - - # Assert - mock_smtp_instance.quit.assert_called_once() - assert mp.smtp is None - -@patch("kairon.shared.channels.mail.processor.smtplib.SMTP") -@patch("kairon.shared.channels.mail.processor.LLMProcessor") -@patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") -@pytest.mark.asyncio -async def test_send_mail(mock_get_channel_config, mock_llm_processor, mock_smtp): - mock_smtp_instance = MagicMock() - mock_smtp.return_value = mock_smtp_instance - - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance - - mock_get_channel_config.return_value = { - 'config': { - 'email_account': "mail_channel_test_user_acc@testuser.com", - 'email_password': "password", - 'smtp_server': "smtp.testuser.com", - 'smtp_port': 587 + + rasa_chat_response = { + "slots": ["name: John Doe"], + "response": [{"text": "How can I help you today?"}] } - } - - bot_id = pytest.bot - mp = MailProcessor(bot=bot_id) - mp.login_smtp() - - await mp.send_mail("recipient@test.com", "Test Subject", "Test Body") - - mock_smtp_instance.sendmail.assert_called_once() - assert mock_smtp_instance.sendmail.call_args[0][0] == "mail_channel_test_user_acc@testuser.com" - assert mock_smtp_instance.sendmail.call_args[0][1] == "recipient@test.com" - assert "Test Subject" in mock_smtp_instance.sendmail.call_args[0][2] - assert "Test Body" in mock_smtp_instance.sendmail.call_args[0][2] - - -@patch("kairon.shared.channels.mail.processor.MailClassificationConfig") -@patch("kairon.shared.channels.mail.processor.LLMProcessor") -@patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config") -def test_process_mail( mock_get_channel_config, llm_processor, mock_mail_classification_config): - mock_get_channel_config.return_value = { - 'config': { - 'email_account': "mail_channel_test_user_acc@testuser.com", - 'email_password': "password", - 'imap_server': "imap.testuser.com" + + result = mp.process_mail("greeting", rasa_chat_response) + + assert result == "Hello John Doe, How can I help you today?" + + rasa_chat_response = { + "slots": ["name: John Doe"], + "response": [{"text": "How can I help you today?"}] } - } - - bot_id = pytest.bot - mp = MailProcessor(bot=bot_id) - mp.mail_configs_dict = { - "greeting": MagicMock(reply_template="Hello {name}, {bot_response}") - } - - rasa_chat_response = { - "slots": ["name: John Doe"], - "response": [{"text": "How can I help you today?"}] - } - - result = mp.process_mail("greeting", rasa_chat_response) - - assert result == "Hello John Doe, How can I help you today?" - - rasa_chat_response = { - "slots": ["name: John Doe"], - "response": [{"text": "How can I help you today?"}] - } - mp.mail_configs_dict = {} # No template for the intent - result = mp.process_mail("greeting", rasa_chat_response) - assert result == MailConstants.DEFAULT_TEMPLATE.format(name="John Doe", bot_response="How can I help you today?") - - - -@patch("kairon.shared.channels.mail.processor.LLMProcessor") -@patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config") -@patch("kairon.shared.channels.mail.processor.BotSettings.objects") -@patch("kairon.shared.channels.mail.processor.MailClassificationConfig.objects") -@patch("kairon.shared.channels.mail.processor.Bot.objects") -@pytest.mark.asyncio -async def test_classify_messages(mock_bot_objects, mock_mail_classification_config_objects, - mock_bot_settings_objects, mock_get_channel_config, mock_llm_processor): - # Arrange - mock_get_channel_config.return_value = { - 'config': { - 'email_account': "mail_channel_test_user_acc@testuser.com", - 'email_password': "password", - 'imap_server': "imap.testuser.com", - 'llm_type': "openai", - 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, - 'system_prompt': "Test system prompt" + mp.mail_configs_dict = {} # No template for the intent + result = mp.process_mail("greeting", rasa_chat_response) + assert result == MailConstants.DEFAULT_TEMPLATE.format(name="John Doe", bot_response="How can I help you today?") + + + + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config") + @patch("kairon.shared.channels.mail.processor.BotSettings.objects") + @patch("kairon.shared.channels.mail.processor.MailClassificationConfig.objects") + @patch("kairon.shared.channels.mail.processor.Bot.objects") + @pytest.mark.asyncio + async def test_classify_messages(self, mock_bot_objects, mock_mail_classification_config_objects, + mock_bot_settings_objects, mock_get_channel_config, mock_llm_processor): + # Arrange + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com", + 'llm_type': "openai", + 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, + 'system_prompt': "Test system prompt" + } } - } - mock_bot_settings = MagicMock() - mock_bot_settings.llm_settings = {'enable_faq': True} - mock_bot_settings_objects.get.return_value = mock_bot_settings + mock_bot_settings = MagicMock() + mock_bot_settings.llm_settings = {'enable_faq': True} + mock_bot_settings_objects.get.return_value = mock_bot_settings - mock_bot = MagicMock() - mock_bot_objects.get.return_value = mock_bot + mock_bot = MagicMock() + mock_bot_objects.get.return_value = mock_bot - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance - future = asyncio.Future() - future.set_result({"content": '[{"intent": "greeting", "entities": {"name": "John Doe"}, "mail_id": "123", "subject": "Hello"}]'}) - mock_llm_processor_instance.predict.return_value = future + future = asyncio.Future() + future.set_result({"content": '[{"intent": "greeting", "entities": {"name": "John Doe"}, "mail_id": "123", "subject": "Hello"}]'}) + mock_llm_processor_instance.predict.return_value = future - bot_id = pytest.bot - mp = MailProcessor(bot=bot_id) + bot_id = pytest.bot + mp = MailProcessor(bot=bot_id) - messages = [{"mail_id": "123", "subject": "Hello", "body": "Hi there"}] + messages = [{"mail_id": "123", "subject": "Hello", "body": "Hi there"}] - result = await mp.classify_messages(messages) + result = await mp.classify_messages(messages) - assert result == [{"intent": "greeting", "entities": {"name": "John Doe"}, "mail_id": "123", "subject": "Hello"}] - mock_llm_processor_instance.predict.assert_called_once() + assert result == [{"intent": "greeting", "entities": {"name": "John Doe"}, "mail_id": "123", "subject": "Hello"}] + mock_llm_processor_instance.predict.assert_called_once() -@patch("kairon.shared.channels.mail.processor.LLMProcessor") -def test_get_context_prompt(llm_processor): - bot_id = pytest.bot - mail_configs = [ - { - 'intent': 'greeting', - 'entities': 'name', - 'subjects': 'Hello', - 'classification_prompt': 'If the email says hello, classify it as greeting' - }, - { - 'intent': 'farewell', - 'entities': 'name', - 'subjects': 'Goodbye', - 'classification_prompt': 'If the email says goodbye, classify it as farewell' - } - ] - - mp = MailProcessor(bot=bot_id) - mp.mail_configs = mail_configs - - expected_context_prompt = ( - "intent: greeting \n" - "entities: name \n" - "\nclassification criteria: \n" - "subjects: Hello \n" - "rule: If the email says hello, classify it as greeting \n\n\n" - "intent: farewell \n" - "entities: name \n" - "\nclassification criteria: \n" - "subjects: Goodbye \n" - "rule: If the email says goodbye, classify it as farewell \n\n\n" - ) - - context_prompt = mp.get_context_prompt() - - assert context_prompt == expected_context_prompt - - -def test_extract_jsons_from_text(): - # Arrange - text = ''' - Here is some text with JSON objects. - {"key1": "value1", "key2": "value2"} - Some more text. - [{"key3": "value3"}, {"key4": "value4"}] - And some final text. - ''' - expected_output = [ - {"key1": "value1", "key2": "value2"}, + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + def test_get_context_prompt(self, llm_processor): + bot_id = pytest.bot + mail_configs = [ + { + 'intent': 'greeting', + 'entities': 'name', + 'subjects': 'Hello', + 'classification_prompt': 'If the email says hello, classify it as greeting' + }, + { + 'intent': 'farewell', + 'entities': 'name', + 'subjects': 'Goodbye', + 'classification_prompt': 'If the email says goodbye, classify it as farewell' + } + ] + + mp = MailProcessor(bot=bot_id) + mp.mail_configs = mail_configs + + expected_context_prompt = ( + "intent: greeting \n" + "entities: name \n" + "\nclassification criteria: \n" + "subjects: Hello \n" + "rule: If the email says hello, classify it as greeting \n\n\n" + "intent: farewell \n" + "entities: name \n" + "\nclassification criteria: \n" + "subjects: Goodbye \n" + "rule: If the email says goodbye, classify it as farewell \n\n\n" + ) + + context_prompt = mp.get_context_prompt() + + assert context_prompt == expected_context_prompt + + + def test_extract_jsons_from_text(self): + # Arrange + text = ''' + Here is some text with JSON objects. + {"key1": "value1", "key2": "value2"} + Some more text. [{"key3": "value3"}, {"key4": "value4"}] - ] + And some final text. + ''' + expected_output = [ + {"key1": "value1", "key2": "value2"}, + [{"key3": "value3"}, {"key4": "value4"}] + ] - # Act - result = MailProcessor.extract_jsons_from_text(text) + # Act + result = MailProcessor.extract_jsons_from_text(text) - # Assert - assert result == expected_output + # Assert + assert result == expected_output -@patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap") -@patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task") -@patch("kairon.shared.channels.mail.processor.MailBox") -@patch("kairon.shared.channels.mail.processor.BackgroundScheduler") -@patch("kairon.shared.channels.mail.processor.LLMProcessor") -@patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") -@pytest.mark.asyncio -async def test_process_mails(mock_get_channel_config, mock_llm_processor, - mock_scheduler, mock_mailbox, mock_process_message_task, - mock_logout_imap): - # Arrange - bot_id = pytest.bot + @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap") + @patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task") + @patch("kairon.shared.channels.mail.processor.MailBox") + @patch("kairon.shared.channels.mail.processor.BackgroundScheduler") + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @pytest.mark.asyncio + async def test_process_mails(self, mock_get_channel_config, mock_llm_processor, + mock_scheduler, mock_mailbox, mock_process_message_task, + mock_logout_imap): + # Arrange + bot_id = pytest.bot - mock_get_channel_config.return_value = { - 'config': { - 'email_account': "mail_channel_test_user_acc@testuser.com", - 'email_password': "password", - 'imap_server': "imap.testuser.com", - 'llm_type': "openai", - 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com", + 'llm_type': "openai", + 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, + } } - } - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance - scheduler_instance = MagicMock() - mock_scheduler.return_value = scheduler_instance + scheduler_instance = MagicMock() + mock_scheduler.return_value = scheduler_instance - mock_mailbox_instance = MagicMock() - mock_mailbox.return_value = mock_mailbox_instance + mock_mailbox_instance = MagicMock() + mock_mailbox.return_value = mock_mailbox_instance - mock_mail_message = MagicMock(spec=MailMessage) - mock_mail_message.subject = "Test Subject" - mock_mail_message.from_ = "test@example.com" - mock_mail_message.date = "2023-10-10" - mock_mail_message.text = "Test Body" - mock_mail_message.html = None + mock_mail_message = MagicMock(spec=MailMessage) + mock_mail_message.subject = "Test Subject" + mock_mail_message.from_ = "test@example.com" + mock_mail_message.date = "2023-10-10" + mock_mail_message.text = "Test Body" + mock_mail_message.html = None - mock_mailbox_instance.login.return_value = mock_mailbox_instance - mock_mailbox_instance.fetch.return_value = [mock_mail_message] + mock_mailbox_instance.login.return_value = mock_mailbox_instance + mock_mailbox_instance.fetch.return_value = [mock_mail_message] - # Act - message_count, time_shift = await MailProcessor.process_mails(bot_id, scheduler_instance) + # Act + message_count, time_shift = await MailProcessor.process_mails(bot_id, scheduler_instance) - # Assert - scheduler_instance.add_job.assert_called_once() - assert message_count == 1 - assert time_shift == 300 # 5 minutes in seconds + # Assert + scheduler_instance.add_job.assert_called_once() + assert message_count == 1 + assert time_shift == 300 # 5 minutes in seconds -import pytest -from unittest.mock import patch, MagicMock -from kairon.shared.channels.mail.processor import MailProcessor, MailConstants -from imap_tools import MailMessage + @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap") + @patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task") + @patch("kairon.shared.channels.mail.processor.MailBox") + @patch("kairon.shared.channels.mail.processor.BackgroundScheduler") + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @pytest.mark.asyncio + async def test_process_mails_no_messages(self, mock_get_channel_config, mock_llm_processor, + mock_scheduler, mock_mailbox, mock_process_message_task, + mock_logout_imap): + # Arrange + bot_id = pytest.bot -@patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap") -@patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task") -@patch("kairon.shared.channels.mail.processor.MailBox") -@patch("kairon.shared.channels.mail.processor.BackgroundScheduler") -@patch("kairon.shared.channels.mail.processor.LLMProcessor") -@patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") -@pytest.mark.asyncio -async def test_process_mails_no_messages(mock_get_channel_config, mock_llm_processor, - mock_scheduler, mock_mailbox, mock_process_message_task, - mock_logout_imap): - # Arrange - bot_id = pytest.bot - - mock_get_channel_config.return_value = { - 'config': { - 'email_account': "mail_channel_test_user_acc@testuser.com", - 'email_password': "password", - 'imap_server': "imap.testuser.com", - 'llm_type': "openai", - 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com", + 'llm_type': "openai", + 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, + } } - } - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + scheduler_instance = MagicMock() + mock_scheduler.return_value = scheduler_instance - scheduler_instance = MagicMock() - mock_scheduler.return_value = scheduler_instance + mock_mailbox_instance = MagicMock() + mock_mailbox.return_value = mock_mailbox_instance - mock_mailbox_instance = MagicMock() - mock_mailbox.return_value = mock_mailbox_instance + mock_mailbox_instance.login.return_value = mock_mailbox_instance + mock_mailbox_instance.fetch.return_value = [] - mock_mailbox_instance.login.return_value = mock_mailbox_instance - mock_mailbox_instance.fetch.return_value = [] + # Act + message_count, time_shift = await MailProcessor.process_mails(bot_id, scheduler_instance) - # Act - message_count, time_shift = await MailProcessor.process_mails(bot_id, scheduler_instance) + # Assert + assert message_count == 0 + assert time_shift == 300 - # Assert - assert message_count == 0 - assert time_shift == 300 + mock_logout_imap.assert_called_once() - mock_logout_imap.assert_called_once() + self.remove_basic_data() From 610a6cfb256aebde1c1b7c9a1f63201a5e33630b Mon Sep 17 00:00:00 2001 From: hasinaxp Date: Mon, 25 Nov 2024 17:06:57 +0530 Subject: [PATCH 07/27] test cases --- tests/unit_test/channels/mail_channel_test.py | 152 ++++++------------ 1 file changed, 45 insertions(+), 107 deletions(-) diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py index 8b625dd4e..965a0dfed 100644 --- a/tests/unit_test/channels/mail_channel_test.py +++ b/tests/unit_test/channels/mail_channel_test.py @@ -1,65 +1,28 @@ import asyncio import os -import shutil +from unittest.mock import patch, MagicMock -import bson import pytest -from apscheduler.schedulers.background import BackgroundScheduler from imap_tools import MailMessage from mongoengine import connect, disconnect -from telebot.types import BotCommandScopeChat from kairon import Utility +os.environ["system_file"] = "./tests/testing_data/system.yaml" +Utility.load_environment() +Utility.load_system_metadata() + from kairon.shared.account.data_objects import Bot, Account from kairon.shared.channels.mail.constants import MailConstants +from kairon.shared.channels.mail.processor import MailProcessor from kairon.shared.chat.data_objects import Channels from kairon.shared.chat.processor import ChatDataProcessor from kairon.shared.data.data_objects import BotSettings -os.environ["system_file"] = "./tests/testing_data/system.yaml" -Utility.load_environment() -Utility.load_system_metadata() -from kairon.exceptions import AppException from kairon.shared.channels.mail.data_objects import MailClassificationConfig -from unittest.mock import patch, MagicMock from kairon.exceptions import AppException -from kairon.shared.channels.mail.processor import MailProcessor from kairon.shared.constants import ChannelTypes -from kairon.shared.data.constant import EVENT_STATUS -from kairon.shared.data.model_processor import ModelProcessor - - - -pytest_bot_trained = False -model_path = "" - -# -# def init_bot_model(bot): -# global pytest_bot_trained -# if pytest_bot_trained: -# return -# from rasa import train -# model_path = os.path.join('models', bot) -# if not os.path.exists(model_path): -# os.mkdir(model_path) -# model_file = train( -# domain='tests/testing_data/model_tester/domain.yml', -# config='tests/testing_data/model_tester/config.yml', -# training_files=['tests/testing_data/model_tester/nlu_with_entities/nlu.yml', -# 'tests/testing_data/model_tester/training_stories_success/stories.yml'], -# output=model_path, -# core_additional_arguments={"augmentation_factor": 100}, -# force_training=True -# ).model -# ModelProcessor.set_training_status( -# bot=bot, -# user="test", -# status=EVENT_STATUS.DONE.value, -# model_path=model_file, -# ) -# pytest_bot_trained = True -# return bot + class TestMailChannel: @@ -67,33 +30,17 @@ class TestMailChannel: def setup(self): connect(**Utility.mongoengine_connection(Utility.environment['database']["url"])) - # Clear collections before running tests - # Account.objects.delete() - # Bot.objects.delete() - # BotSettings.objects.delete() - # MailClassificationConfig.objects.delete() - - yield - # Clear collections after running tests - # MailClassificationConfig.objects.delete() - # BotSettings.objects(user="mail_channel_test_user_acc").delete() - # Bot.objects(user="mail_channel_test_user_acc").delete() - # Account.objects(user="mail_channel_test_user_acc").delete() - + self.remove_basic_data() disconnect() - # if len(model_path) > 0: - # shutil.rmtree(model_path) - def create_basic_data(self): a = Account.objects.create(name="mail_channel_test_user_acc", user="mail_channel_test_user_acc") bot = Bot.objects.create(name="mail_channel_test_bot", user="mail_channel_test_user_acc", status=True, account=a.id) - print(bot) - pytest.bot = str(bot.id) - b = BotSettings.objects.create(bot=pytest.bot, user="mail_channel_test_user_acc") - b.llm_settings.enable_faq = True + pytest.mail_test_bot = str(bot.id) + b = BotSettings.objects.create(bot=pytest.mail_test_bot, user="mail_channel_test_user_acc") + # b.llm_settings.enable_faq = True b.save() ChatDataProcessor.save_channel_config( { @@ -106,7 +53,7 @@ def create_basic_data(self): 'smtp_port': "587", } }, - pytest.bot, + pytest.mail_test_bot, user="mail_channel_test_user_acc", ) @@ -115,21 +62,22 @@ def remove_basic_data(self): BotSettings.objects(user="mail_channel_test_user_acc").delete() Bot.objects(user="mail_channel_test_user_acc").delete() Account.objects(user="mail_channel_test_user_acc").delete() + Channels.objects(connector_type=ChannelTypes.MAIL.value).delete() def test_create_doc_new_entry(self): self.create_basic_data() - print(pytest.bot) + print(pytest.mail_test_bot) doc = MailClassificationConfig.create_doc( intent="greeting", entities=["user_name"], subjects=["hello"], classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", - bot=pytest.bot, + bot=pytest.mail_test_bot, user="mail_channel_test_user_acc" ) assert doc.intent == "greeting" - assert doc.bot == pytest.bot + assert doc.bot == pytest.mail_test_bot assert doc.status is True MailClassificationConfig.objects.delete() @@ -142,7 +90,7 @@ def test_create_doc_existing_active_entry(self): subjects=["hello"], classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", - bot=pytest.bot, + bot=pytest.mail_test_bot, user="mail_channel_test_user_acc" ) with pytest.raises(AppException, match=r"Mail configuration already exists for intent \[greeting\]"): @@ -152,7 +100,7 @@ def test_create_doc_existing_active_entry(self): subjects=["hi"], classification_prompt="Another greeting.", reply_template="Hello!", - bot=pytest.bot, + bot=pytest.mail_test_bot, user="mail_channel_test_user_acc" ) MailClassificationConfig.objects.delete() @@ -166,7 +114,7 @@ def test_get_docs(self): subjects=["hello"], classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", - bot=pytest.bot, + bot=pytest.mail_test_bot, user="mail_channel_test_user_acc" ) MailClassificationConfig.create_doc( @@ -175,10 +123,10 @@ def test_get_docs(self): subjects=["bye"], classification_prompt="Classify this email as a goodbye.", reply_template="Goodbye!", - bot=pytest.bot, + bot=pytest.mail_test_bot, user="mail_channel_test_user_acc" ) - docs = MailClassificationConfig.get_docs(bot=pytest.bot) + docs = MailClassificationConfig.get_docs(bot=pytest.mail_test_bot) assert len(docs) == 2 assert docs[0]["intent"] == "greeting" assert docs[1]["intent"] == "goodbye" @@ -193,10 +141,10 @@ def test_get_doc(self): subjects=["hello"], classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", - bot=pytest.bot, + bot=pytest.mail_test_bot, user="mail_channel_test_user_acc" ) - doc = MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") + doc = MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") assert doc["intent"] == "greeting" assert doc["classification_prompt"] == "Classify this email as a greeting." MailClassificationConfig.objects.delete() @@ -205,7 +153,7 @@ def test_get_doc(self): def test_get_doc_nonexistent(self): """Test retrieving a non-existent document.""" with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): - MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") + MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") MailClassificationConfig.objects.delete() @@ -218,12 +166,12 @@ def test_delete_doc(self): subjects=["hello"], classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", - bot=pytest.bot, + bot=pytest.mail_test_bot, user="mail_channel_test_user_acc" ) - MailClassificationConfig.delete_doc(bot=pytest.bot, intent="greeting") + MailClassificationConfig.delete_doc(bot=pytest.mail_test_bot, intent="greeting") with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): - MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") + MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") MailClassificationConfig.objects.delete() @@ -235,12 +183,12 @@ def test_soft_delete_doc(self): subjects=["hello"], classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", - bot=pytest.bot, + bot=pytest.mail_test_bot, user="mail_channel_test_user_acc" ) - MailClassificationConfig.soft_delete_doc(bot=pytest.bot, intent="greeting") + MailClassificationConfig.soft_delete_doc(bot=pytest.mail_test_bot, intent="greeting") with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): - MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") + MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") MailClassificationConfig.objects.delete() @@ -253,16 +201,16 @@ def test_update_doc(self): subjects=["hello"], classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", - bot=pytest.bot, + bot=pytest.mail_test_bot, user="mail_channel_test_user_acc" ) MailClassificationConfig.update_doc( - bot=pytest.bot, + bot=pytest.mail_test_bot, intent="greeting", entities=["user_name", "greeting"], reply_template="Hello there!" ) - doc = MailClassificationConfig.get_doc(bot=pytest.bot, intent="greeting") + doc = MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") assert doc["entities"] == ["user_name", "greeting"] assert doc["reply_template"] == "Hello there!" @@ -275,12 +223,12 @@ def test_update_doc_invalid_key(self): subjects=["hello"], classification_prompt="Classify this email as a greeting.", reply_template="Hi, how can I help?", - bot=pytest.bot, + bot=pytest.mail_test_bot, user="mail_channel_test_user_acc" ) with pytest.raises(AppException, match=r"Invalid key \[invalid_key\] provided for updating mail config"): MailClassificationConfig.update_doc( - bot=pytest.bot, + bot=pytest.mail_test_bot, intent="greeting", invalid_key="value" ) @@ -310,7 +258,7 @@ def test_login_imap(self, mock_get_channel_config, mock_mailbox, mock_llm_proces } } - bot_id = pytest.bot + bot_id = pytest.mail_test_bot mp = MailProcessor(bot=bot_id) mp.login_imap() @@ -344,7 +292,7 @@ def test_login_imap_logout(self, mock_get_channel_config, mock_mailbox, mock_llm } } - bot_id = pytest.bot + bot_id = pytest.mail_test_bot mp = MailProcessor(bot=bot_id) mp.login_imap() @@ -373,7 +321,7 @@ def test_login_smtp(self, mock_get_channel_config, mock_llm_processor, mock_smtp } } - bot_id = pytest.bot + bot_id = pytest.mail_test_bot mp = MailProcessor(bot=bot_id) mp.login_smtp() @@ -403,7 +351,7 @@ def test_logout_smtp(self, mock_get_channel_config, mock_llm_processor, mock_smt } } - bot_id = pytest.bot + bot_id = pytest.mail_test_bot mp = MailProcessor(bot=bot_id) mp.login_smtp() @@ -432,7 +380,7 @@ async def test_send_mail(self, mock_get_channel_config, mock_llm_processor, mock } } - bot_id = pytest.bot + bot_id = pytest.mail_test_bot mp = MailProcessor(bot=bot_id) mp.login_smtp() @@ -457,7 +405,7 @@ def test_process_mail(self, mock_get_channel_config, llm_processor, mock_mail_c } } - bot_id = pytest.bot + bot_id = pytest.mail_test_bot mp = MailProcessor(bot=bot_id) mp.mail_configs_dict = { "greeting": MagicMock(reply_template="Hello {name}, {bot_response}") @@ -490,7 +438,6 @@ def test_process_mail(self, mock_get_channel_config, llm_processor, mock_mail_c @pytest.mark.asyncio async def test_classify_messages(self, mock_bot_objects, mock_mail_classification_config_objects, mock_bot_settings_objects, mock_get_channel_config, mock_llm_processor): - # Arrange mock_get_channel_config.return_value = { 'config': { 'email_account': "mail_channel_test_user_acc@testuser.com", @@ -516,7 +463,7 @@ async def test_classify_messages(self, mock_bot_objects, mock_mail_classificatio future.set_result({"content": '[{"intent": "greeting", "entities": {"name": "John Doe"}, "mail_id": "123", "subject": "Hello"}]'}) mock_llm_processor_instance.predict.return_value = future - bot_id = pytest.bot + bot_id = pytest.mail_test_bot mp = MailProcessor(bot=bot_id) messages = [{"mail_id": "123", "subject": "Hello", "body": "Hi there"}] @@ -529,7 +476,7 @@ async def test_classify_messages(self, mock_bot_objects, mock_mail_classificatio @patch("kairon.shared.channels.mail.processor.LLMProcessor") def test_get_context_prompt(self, llm_processor): - bot_id = pytest.bot + bot_id = pytest.mail_test_bot mail_configs = [ { 'intent': 'greeting', @@ -567,7 +514,6 @@ def test_get_context_prompt(self, llm_processor): def test_extract_jsons_from_text(self): - # Arrange text = ''' Here is some text with JSON objects. {"key1": "value1", "key2": "value2"} @@ -580,10 +526,8 @@ def test_extract_jsons_from_text(self): [{"key3": "value3"}, {"key4": "value4"}] ] - # Act result = MailProcessor.extract_jsons_from_text(text) - # Assert assert result == expected_output @@ -600,8 +544,7 @@ def test_extract_jsons_from_text(self): async def test_process_mails(self, mock_get_channel_config, mock_llm_processor, mock_scheduler, mock_mailbox, mock_process_message_task, mock_logout_imap): - # Arrange - bot_id = pytest.bot + bot_id = pytest.mail_test_bot mock_get_channel_config.return_value = { 'config': { @@ -632,10 +575,8 @@ async def test_process_mails(self, mock_get_channel_config, mock_llm_processor, mock_mailbox_instance.login.return_value = mock_mailbox_instance mock_mailbox_instance.fetch.return_value = [mock_mail_message] - # Act message_count, time_shift = await MailProcessor.process_mails(bot_id, scheduler_instance) - # Assert scheduler_instance.add_job.assert_called_once() assert message_count == 1 assert time_shift == 300 # 5 minutes in seconds @@ -652,8 +593,7 @@ async def test_process_mails(self, mock_get_channel_config, mock_llm_processor, async def test_process_mails_no_messages(self, mock_get_channel_config, mock_llm_processor, mock_scheduler, mock_mailbox, mock_process_message_task, mock_logout_imap): - # Arrange - bot_id = pytest.bot + bot_id = pytest.mail_test_bot mock_get_channel_config.return_value = { 'config': { @@ -677,10 +617,8 @@ async def test_process_mails_no_messages(self, mock_get_channel_config, mock_llm mock_mailbox_instance.login.return_value = mock_mailbox_instance mock_mailbox_instance.fetch.return_value = [] - # Act message_count, time_shift = await MailProcessor.process_mails(bot_id, scheduler_instance) - # Assert assert message_count == 0 assert time_shift == 300 From 34431642118e27c71856a0c378fc54ca3db5f33b Mon Sep 17 00:00:00 2001 From: hasinaxp Date: Mon, 25 Nov 2024 18:12:36 +0530 Subject: [PATCH 08/27] test cases --- kairon/shared/channels/mail/processor.py | 8 +++++--- tests/unit_test/channels/mail_channel_test.py | 19 +++++++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index 52e728c4c..0c3a5aaf4 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -59,7 +59,7 @@ def login_smtp(self): smtp_server = self.config.get('smtp_server', MailConstants.DEFAULT_SMTP_SERVER) smtp_port = self.config.get('smtp_port', MailConstants.DEFAULT_SMTP_PORT) smtp_port = int(smtp_port) - self.smtp = smtplib.SMTP(smtp_server, smtp_port) + self.smtp = smtplib.SMTP(smtp_server, smtp_port, timeout=30) self.smtp.starttls() self.smtp.login(email_account, email_password) @@ -82,8 +82,10 @@ async def send_mail(self, to: str, subject: str, body: str): def process_mail(self, intent: str, rasa_chat_response: dict): slots = rasa_chat_response.get('slots', []) - slots = {key.strip(): value.strip() for slot_str in slots for key, value in [slot_str.split(":", 1)] if - value.strip() != 'None'} + slots = {key.strip(): value.strip() for slot_str in slots + for split_result in [slot_str.split(":", 1)] + if len(split_result) == 2 + for key, value in [split_result]} responses = '

'.join(response.get('text', '') for response in rasa_chat_response.get('response', [])) diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py index 965a0dfed..0beaf70a0 100644 --- a/tests/unit_test/channels/mail_channel_test.py +++ b/tests/unit_test/channels/mail_channel_test.py @@ -327,7 +327,7 @@ def test_login_smtp(self, mock_get_channel_config, mock_llm_processor, mock_smtp mp.login_smtp() mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False) - mock_smtp.assert_called_once_with("smtp.testuser.com", 587) + mock_smtp.assert_called_once_with("smtp.testuser.com", 587, timeout=30) mock_smtp_instance.starttls.assert_called_once() mock_smtp_instance.login.assert_called_once_with("mail_channel_test_user_acc@testuser.com", "password") @@ -624,10 +624,25 @@ async def test_process_mails_no_messages(self, mock_get_channel_config, mock_llm mock_logout_imap.assert_called_once() - self.remove_basic_data() + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @pytest.mark.asyncio + async def test_classify_messages_invalid_llm_response(self, mock_get_channel_config, mock_llm_processor): + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + future = asyncio.Future() + future.set_result({"content": 'invalid json content'}) + mock_llm_processor_instance.predict.return_value = future + + mp = MailProcessor(bot=pytest.mail_test_bot) + messages = [{"mail_id": "123", "subject": "Hello", "body": "Hi there"}] + + ans = await mp.classify_messages(messages) + assert not ans From 8f8ce73271fbcb7134ee1b0ce827e45be384074e Mon Sep 17 00:00:00 2001 From: hasinaxp Date: Wed, 27 Nov 2024 12:16:37 +0530 Subject: [PATCH 09/27] unsafe changes --- kairon/__init__.py | 4 +- kairon/cli/mail_channel.py | 32 ++++++++++++ kairon/events/definitions/factory.py | 4 +- .../definitions/mail_channel_schedule.py | 50 +++++++++++++++++++ kairon/shared/channels/mail/processor.py | 7 +-- kairon/shared/channels/mail/scheduler.py | 3 +- kairon/shared/constants.py | 1 + tests/unit_test/channels/mail_channel_test.py | 5 +- tests/unit_test/cli_test.py | 14 ++++++ tests/unit_test/events/definitions_test.py | 26 +++++++++- 10 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 kairon/cli/mail_channel.py create mode 100644 kairon/events/definitions/mail_channel_schedule.py diff --git a/kairon/__init__.py b/kairon/__init__.py index 7fcd49789..cb4cc9f2d 100644 --- a/kairon/__init__.py +++ b/kairon/__init__.py @@ -44,7 +44,8 @@ def create_argument_parser(): - from kairon.cli import importer, training, testing, conversations_deletion, translator, delete_logs, message_broadcast,content_importer + from kairon.cli import importer, training, testing, conversations_deletion, translator, delete_logs,\ + message_broadcast,content_importer, mail_channel parser = ArgumentParser( prog="kairon", @@ -62,6 +63,7 @@ def create_argument_parser(): delete_logs.add_subparser(subparsers, parents=parent_parsers) message_broadcast.add_subparser(subparsers, parents=parent_parsers) content_importer.add_subparser(subparsers, parents=parent_parsers) + mail_channel.add_subparser(subparsers, parents=parent_parsers) return parser diff --git a/kairon/cli/mail_channel.py b/kairon/cli/mail_channel.py new file mode 100644 index 000000000..9fb2896d3 --- /dev/null +++ b/kairon/cli/mail_channel.py @@ -0,0 +1,32 @@ +from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter +from typing import List +from rasa.cli import SubParsersAction + +from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + + +def process_channel_mails(args): + MailChannelScheduleEvent(args.bot, args.user).execute(mails=args.mails) + + +def add_subparser(subparsers: SubParsersAction, parents: List[ArgumentParser]): + mail_parser = subparsers.add_parser( + "mail-channel", + conflict_handler="resolve", + formatter_class=ArgumentDefaultsHelpFormatter, + parents=parents, + help="Mail channel" + ) + mail_parser.add_argument('bot', + type=str, + help="Bot id for which command is executed", action='store') + + mail_parser.add_argument('user', + type=str, + help="Kairon user who is initiating the command", action='store') + + mail_parser.add_argument('mails', + type=list, + help="List of mails to be processed", action='store') + + mail_parser.set_defaults(func=process_channel_mails) \ No newline at end of file diff --git a/kairon/events/definitions/factory.py b/kairon/events/definitions/factory.py index 9ff4128f0..dd754a92f 100644 --- a/kairon/events/definitions/factory.py +++ b/kairon/events/definitions/factory.py @@ -2,6 +2,7 @@ from kairon.events.definitions.data_importer import TrainingDataImporterEvent from kairon.events.definitions.faq_importer import FaqDataImporterEvent from kairon.events.definitions.history_delete import DeleteHistoryEvent +from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent from kairon.events.definitions.message_broadcast import MessageBroadcastEvent from kairon.events.definitions.model_testing import ModelTestingEvent from kairon.events.definitions.model_training import ModelTrainingEvent @@ -20,7 +21,8 @@ class EventFactory: EventClass.multilingual: MultilingualEvent, EventClass.faq_importer: FaqDataImporterEvent, EventClass.message_broadcast: MessageBroadcastEvent, - EventClass.content_importer: DocContentImporterEvent + EventClass.content_importer: DocContentImporterEvent, + EventClass.email_channel_scheduler: MailChannelScheduleEvent } @staticmethod diff --git a/kairon/events/definitions/mail_channel_schedule.py b/kairon/events/definitions/mail_channel_schedule.py new file mode 100644 index 000000000..8105dc495 --- /dev/null +++ b/kairon/events/definitions/mail_channel_schedule.py @@ -0,0 +1,50 @@ +from typing import Text +from loguru import logger +from kairon import Utility +from kairon.events.definitions.base import EventsBase +from kairon.shared.channels.mail.processor import MailProcessor +from kairon.shared.constants import EventClass, ChannelTypes + + +class MailChannelScheduleEvent(EventsBase): + """ + Event to start mail channel scheduler if not already running. + """ + + def __init__(self, bot: Text, user: Text, **kwargs): + """ + Initialise event. + """ + self.bot = bot + self.user = user + + def validate(self): + """ + validate mail channel exists + """ + pass + + + def enqueue(self, **kwargs): + """ + Send event to event server. + """ + try: + mails: list = kwargs.get('mails', []) + payload = {'bot': self.bot, 'user': self.user, 'mails': mails} + Utility.request_event_server(EventClass.email_channel_scheduler, payload) + except Exception as e: + logger.error(str(e)) + raise e + + def execute(self, **kwargs): + """ + Execute the event. + """ + try: + mails = kwargs.get('mails') + if mails: + MailProcessor.process_message_task(self.bot, mails) + except Exception as e: + logger.error(str(e)) + raise e \ No newline at end of file diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index 0c3a5aaf4..b1c1cc9e5 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -7,6 +7,7 @@ from pydantic.validators import datetime from imap_tools import MailBox, AND from kairon.chat.utils import ChatUtils +from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent from kairon.exceptions import AppException from kairon.shared.account.data_objects import Bot from kairon.shared.channels.mail.constants import MailConstants @@ -194,7 +195,7 @@ def process_message_task(bot: str, message_batch: [dict]): @staticmethod - async def process_mails(bot: str, scheduler : BackgroundScheduler = None): + async def process_mails(bot: str): mp = MailProcessor(bot) time_shift = int(mp.config.get('interval', 5 * 60)) last_read_timestamp = datetime.now() - timedelta(seconds=time_shift) @@ -209,7 +210,7 @@ async def process_mails(bot: str, scheduler : BackgroundScheduler = None): sender_id = msg.from_ date = msg.date body = msg.text or msg.html or "" - logger.info(subject, sender_id, date, body) + logger.info(subject, sender_id, date) message_entry = { 'mail_id': sender_id, 'subject': subject, @@ -225,7 +226,7 @@ async def process_mails(bot: str, scheduler : BackgroundScheduler = None): for batch_id in range(0, len(messages), MailConstants.PROCESS_MESSAGE_BATCH_SIZE): batch = messages[batch_id: batch_id + MailConstants.PROCESS_MESSAGE_BATCH_SIZE] - scheduler.add_job(MailProcessor.process_message_task, args=[bot, batch], run_date=datetime.now()) + MailChannelScheduleEvent(bot, mp.bot_settings.user).enqueue(mails=batch) return len(messages), time_shift except Exception as e: diff --git a/kairon/shared/channels/mail/scheduler.py b/kairon/shared/channels/mail/scheduler.py index 44dce7f70..e6062df66 100644 --- a/kairon/shared/channels/mail/scheduler.py +++ b/kairon/shared/channels/mail/scheduler.py @@ -58,8 +58,9 @@ async def process_mails(bot, scheduler: BackgroundScheduler = None): if bot not in MailScheduler.scheduled_bots: return logger.info(f"MailScheduler: Processing mails for bot {bot}") - _, next_delay = await MailProcessor.process_mails(bot, scheduler) + _, next_delay = await MailProcessor.process_mails(bot) logger.info(f"next_delay: {next_delay}") next_timestamp = datetime.now() + timedelta(seconds=next_delay) MailScheduler.scheduler.add_job(MailScheduler.process_mails_task, 'date', args=[bot, scheduler], run_date=next_timestamp) MailScheduler.epoch() + diff --git a/kairon/shared/constants.py b/kairon/shared/constants.py index 77ae2c805..66419bde0 100644 --- a/kairon/shared/constants.py +++ b/kairon/shared/constants.py @@ -80,6 +80,7 @@ class EventClass(str, Enum): web_search = "web_search" scheduler_evaluator = "scheduler_evaluator" content_importer = "content_importer" + email_channel_scheduler = "email_channel_scheduler" class EventRequestType(str, Enum): diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py index 0beaf70a0..c2d93468e 100644 --- a/tests/unit_test/channels/mail_channel_test.py +++ b/tests/unit_test/channels/mail_channel_test.py @@ -575,9 +575,8 @@ async def test_process_mails(self, mock_get_channel_config, mock_llm_processor, mock_mailbox_instance.login.return_value = mock_mailbox_instance mock_mailbox_instance.fetch.return_value = [mock_mail_message] - message_count, time_shift = await MailProcessor.process_mails(bot_id, scheduler_instance) + message_count, time_shift = await MailProcessor.process_mails(bot_id) - scheduler_instance.add_job.assert_called_once() assert message_count == 1 assert time_shift == 300 # 5 minutes in seconds @@ -617,7 +616,7 @@ async def test_process_mails_no_messages(self, mock_get_channel_config, mock_llm mock_mailbox_instance.login.return_value = mock_mailbox_instance mock_mailbox_instance.fetch.return_value = [] - message_count, time_shift = await MailProcessor.process_mails(bot_id, scheduler_instance) + message_count, time_shift = await MailProcessor.process_mails(bot_id) assert message_count == 0 assert time_shift == 300 diff --git a/tests/unit_test/cli_test.py b/tests/unit_test/cli_test.py index b5c4ad361..ca3bb6123 100644 --- a/tests/unit_test/cli_test.py +++ b/tests/unit_test/cli_test.py @@ -2,6 +2,7 @@ import os from datetime import datetime from unittest import mock +from unittest.mock import patch import pytest from mongoengine import connect @@ -314,6 +315,19 @@ def init_connection(self): def test_delete_logs(self, mock_args): cli() +class TestMailChanelCli: + + @pytest.fixture(autouse=True, scope='class') + def init_connection(self): + os.environ["system_file"] = "./tests/testing_data/system.yaml" + Utility.load_environment() + connect(**Utility.mongoengine_connection(Utility.environment['database']["url"])) + + def test_start_mail_channel(self): + from kairon.cli.mail_channel import process_channel_mails + with patch('argparse.ArgumentParser.parse_args', return_value=argparse.Namespace(func=process_channel_mails)): + cli() + class TestMessageBroadcastCli: diff --git a/tests/unit_test/events/definitions_test.py b/tests/unit_test/events/definitions_test.py index 020517f51..66f83ffd9 100644 --- a/tests/unit_test/events/definitions_test.py +++ b/tests/unit_test/events/definitions_test.py @@ -11,7 +11,6 @@ from unittest.mock import patch from mongoengine import connect -from augmentation.utils import WebsiteParser from kairon import Utility from kairon.events.definitions.data_importer import TrainingDataImporterEvent from kairon.events.definitions.faq_importer import FaqDataImporterEvent @@ -1199,3 +1198,28 @@ def test_delete_message_broadcast(self): assert len(list(MessageBroadcastProcessor.list_settings(bot))) == 1 with pytest.raises(AppException, match="Notification settings not found!"): MessageBroadcastProcessor.get_settings(setting_id, bot) + + + @responses.activate + def test_trigger_mail_channel_schedule_event_enqueue(self): + from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + bot = "test_add_schedule_event" + user = "test_user" + url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False" + responses.add( + "POST", url, + json={"message": "Failed", "success": True, "error_code": 400, "data": None} + ) + event = MailChannelScheduleEvent(bot, user) + try: + event.enqueue() + except AppException as e: + pytest.fail(f"Unexpected exception: {e}") + + @responses.activate + def test_trigger_mail_channel_schedule_event_execute(self): + from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + try: + MailChannelScheduleEvent("", "").execute() + except AppException as e: + pytest.fail(f"Unexpected exception: {e}") \ No newline at end of file From 8edbe0a5e2d682294fdf04e52b0562c6eb1baee2 Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Sun, 1 Dec 2024 21:40:54 +0530 Subject: [PATCH 10/27] test case fixes --- kairon/api/app/routers/bot/bot.py | 1 + kairon/events/server.py | 7 + kairon/shared/channels/mail/processor.py | 51 ++++--- kairon/shared/channels/mail/scheduler.py | 51 +++++-- kairon/shared/chat/processor.py | 3 + tests/integration_test/action_service_test.py | 124 +----------------- tests/integration_test/event_service_test.py | 13 +- tests/unit_test/channels/mail_channel_test.py | 36 +++-- .../unit_test/channels/mail_scheduler_test.py | 66 ++++++++-- tests/unit_test/cli_test.py | 8 +- tests/unit_test/events/definitions_test.py | 2 +- 11 files changed, 183 insertions(+), 179 deletions(-) diff --git a/kairon/api/app/routers/bot/bot.py b/kairon/api/app/routers/bot/bot.py index d267a3b84..382584e0d 100644 --- a/kairon/api/app/routers/bot/bot.py +++ b/kairon/api/app/routers/bot/bot.py @@ -26,6 +26,7 @@ from kairon.shared.actions.data_objects import ActionServerLogs from kairon.shared.auth import Authentication from kairon.shared.channels.mail.data_objects import MailClassificationConfig +from kairon.shared.channels.mail.scheduler import MailScheduler from kairon.shared.constants import TESTER_ACCESS, DESIGNER_ACCESS, CHAT_ACCESS, UserActivityType, ADMIN_ACCESS, \ EventClass, AGENT_ACCESS from kairon.shared.content_importer.content_processor import ContentImporterLogProcessor diff --git a/kairon/events/server.py b/kairon/events/server.py index d34dec98a..7aef6fffe 100644 --- a/kairon/events/server.py +++ b/kairon/events/server.py @@ -146,3 +146,10 @@ def delete_scheduled_event(event_id: Text = Path(description="Event id")): def dispatch_scheduled_event(event_id: Text = Path(description="Event id")): KScheduler().dispatch_event(event_id) return {"data": None, "message": "Scheduled event dispatch!"} + + +@app.get('/api/mail/request_epoch', response_model=Response) +def request_epoch(): + from kairon.shared.channels.mail.scheduler import MailScheduler + MailScheduler.epoch() + return {"data": None, "message": "Mail scheduler epoch request!"} \ No newline at end of file diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index b1c1cc9e5..cc1d23d9a 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -6,8 +6,6 @@ from pydantic.schema import timedelta from pydantic.validators import datetime from imap_tools import MailBox, AND -from kairon.chat.utils import ChatUtils -from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent from kairon.exceptions import AppException from kairon.shared.account.data_objects import Bot from kairon.shared.channels.mail.constants import MailConstants @@ -118,9 +116,14 @@ async def classify_messages(self, messages: [dict]) -> [dict]: logger.error(str(e)) raise AppException(str(e)) + @staticmethod async def process_messages(bot: str, batch: [dict]): + """ + classify and respond to a batch of messages + """ try: + from kairon.chat.utils import ChatUtils mp = MailProcessor(bot) classifications = await mp.classify_messages(batch) user_messages: [str] = [] @@ -135,7 +138,7 @@ async def process_messages(bot: str, batch: [dict]): sender_id = classification['mail_id'] subject = f"{classification['subject']}" - #mail_id is in the format "name " + # mail_id is in the format "name " or "email" if '<' in sender_id: sender_id = sender_id.split('<')[1].split('>')[0] @@ -163,11 +166,9 @@ async def process_messages(bot: str, batch: [dict]): }) logger.info(chat_responses) - for index, response in enumerate(chat_responses): responses[index]['body'] = mp.process_mail(intents[index], response) - mp.login_smtp() tasks = [mp.send_mail(**response) for response in responses] await asyncio.gather(*tasks) @@ -190,14 +191,31 @@ def get_context_prompt(self) -> str: @staticmethod def process_message_task(bot: str, message_batch: [dict]): + """ + Process a batch of messages + used for execution by executor + """ asyncio.run(MailProcessor.process_messages(bot, message_batch)) - @staticmethod - async def process_mails(bot: str): + def read_mails(bot: str) -> ([dict], str, int): + """ + Read mails from the mailbox + Parameters: + - bot: str - bot id + Returns: + - list of messages - each message is a dict with the following + - mail_id + - subject + - date + - body + - user + - time_shift + + """ mp = MailProcessor(bot) - time_shift = int(mp.config.get('interval', 5 * 60)) + time_shift = int(mp.config.get('interval', 20 * 60)) last_read_timestamp = datetime.now() - timedelta(seconds=time_shift) messages = [] is_logged_in = False @@ -220,23 +238,18 @@ async def process_mails(bot: str): messages.append(message_entry) mp.logout_imap() is_logged_in = False - - if not messages or len(messages) == 0: - return 0, time_shift - - for batch_id in range(0, len(messages), MailConstants.PROCESS_MESSAGE_BATCH_SIZE): - batch = messages[batch_id: batch_id + MailConstants.PROCESS_MESSAGE_BATCH_SIZE] - MailChannelScheduleEvent(bot, mp.bot_settings.user).enqueue(mails=batch) - - return len(messages), time_shift + return messages, mp.bot_settings.user, time_shift except Exception as e: logger.exception(e) if is_logged_in: mp.logout_imap() - return 0, time_shift + return [], mp.bot_settings.user, time_shift @staticmethod - def extract_jsons_from_text(text): + def extract_jsons_from_text(text) -> list: + """ + Extract json objects from text as a list + """ json_pattern = re.compile(r'(\{.*?\}|\[.*?\])', re.DOTALL) jsons = [] for match in json_pattern.findall(text): diff --git a/kairon/shared/channels/mail/scheduler.py b/kairon/shared/channels/mail/scheduler.py index e6062df66..c7ef86758 100644 --- a/kairon/shared/channels/mail/scheduler.py +++ b/kairon/shared/channels/mail/scheduler.py @@ -1,11 +1,14 @@ import asyncio from datetime import datetime, timedelta +from urllib.parse import urljoin from apscheduler.jobstores.mongodb import MongoDBJobStore from apscheduler.schedulers.background import BackgroundScheduler from pymongo import MongoClient from kairon import Utility +from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent +from kairon.exceptions import AppException from kairon.shared.channels.mail.processor import MailProcessor from kairon.shared.chat.data_objects import Channels from kairon.shared.constants import ChannelTypes @@ -30,14 +33,13 @@ def epoch(): job_defaults={'coalesce': True, 'misfire_grace_time': 7200}) bots = Channels.objects(connector_type= ChannelTypes.MAIL) - bots = set(bot['bot'] for bot in bots.values_list('bot')) - + bots_list = [bot['bot'] for bot in bots] + bots = set(bots_list) unscheduled_bots = bots - MailScheduler.scheduled_bots - logger.info(f"MailScheduler: Epoch: {MailScheduler.scheduled_bots}") for bot in unscheduled_bots: first_schedule_time = datetime.now() + timedelta(seconds=5) - MailScheduler.scheduler.add_job(MailScheduler.process_mails_task, + MailScheduler.scheduler.add_job(MailScheduler.process_mails, 'date', args=[bot, MailScheduler.scheduler], run_date=first_schedule_time) MailScheduler.scheduled_bots.add(bot) @@ -48,19 +50,44 @@ def epoch(): return False @staticmethod - def process_mails_task(bot, scheduler: BackgroundScheduler = None): - if scheduler: - asyncio.run(MailScheduler.process_mails(bot, scheduler)) + def request_epoch(): + event_server_url = Utility.get_event_server_url() + resp = Utility.execute_http_request( + "GET", + urljoin( + event_server_url, + "/api/mail/request_epoch", + ), + err_msg=f"Failed to request epoch", + ) + if not resp['success']: + raise AppException("Failed to request email channel epoch") @staticmethod - async def process_mails(bot, scheduler: BackgroundScheduler = None): + def process_mails(bot, scheduler: BackgroundScheduler = None): if bot not in MailScheduler.scheduled_bots: return logger.info(f"MailScheduler: Processing mails for bot {bot}") - _, next_delay = await MailProcessor.process_mails(bot) - logger.info(f"next_delay: {next_delay}") - next_timestamp = datetime.now() + timedelta(seconds=next_delay) - MailScheduler.scheduler.add_job(MailScheduler.process_mails_task, 'date', args=[bot, scheduler], run_date=next_timestamp) + next_timestamp = MailScheduler.read_mailbox_and_schedule_events(bot) + MailScheduler.scheduler.add_job(MailScheduler.process_mails, 'date', args=[bot, scheduler], run_date=next_timestamp) MailScheduler.epoch() + @staticmethod + def read_mailbox_and_schedule_events(bot) -> datetime: + vals = MailProcessor.read_mails(bot) + print(vals) + emails, user, next_delay = vals + for email in emails: + MailChannelScheduleEvent(bot, user).enqueue(mails=[email]) + next_timestamp = datetime.now() + timedelta(seconds=next_delay) + return next_timestamp + + + + + + + + + diff --git a/kairon/shared/chat/processor.py b/kairon/shared/chat/processor.py index 43d136e50..208331b35 100644 --- a/kairon/shared/chat/processor.py +++ b/kairon/shared/chat/processor.py @@ -39,6 +39,9 @@ def save_channel_config(configuration: Dict, bot: Text, user: Text): channel.user = user channel.timestamp = datetime.utcnow() channel.save() + if configuration['connector_type'] == ChannelTypes.MAIL.value: + from kairon.shared.channels.mail.scheduler import MailScheduler + MailScheduler.request_epoch() if primary_slack_config_changed: ChatDataProcessor.delete_channel_config(bot, connector_type="slack", config__is_primary=False) channel_endpoint = DataUtility.get_channel_endpoint(channel) diff --git a/tests/integration_test/action_service_test.py b/tests/integration_test/action_service_test.py index 3f4551f45..35f50340c 100644 --- a/tests/integration_test/action_service_test.py +++ b/tests/integration_test/action_service_test.py @@ -2,6 +2,10 @@ import datetime import os from urllib.parse import urlencode, urljoin +from kairon.shared.utils import Utility +os.environ["system_file"] = "./tests/testing_data/system.yaml" +Utility.load_environment() +Utility.load_system_metadata() import urllib import litellm @@ -11,18 +15,17 @@ import responses import ujson as json from apscheduler.util import obj_to_ref -from cycler import U from deepdiff import DeepDiff from fastapi.testclient import TestClient from jira import JIRAError from litellm import embedding from mongoengine import connect + + from kairon.events.executors.factory import ExecutorFactory from kairon.shared.callback.data_objects import CallbackConfig, encrypt_secret -from kairon.shared.utils import Utility -Utility.load_system_metadata() from kairon.actions.definitions.live_agent import ActionLiveAgent from kairon.actions.definitions.set_slot import ActionSetSlot @@ -11752,121 +11755,6 @@ def test_prompt_action_response_action_with_prompt_question_from_slot(mock_embed 'response': None, 'image': None, 'attachment': None} ] -@mock.patch.object(litellm, "aembedding", autospec=True) -@mock.patch.object(ActionUtility, 'execute_request_async', autospec=True) -def test_prompt_action_response_action_with_prompt_question_from_slot_perplexity(mock_execute_request_async, mock_embedding, aioresponses): - from uuid6 import uuid7 - llm_type = "perplexity" - action_name = "test_prompt_action_response_action_with_prompt_question_from_slot" - bot = "5f50fd0a56b69s8ca10d35d2l" - user = "udit.pandey" - value = "keyvalue" - user_msg = "What kind of language is python?" - bot_content = "Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation. Python is dynamically typed and garbage-collected." - generated_text = "Python is dynamically typed, garbage-collected, high level, general purpose programming." - llm_prompts = [ - {'name': 'System Prompt', - 'data': 'You are a personal assistant. Answer question based on the context below.', - 'type': 'system', 'source': 'static', 'is_enabled': True}, - {'name': 'History Prompt', 'type': 'user', 'source': 'history', 'is_enabled': True}, - {'name': 'Query Prompt', 'data': "What kind of language is python?", 'instructions': 'Rephrase the query.', - 'type': 'query', 'source': 'static', 'is_enabled': False}, - {'name': 'Similarity Prompt', - 'instructions': 'Answer question based on the context above, if answer is not in the context go check previous logs.', - 'type': 'user', 'source': 'bot_content', 'data': 'python', - 'hyperparameters': {"top_results": 10, "similarity_threshold": 0.70}, - 'is_enabled': True} - ] - mock_execute_request_async.return_value = ( - { - 'formatted_response': 'Python is dynamically typed, garbage-collected, high level, general purpose programming.', - 'response': 'Python is dynamically typed, garbage-collected, high level, general purpose programming.'}, - 200, - mock.ANY, - mock.ANY - ) - embedding = list(np.random.random(OPENAI_EMBEDDING_OUTPUT)) - mock_embedding.return_value = litellm.EmbeddingResponse(**{'data': [{'embedding': embedding}]}) - expected_body = {'messages': [ - {'role': 'system', 'content': 'You are a personal assistant. Answer question based on the context below.\n'}, - {'role': 'user', 'content': 'hello'}, {'role': 'assistant', 'content': 'how are you'}, {'role': 'user', - 'content': "\nInstructions on how to use Similarity Prompt:\n['Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation. Python is dynamically typed and garbage-collected.']\nAnswer question based on the context above, if answer is not in the context go check previous logs.\n \nQ: What kind of language is python? \nA:"}], - 'metadata': {'user': 'udit.pandey', 'bot': '5f50fd0a56b698ca10d35d2l', 'invocation': 'prompt_action'}, - 'api_key': 'keyvalue', - 'num_retries': 3, 'temperature': 0.0, 'max_tokens': 300, 'model': 'gpt-4o-mini', 'top_p': 0.0, 'n': 1, - 'stop': None, 'presence_penalty': 0.0, 'frequency_penalty': 0.0, 'logit_bias': {}} - aioresponses.add( - url=urljoin(Utility.environment['llm']['url'], - f"/{bot}/completion/{llm_type}"), - method="POST", - status=200, - payload={'formatted_response': generated_text, 'response': generated_text}, - body=json.dumps(expected_body) - ) - aioresponses.add( - url=f"{Utility.environment['vector']['db']}/collections/{bot}_python_faq_embd/points/search", - body={'vector': embedding}, - payload={'result': [{'id': uuid7().__str__(), 'score': 0.80, 'payload': {'content': bot_content}}]}, - method="POST", - status=200 - ) - hyperparameters = Utility.get_llm_hyperparameters("perplexity") - hyperparameters['search_domain_filter'] = ["domain1.com", "domain2.com"] - Actions(name=action_name, type=ActionType.prompt_action.value, bot=bot, user=user).save() - BotSettings(llm_settings=LLMSettings(enable_faq=True), bot=bot, user=user).save() - PromptAction(name=action_name, bot=bot, user=user, num_bot_responses=2, llm_prompts=llm_prompts, llm_type="perplexity", hyperparameters = hyperparameters, - user_question=UserQuestion(type="from_slot", value="prompt_question")).save() - llm_secret = LLMSecret( - llm_type=llm_type, - api_key=value, - models=["perplexity/llama-3.1-sonar-small-128k-online", "perplexity/llama-3.1-sonar-large-128k-online", "perplexity/llama-3.1-sonar-huge-128k-online"], - bot=bot, - user=user - ) - llm_secret.save() - llm_secret = LLMSecret( - llm_type="openai", - api_key="api_key", - models=["gpt-3.5-turbo", "gpt-4o-mini"], - bot=bot, - user=user - ) - llm_secret.save() - request_object = json.load(open("tests/testing_data/actions/action-request.json")) - request_object["tracker"]["slots"] = {"bot": bot, "prompt_question": user_msg} - request_object["next_action"] = action_name - request_object["tracker"]["sender_id"] = user - request_object['tracker']['events'] = [{"event": "user", 'text': 'hello', - "data": {"elements": '', "quick_replies": '', "buttons": '', - "attachment": '', "image": '', "custom": ''}}, - {'event': 'bot', "text": "how are you", - "data": {"elements": '', "quick_replies": '', "buttons": '', - "attachment": '', "image": '', "custom": ''}}] - response = client.post("/webhook", json=request_object) - response_json = response.json() - mock_execute_request_async.assert_called_once_with( - http_url=f"{Utility.environment['llm']['url']}/{urllib.parse.quote(bot)}/completion/{llm_type}", - request_method="POST", - request_body={ - 'messages': [{'role': 'system', 'content': 'You are a personal assistant. Answer question based on the context below.\n'}, - {'role': 'user', 'content': 'hello'}, - {'role': 'assistant', 'content': 'how are you'}, - {'role': 'user', 'content': "\nInstructions on how to use Similarity Prompt:\n['Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation. Python is dynamically typed and garbage-collected.']\nAnswer question based on the context above, if answer is not in the context go check previous logs.\n \nQ: What kind of language is python? inurl:domain1.com|domain2.com \nA:"}], - 'hyperparameters': hyperparameters, - 'user': user, - 'invocation': "prompt_action" - }, - timeout=Utility.environment['llm'].get('request_timeout', 30) - ) - called_args = mock_execute_request_async.call_args - user_message = called_args.kwargs['request_body']['messages'][-1]['content'] - assert "inurl:domain1.com|domain2.com" in user_message - assert response_json['events'] == [ - {'event': 'slot', 'timestamp': None, 'name': 'kairon_action_response', 'value': generated_text}] - assert response_json['responses'] == [ - {'text': generated_text, 'buttons': [], 'elements': [], 'custom': {}, 'template': None, - 'response': None, 'image': None, 'attachment': None} - ] @mock.patch.object(litellm, "aembedding", autospec=True) def test_prompt_action_response_action_with_prompt_question_from_slot_different_embedding_completion(mock_embedding, aioresponses): diff --git a/tests/integration_test/event_service_test.py b/tests/integration_test/event_service_test.py index 46135d175..55e859ace 100644 --- a/tests/integration_test/event_service_test.py +++ b/tests/integration_test/event_service_test.py @@ -533,4 +533,15 @@ def test_scheduled_event_request_dispatch(mock_dispatch_event): assert isinstance(args[1], JobEvent) assert isinstance(args[0], BackgroundScheduler) -os_patch.stop() \ No newline at end of file + +@patch('kairon.shared.channels.mail.scheduler.MailScheduler.epoch') +def test_request_epoch(mock_epoch): + response = client.get('/api/mail/request_epoch') + mock_epoch.assert_called_once() + assert response.status_code == 200 + resp = response.json() + assert resp['data'] is None + assert resp['success'] + +os_patch.stop() + diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py index c2d93468e..dc898ce88 100644 --- a/tests/unit_test/channels/mail_channel_test.py +++ b/tests/unit_test/channels/mail_channel_test.py @@ -64,8 +64,10 @@ def remove_basic_data(self): Account.objects(user="mail_channel_test_user_acc").delete() Channels.objects(connector_type=ChannelTypes.MAIL.value).delete() - def test_create_doc_new_entry(self): + @patch("kairon.shared.utils.Utility.execute_http_request") + def test_create_doc_new_entry(self, execute_http_request): self.create_basic_data() + execute_http_request.return_value = {"success": True} print(pytest.mail_test_bot) doc = MailClassificationConfig.create_doc( intent="greeting", @@ -239,8 +241,10 @@ def test_update_doc_invalid_key(self): @patch("kairon.shared.channels.mail.processor.LLMProcessor") @patch("kairon.shared.channels.mail.processor.MailBox") @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") - def test_login_imap(self, mock_get_channel_config, mock_mailbox, mock_llm_processor): + @patch("kairon.shared.utils.Utility.execute_http_request") + def test_login_imap(self, execute_http_req, mock_get_channel_config, mock_mailbox, mock_llm_processor): self.create_basic_data() + execute_http_req.return_value = {"success": True} mock_mailbox_instance = MagicMock() mock_mailbox.return_value = mock_mailbox_instance mock_mailbox_instance.login.return_value = ("OK", ["Logged in"]) @@ -273,8 +277,10 @@ def test_login_imap(self, mock_get_channel_config, mock_mailbox, mock_llm_proces @patch("kairon.shared.channels.mail.processor.LLMProcessor") @patch("kairon.shared.channels.mail.processor.MailBox") @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") - def test_login_imap_logout(self, mock_get_channel_config, mock_mailbox, mock_llm_processor): + @patch("kairon.shared.utils.Utility.execute_http_request") + def test_login_imap_logout(self,execute_http_request, mock_get_channel_config, mock_mailbox, mock_llm_processor): self.create_basic_data() + execute_http_request.return_value = {"success": True} mock_mailbox_instance = MagicMock() mock_mailbox.return_value = mock_mailbox_instance mock_mailbox_instance.login.return_value = mock_mailbox_instance # Ensure login returns the instance @@ -541,7 +547,7 @@ def test_extract_jsons_from_text(self): @patch("kairon.shared.channels.mail.processor.LLMProcessor") @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") @pytest.mark.asyncio - async def test_process_mails(self, mock_get_channel_config, mock_llm_processor, + async def test_read_mails(self, mock_get_channel_config, mock_llm_processor, mock_scheduler, mock_mailbox, mock_process_message_task, mock_logout_imap): bot_id = pytest.mail_test_bot @@ -575,10 +581,16 @@ async def test_process_mails(self, mock_get_channel_config, mock_llm_processor, mock_mailbox_instance.login.return_value = mock_mailbox_instance mock_mailbox_instance.fetch.return_value = [mock_mail_message] - message_count, time_shift = await MailProcessor.process_mails(bot_id) + mails, user, time_shift = MailProcessor.read_mails(bot_id) + print(mails) + assert len(mails) == 1 + assert mails[0]["subject"] == "Test Subject" + assert mails[0]["mail_id"] == "test@example.com" + assert mails[0]["date"] == "2023-10-10" + assert mails[0]["body"] == "Test Body" + assert user == 'mail_channel_test_user_acc' + assert time_shift == 1200 - assert message_count == 1 - assert time_shift == 300 # 5 minutes in seconds @@ -589,7 +601,7 @@ async def test_process_mails(self, mock_get_channel_config, mock_llm_processor, @patch("kairon.shared.channels.mail.processor.LLMProcessor") @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") @pytest.mark.asyncio - async def test_process_mails_no_messages(self, mock_get_channel_config, mock_llm_processor, + async def test_read_mails_no_messages(self, mock_get_channel_config, mock_llm_processor, mock_scheduler, mock_mailbox, mock_process_message_task, mock_logout_imap): bot_id = pytest.mail_test_bot @@ -616,10 +628,10 @@ async def test_process_mails_no_messages(self, mock_get_channel_config, mock_llm mock_mailbox_instance.login.return_value = mock_mailbox_instance mock_mailbox_instance.fetch.return_value = [] - message_count, time_shift = await MailProcessor.process_mails(bot_id) - - assert message_count == 0 - assert time_shift == 300 + mails, user, time_shift = MailProcessor.read_mails(bot_id) + assert len(mails) == 0 + assert user == 'mail_channel_test_user_acc' + assert time_shift == 1200 mock_logout_imap.assert_called_once() diff --git a/tests/unit_test/channels/mail_scheduler_test.py b/tests/unit_test/channels/mail_scheduler_test.py index f5504d2f6..656d46093 100644 --- a/tests/unit_test/channels/mail_scheduler_test.py +++ b/tests/unit_test/channels/mail_scheduler_test.py @@ -1,7 +1,11 @@ +from datetime import datetime, timedelta + import pytest from unittest.mock import patch, MagicMock, AsyncMock import os from kairon import Utility +from kairon.exceptions import AppException + os.environ["system_file"] = "./tests/testing_data/system.yaml" Utility.load_environment() Utility.load_system_metadata() @@ -12,17 +16,17 @@ def setup_environment(): with patch("pymongo.MongoClient") as mock_client, \ patch("kairon.shared.chat.data_objects.Channels.objects") as mock_channels, \ - patch("kairon.shared.channels.mail.processor.MailProcessor.process_mails", new_callable=AsyncMock) as mock_process_mails, \ + patch("kairon.shared.channels.mail.processor.MailProcessor.read_mails") as mock_read_mails, \ patch("apscheduler.schedulers.background.BackgroundScheduler", autospec=True) as mock_scheduler: mock_client_instance = mock_client.return_value - mock_channels.return_value = MagicMock(values_list=MagicMock(return_value=[{'bot': 'test_bot_1'}, {'bot': 'test_bot_2'}])) - mock_process_mails.return_value = ([], 60) # Mock responses and next_delay + mock_channels.return_value = [{'bot': 'test_bot_1'}, {'bot': 'test_bot_2'}] + mock_read_mails.return_value = ([], 'test@user.com', 60) # Mock responses and next_delay mock_scheduler_instance = mock_scheduler.return_value yield { 'mock_client': mock_client_instance, 'mock_channels': mock_channels, - 'mock_process_mails': mock_process_mails, + 'mock_read_mails': mock_read_mails, 'mock_scheduler': mock_scheduler_instance } @@ -30,7 +34,7 @@ def setup_environment(): async def test_mail_scheduler_epoch(setup_environment): # Arrange mock_scheduler = setup_environment['mock_scheduler'] - MailScheduler.mail_queue_name = "test_queue" + # MailScheduler.mail_queue_name = "test_queue" MailScheduler.scheduler = mock_scheduler # Act @@ -39,16 +43,15 @@ async def test_mail_scheduler_epoch(setup_environment): # Assert mock_scheduler.add_job.assert_called() -@pytest.mark.asyncio -async def test_mail_scheduler_process_mails(setup_environment): - mock_process_mails = setup_environment['mock_process_mails'] +def test_mail_scheduler_process_mails(setup_environment): + mock_read_mails = setup_environment['mock_read_mails'] mock_scheduler = setup_environment['mock_scheduler'] MailScheduler.scheduled_bots.add("test_bot_1") MailScheduler.scheduler = mock_scheduler - await MailScheduler.process_mails("test_bot_1", mock_scheduler) + MailScheduler.process_mails("test_bot_1", mock_scheduler) - mock_process_mails.assert_awaited_once_with("test_bot_1", mock_scheduler) + mock_read_mails.assert_called_once_with('test_bot_1') assert "test_bot_1" in MailScheduler.scheduled_bots @@ -56,17 +59,17 @@ async def test_mail_scheduler_process_mails(setup_environment): def setup_environment2(): with patch("pymongo.MongoClient") as mock_client, \ patch("kairon.shared.chat.data_objects.Channels.objects") as mock_channels, \ - patch("kairon.shared.channels.mail.processor.MailProcessor.process_mails", new_callable=AsyncMock) as mock_process_mails, \ + patch("kairon.shared.channels.mail.processor.MailProcessor.read_mails", new_callable=AsyncMock) as mock_read_mails, \ patch("apscheduler.jobstores.mongodb.MongoDBJobStore.__init__", return_value=None) as mock_jobstore_init: mock_client_instance = mock_client.return_value mock_channels.return_value = MagicMock(values_list=MagicMock(return_value=[{'bot': 'test_bot_1'}, {'bot': 'test_bot_2'}])) - mock_process_mails.return_value = ([], 60) + mock_read_mails.return_value = ([], 60) yield { 'mock_client': mock_client_instance, 'mock_channels': mock_channels, - 'mock_process_mails': mock_process_mails, + 'mock_read_mails': mock_read_mails, 'mock_jobstore_init': mock_jobstore_init, } @@ -82,3 +85,40 @@ async def test_mail_scheduler_epoch_creates_scheduler(setup_environment2): assert started assert MailScheduler.scheduler is not None mock_start.assert_called_once() + + +@patch('kairon.shared.channels.mail.scheduler.Utility.get_event_server_url') +@patch('kairon.shared.channels.mail.scheduler.Utility.execute_http_request') +def test_request_epoch_success(mock_execute_http_request, mock_get_event_server_url): + mock_get_event_server_url.return_value = "http://localhost" + mock_execute_http_request.return_value = {'success': True} + + try: + MailScheduler.request_epoch() + except AppException: + pytest.fail("request_epoch() raised AppException unexpectedly!") + +@patch('kairon.shared.channels.mail.scheduler.Utility.get_event_server_url') +@patch('kairon.shared.channels.mail.scheduler.Utility.execute_http_request') +def test_request_epoch_failure(mock_execute_http_request, mock_get_event_server_url): + mock_get_event_server_url.return_value = "http://localhost" + mock_execute_http_request.return_value = {'success': False} + + with pytest.raises(AppException): + MailScheduler.request_epoch() + + +@patch("kairon.shared.channels.mail.processor.MailProcessor.read_mails") +@patch("kairon.shared.channels.mail.scheduler.MailChannelScheduleEvent.enqueue") +@patch("kairon.shared.channels.mail.scheduler.datetime") +def test_read_mailbox_and_schedule_events(mock_datetime, mock_enqueue, mock_read_mails): + bot = "test_bot" + fixed_now = datetime(2024, 12, 1, 20, 41, 55, 390288) + mock_datetime.now.return_value = fixed_now + mock_read_mails.return_value = ([ + {"subject": "Test Subject", "mail_id": "test@example.com", "date": "2023-10-10", "body": "Test Body"} + ], "mail_channel_test_user_acc", 1200) + next_timestamp = MailScheduler.read_mailbox_and_schedule_events(bot) + mock_read_mails.assert_called_once_with(bot) + mock_enqueue.assert_called_once() + assert next_timestamp == fixed_now + timedelta(seconds=1200) \ No newline at end of file diff --git a/tests/unit_test/cli_test.py b/tests/unit_test/cli_test.py index ca3bb6123..9064726e3 100644 --- a/tests/unit_test/cli_test.py +++ b/tests/unit_test/cli_test.py @@ -315,7 +315,7 @@ def init_connection(self): def test_delete_logs(self, mock_args): cli() -class TestMailChanelCli: +class TestMailChannelCli: @pytest.fixture(autouse=True, scope='class') def init_connection(self): @@ -323,10 +323,12 @@ def init_connection(self): Utility.load_environment() connect(**Utility.mongoengine_connection(Utility.environment['database']["url"])) - def test_start_mail_channel(self): + @mock.patch("kairon.cli.mail_channel.MailChannelScheduleEvent.execute") + def test_start_mail_channel(self, mock_execute): from kairon.cli.mail_channel import process_channel_mails - with patch('argparse.ArgumentParser.parse_args', return_value=argparse.Namespace(func=process_channel_mails)): + with patch('argparse.ArgumentParser.parse_args', return_value=argparse.Namespace(func=process_channel_mails, bot="test_bot", user="test_user", mails=[{"mail": "test_mail"}])): cli() + mock_execute.assert_called_once() class TestMessageBroadcastCli: diff --git a/tests/unit_test/events/definitions_test.py b/tests/unit_test/events/definitions_test.py index 66f83ffd9..41103937d 100644 --- a/tests/unit_test/events/definitions_test.py +++ b/tests/unit_test/events/definitions_test.py @@ -1208,7 +1208,7 @@ def test_trigger_mail_channel_schedule_event_enqueue(self): url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False" responses.add( "POST", url, - json={"message": "Failed", "success": True, "error_code": 400, "data": None} + json={"message": "test msg", "success": True, "error_code": 400, "data": None} ) event = MailChannelScheduleEvent(bot, user) try: From fed613e68b45c37c459d4b4e517edf9967d35e26 Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Mon, 2 Dec 2024 09:22:27 +0530 Subject: [PATCH 11/27] test for proces_messages --- tests/unit_test/channels/mail_channel_test.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py index dc898ce88..010ddeaf6 100644 --- a/tests/unit_test/channels/mail_channel_test.py +++ b/tests/unit_test/channels/mail_channel_test.py @@ -656,6 +656,57 @@ async def test_classify_messages_invalid_llm_response(self, mock_get_channel_con assert not ans + @patch("kairon.shared.channels.mail.processor.MailProcessor.classify_messages") + @patch("kairon.shared.channels.mail.processor.MailProcessor.login_smtp") + @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_smtp") + @patch("kairon.shared.channels.mail.processor.MailProcessor.send_mail") + @patch("kairon.chat.utils.ChatUtils.process_messages_via_bot") + @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @pytest.mark.asyncio + async def test_process_messages(self, mock_llm_processor, mock_process_messages_via_bot, mock_send_mail, mock_logout_smtp, mock_login_smtp, + mock_classify_messages): + + + # Arrange + bot = pytest.mail_test_bot + batch = [{"mail_id": "test@example.com", "subject": "Test Subject", "date": "2023-10-10", "body": "Test Body"}] + mock_classify_messages.return_value = [{ + "intent": "test_intent", + "entities": {"entity_name": "value"}, + "mail_id": "test@example.com", + "subject": "Test Subject", + "name": "spandan" + }] + mock_process_messages_via_bot.return_value = [{ + "slots": ["name: spandan"], + "response": [{"text": "Test Response"}] + }] + + mock_llm_processor_instance = MagicMock() + mock_llm_processor.return_value = mock_llm_processor_instance + + # Act + await MailProcessor.process_messages(bot, batch) + + # Assert + mock_classify_messages.assert_called_once_with(batch) + mock_process_messages_via_bot.assert_called_once() + mock_login_smtp.assert_called_once() + mock_send_mail.assert_called_once() + mock_logout_smtp.assert_called_once() + + @patch("kairon.shared.channels.mail.processor.MailProcessor.classify_messages") + @pytest.mark.asyncio + async def test_process_messages_exception(self, mock_classify_messages): + # Arrange + bot = "test_bot" + batch = [{"mail_id": "test@example.com", "subject": "Test Subject", "date": "2023-10-10", "body": "Test Body"}] + mock_classify_messages.side_effect = Exception("Test Exception") + + # Act & Assert + with pytest.raises(AppException): + await MailProcessor.process_messages(bot, batch) + From 124bbf26edf2aac087aeedebf7a83045354d5636 Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Mon, 2 Dec 2024 09:34:12 +0530 Subject: [PATCH 12/27] cleanup --- kairon/api/app/routers/bot/bot.py | 1 - kairon/events/definitions/mail_channel_schedule.py | 2 +- kairon/shared/channels/mail/processor.py | 1 - kairon/shared/channels/mail/scheduler.py | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/kairon/api/app/routers/bot/bot.py b/kairon/api/app/routers/bot/bot.py index 382584e0d..d267a3b84 100644 --- a/kairon/api/app/routers/bot/bot.py +++ b/kairon/api/app/routers/bot/bot.py @@ -26,7 +26,6 @@ from kairon.shared.actions.data_objects import ActionServerLogs from kairon.shared.auth import Authentication from kairon.shared.channels.mail.data_objects import MailClassificationConfig -from kairon.shared.channels.mail.scheduler import MailScheduler from kairon.shared.constants import TESTER_ACCESS, DESIGNER_ACCESS, CHAT_ACCESS, UserActivityType, ADMIN_ACCESS, \ EventClass, AGENT_ACCESS from kairon.shared.content_importer.content_processor import ContentImporterLogProcessor diff --git a/kairon/events/definitions/mail_channel_schedule.py b/kairon/events/definitions/mail_channel_schedule.py index 8105dc495..2c2b10d14 100644 --- a/kairon/events/definitions/mail_channel_schedule.py +++ b/kairon/events/definitions/mail_channel_schedule.py @@ -3,7 +3,7 @@ from kairon import Utility from kairon.events.definitions.base import EventsBase from kairon.shared.channels.mail.processor import MailProcessor -from kairon.shared.constants import EventClass, ChannelTypes +from kairon.shared.constants import EventClass class MailChannelScheduleEvent(EventsBase): diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index cc1d23d9a..22cba5a10 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -1,7 +1,6 @@ import asyncio import re -from apscheduler.schedulers.background import BackgroundScheduler from loguru import logger from pydantic.schema import timedelta from pydantic.validators import datetime diff --git a/kairon/shared/channels/mail/scheduler.py b/kairon/shared/channels/mail/scheduler.py index c7ef86758..5ae557715 100644 --- a/kairon/shared/channels/mail/scheduler.py +++ b/kairon/shared/channels/mail/scheduler.py @@ -1,4 +1,3 @@ -import asyncio from datetime import datetime, timedelta from urllib.parse import urljoin From 67623e669f198d0ea78c660c373c93800a7076ca Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Mon, 2 Dec 2024 10:07:44 +0530 Subject: [PATCH 13/27] test fix --- tests/unit_test/channels/mail_channel_test.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py index 010ddeaf6..c798c1203 100644 --- a/tests/unit_test/channels/mail_channel_test.py +++ b/tests/unit_test/channels/mail_channel_test.py @@ -543,12 +543,11 @@ def test_extract_jsons_from_text(self): @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap") @patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task") @patch("kairon.shared.channels.mail.processor.MailBox") - @patch("kairon.shared.channels.mail.processor.BackgroundScheduler") @patch("kairon.shared.channels.mail.processor.LLMProcessor") @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") @pytest.mark.asyncio async def test_read_mails(self, mock_get_channel_config, mock_llm_processor, - mock_scheduler, mock_mailbox, mock_process_message_task, + mock_mailbox, mock_process_message_task, mock_logout_imap): bot_id = pytest.mail_test_bot @@ -565,8 +564,6 @@ async def test_read_mails(self, mock_get_channel_config, mock_llm_processor, mock_llm_processor_instance = MagicMock() mock_llm_processor.return_value = mock_llm_processor_instance - scheduler_instance = MagicMock() - mock_scheduler.return_value = scheduler_instance mock_mailbox_instance = MagicMock() mock_mailbox.return_value = mock_mailbox_instance @@ -597,12 +594,11 @@ async def test_read_mails(self, mock_get_channel_config, mock_llm_processor, @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap") @patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task") @patch("kairon.shared.channels.mail.processor.MailBox") - @patch("kairon.shared.channels.mail.processor.BackgroundScheduler") @patch("kairon.shared.channels.mail.processor.LLMProcessor") @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") @pytest.mark.asyncio async def test_read_mails_no_messages(self, mock_get_channel_config, mock_llm_processor, - mock_scheduler, mock_mailbox, mock_process_message_task, + mock_mailbox, mock_process_message_task, mock_logout_imap): bot_id = pytest.mail_test_bot @@ -619,8 +615,6 @@ async def test_read_mails_no_messages(self, mock_get_channel_config, mock_llm_pr mock_llm_processor_instance = MagicMock() mock_llm_processor.return_value = mock_llm_processor_instance - scheduler_instance = MagicMock() - mock_scheduler.return_value = scheduler_instance mock_mailbox_instance = MagicMock() mock_mailbox.return_value = mock_mailbox_instance From 6a51ffe97cf0fee88fcc30066878cb1dc2e59f11 Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Mon, 2 Dec 2024 12:35:20 +0530 Subject: [PATCH 14/27] more tests --- .../definitions/mail_channel_schedule.py | 5 +-- tests/unit_test/chat/chat_test.py | 32 ++++++++++++++++++- tests/unit_test/events/definitions_test.py | 30 ++++++++++++++++- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/kairon/events/definitions/mail_channel_schedule.py b/kairon/events/definitions/mail_channel_schedule.py index 2c2b10d14..de6b9485a 100644 --- a/kairon/events/definitions/mail_channel_schedule.py +++ b/kairon/events/definitions/mail_channel_schedule.py @@ -2,6 +2,7 @@ from loguru import logger from kairon import Utility from kairon.events.definitions.base import EventsBase +from kairon.exceptions import AppException from kairon.shared.channels.mail.processor import MailProcessor from kairon.shared.constants import EventClass @@ -35,7 +36,7 @@ def enqueue(self, **kwargs): Utility.request_event_server(EventClass.email_channel_scheduler, payload) except Exception as e: logger.error(str(e)) - raise e + raise AppException(e) def execute(self, **kwargs): """ @@ -47,4 +48,4 @@ def execute(self, **kwargs): MailProcessor.process_message_task(self.bot, mails) except Exception as e: logger.error(str(e)) - raise e \ No newline at end of file + raise AppException(e) \ No newline at end of file diff --git a/tests/unit_test/chat/chat_test.py b/tests/unit_test/chat/chat_test.py index 36f5b1000..94451e754 100644 --- a/tests/unit_test/chat/chat_test.py +++ b/tests/unit_test/chat/chat_test.py @@ -1,4 +1,5 @@ import time +from unittest.mock import MagicMock, patch, AsyncMock import ujson as json import os @@ -933,4 +934,33 @@ async def test_mongotracker_save(self): data = list(store.client.get_database(config['db']).get_collection(bot).find({'type': 'flattened'})) assert len(data) == 1 assert data[0]['tag'] == 'tracker_store' - assert data[0]['type'] == 'flattened' \ No newline at end of file + assert data[0]['type'] == 'flattened' + + + + +@pytest.mark.asyncio +@patch("kairon.chat.utils.AgentProcessor.get_agent_without_cache") +@patch("kairon.chat.utils.ChatUtils.get_metadata") +async def test_process_messages_via_bot(mock_get_metadata, mock_get_agent_without_cache): + messages = ["/greet", "/bye"] + account = 1 + bot = "test_bot" + user = "test_user" + is_integration_user = False + metadata = {"key": "value"} + + mock_get_metadata.return_value = metadata + mock_model = MagicMock() + mock_get_agent_without_cache.return_value = mock_model + mock_model.handle_message = AsyncMock(side_effect=[{"text": "Hello"}, {"text": "Goodbye"}]) + from kairon.chat.utils import ChatUtils + + responses = await ChatUtils.process_messages_via_bot(messages, account, bot, user, is_integration_user, metadata) + + assert len(responses) == 2 + assert responses[0] == {"text": "Hello"} + assert responses[1] == {"text": "Goodbye"} + mock_get_metadata.assert_called_once_with(account, bot, is_integration_user, metadata) + mock_get_agent_without_cache.assert_called_once_with(bot, False) + assert mock_model.handle_message.call_count == 2 diff --git a/tests/unit_test/events/definitions_test.py b/tests/unit_test/events/definitions_test.py index 41103937d..01d0fe7a3 100644 --- a/tests/unit_test/events/definitions_test.py +++ b/tests/unit_test/events/definitions_test.py @@ -1216,10 +1216,38 @@ def test_trigger_mail_channel_schedule_event_enqueue(self): except AppException as e: pytest.fail(f"Unexpected exception: {e}") + @responses.activate + def test_trigger_mail_channel_schedule_event_enqueue_exception(self): + from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + from kairon.exceptions import AppException + from unittest.mock import patch + + bot = "test_add_schedule_event" + user = "test_user" + url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False" + responses.add( + "POST", url, + json={"message": "test msg", "success": False, "error_code": 400, "data": None} + ) + event = MailChannelScheduleEvent(bot, user) + with pytest.raises(AppException, match="Failed to trigger email_channel_scheduler event: test msg"): + event.enqueue() + @responses.activate def test_trigger_mail_channel_schedule_event_execute(self): from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent try: MailChannelScheduleEvent("", "").execute() except AppException as e: - pytest.fail(f"Unexpected exception: {e}") \ No newline at end of file + pytest.fail(f"Unexpected exception: {e}") + + @responses.activate + def test_trigger_mail_channel_schedule_event_execute_exception(self): + from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + from kairon.exceptions import AppException + from unittest.mock import patch + + with patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task", + side_effect=Exception("Test")): + with pytest.raises(AppException, match="Test"): + MailChannelScheduleEvent("", "").execute(mails=["test@mail.com"]) \ No newline at end of file From b978fbed805c480576ef175dad9909979a6b6700 Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Mon, 2 Dec 2024 12:40:39 +0530 Subject: [PATCH 15/27] more tests --- kairon/chat/utils.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/kairon/chat/utils.py b/kairon/chat/utils.py index 41cf95660..c0e909355 100644 --- a/kairon/chat/utils.py +++ b/kairon/chat/utils.py @@ -52,6 +52,18 @@ async def process_messages_via_bot( is_integration_user: bool = False, metadata: Dict = None, ): + """Process a list of messages through the bot. + Args: + messages: List of messages to process + account: Account ID + bot: Bot ID + user: User ID + is_integration_user: Flag indicating if user is integration user + metadata: Additional metadata + + Returns: + List of responses from the bot + """ responses = [] uncached_model = AgentProcessor.get_agent_without_cache(bot, False) metadata = ChatUtils.get_metadata(account, bot, is_integration_user, metadata) From b3e39018409f760d0538fc61fa0fe635a458104c Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Wed, 4 Dec 2024 15:25:26 +0530 Subject: [PATCH 16/27] mail event validation --- kairon/cli/mail_channel.py | 10 ++++-- .../definitions/mail_channel_schedule.py | 2 +- kairon/shared/channels/mail/processor.py | 11 ++++++ kairon/shared/channels/mail/scheduler.py | 6 ++-- tests/unit_test/channels/mail_channel_test.py | 29 +++++++++++++++ tests/unit_test/cli_test.py | 21 ++++++++++- tests/unit_test/events/definitions_test.py | 35 +++++++++++++++++++ 7 files changed, 107 insertions(+), 7 deletions(-) diff --git a/kairon/cli/mail_channel.py b/kairon/cli/mail_channel.py index 9fb2896d3..589b4c8c0 100644 --- a/kairon/cli/mail_channel.py +++ b/kairon/cli/mail_channel.py @@ -1,3 +1,4 @@ +import json from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter from typing import List from rasa.cli import SubParsersAction @@ -6,7 +7,10 @@ def process_channel_mails(args): - MailChannelScheduleEvent(args.bot, args.user).execute(mails=args.mails) + mails = json.loads(args.mails) + if not isinstance(mails, list): + raise ValueError("Mails should be a list") + MailChannelScheduleEvent(args.bot, args.user).execute(mails=mails) def add_subparser(subparsers: SubParsersAction, parents: List[ArgumentParser]): @@ -26,7 +30,7 @@ def add_subparser(subparsers: SubParsersAction, parents: List[ArgumentParser]): help="Kairon user who is initiating the command", action='store') mail_parser.add_argument('mails', - type=list, - help="List of mails to be processed", action='store') + type=str, + help="json representing List of mails to be processed", action='store') mail_parser.set_defaults(func=process_channel_mails) \ No newline at end of file diff --git a/kairon/events/definitions/mail_channel_schedule.py b/kairon/events/definitions/mail_channel_schedule.py index de6b9485a..6212f4740 100644 --- a/kairon/events/definitions/mail_channel_schedule.py +++ b/kairon/events/definitions/mail_channel_schedule.py @@ -23,7 +23,7 @@ def validate(self): """ validate mail channel exists """ - pass + return MailProcessor.validate_smpt_connection(self.bot) def enqueue(self, **kwargs): diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index 22cba5a10..7fe2bc716 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -66,6 +66,17 @@ def logout_smtp(self): self.smtp.quit() self.smtp = None + + @staticmethod + def validate_smpt_connection(bot): + try: + mp = MailProcessor(bot) + mp.login_smtp() + mp.logout_smtp() + return True + except Exception as e: + return False + async def send_mail(self, to: str, subject: str, body: str): try: email_account = self.config['email_account'] diff --git a/kairon/shared/channels/mail/scheduler.py b/kairon/shared/channels/mail/scheduler.py index 5ae557715..4d447bb0a 100644 --- a/kairon/shared/channels/mail/scheduler.py +++ b/kairon/shared/channels/mail/scheduler.py @@ -57,7 +57,7 @@ def request_epoch(): event_server_url, "/api/mail/request_epoch", ), - err_msg=f"Failed to request epoch", + err_msg="Failed to request epoch", ) if not resp['success']: raise AppException("Failed to request email channel epoch") @@ -78,7 +78,9 @@ def read_mailbox_and_schedule_events(bot) -> datetime: print(vals) emails, user, next_delay = vals for email in emails: - MailChannelScheduleEvent(bot, user).enqueue(mails=[email]) + ev = MailChannelScheduleEvent(bot, user) + ev.validate() + ev.enqueue(mails=[email]) next_timestamp = datetime.now() + timedelta(seconds=next_delay) return next_timestamp diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py index c798c1203..b8ad61c5d 100644 --- a/tests/unit_test/channels/mail_channel_test.py +++ b/tests/unit_test/channels/mail_channel_test.py @@ -701,7 +701,36 @@ async def test_process_messages_exception(self, mock_classify_messages): with pytest.raises(AppException): await MailProcessor.process_messages(bot, batch) + @patch('kairon.shared.channels.mail.processor.MailProcessor.__init__') + @patch('kairon.shared.channels.mail.processor.MailProcessor.login_smtp') + @patch('kairon.shared.channels.mail.processor.MailProcessor.logout_smtp') + def test_validate_smpt_connection(self, mp, mock_logout_smtp, mock_login_smtp): + # Mock the login and logout methods to avoid actual SMTP server interaction + mp.return_value = None + mock_login_smtp.return_value = None + mock_logout_smtp.return_value = None + + # Call the static method validate_smpt_connection + result = MailProcessor.validate_smpt_connection('test_bot_id') + + # Assert that the method returns True + assert result + + # Assert that login_smtp and logout_smtp were called once + mock_login_smtp.assert_called_once() + mock_logout_smtp.assert_called_once() + + @patch('kairon.shared.channels.mail.processor.MailProcessor.login_smtp') + @patch('kairon.shared.channels.mail.processor.MailProcessor.logout_smtp') + def test_validate_smpt_connection_failure(self, mock_logout_smtp, mock_login_smtp): + # Mock the login method to raise an exception + mock_login_smtp.side_effect = Exception("SMTP login failed") + + # Call the static method validate_smpt_connection + result = MailProcessor.validate_smpt_connection('test_bot_id') + # Assert that the method returns False + assert not result diff --git a/tests/unit_test/cli_test.py b/tests/unit_test/cli_test.py index 9064726e3..5cf37b51f 100644 --- a/tests/unit_test/cli_test.py +++ b/tests/unit_test/cli_test.py @@ -1,4 +1,5 @@ import argparse +import json import os from datetime import datetime from unittest import mock @@ -326,11 +327,29 @@ def init_connection(self): @mock.patch("kairon.cli.mail_channel.MailChannelScheduleEvent.execute") def test_start_mail_channel(self, mock_execute): from kairon.cli.mail_channel import process_channel_mails - with patch('argparse.ArgumentParser.parse_args', return_value=argparse.Namespace(func=process_channel_mails, bot="test_bot", user="test_user", mails=[{"mail": "test_mail"}])): + data = [{"mail": "test_mail"}] + data = json.dumps(data) + with patch('argparse.ArgumentParser.parse_args', + return_value=argparse.Namespace(func=process_channel_mails, + bot="test_bot", + user="test_user", mails=data)): cli() mock_execute.assert_called_once() + @mock.patch("kairon.cli.mail_channel.MailChannelScheduleEvent.execute") + def test_start_mail_channel_wrong_format(self, mock_execute): + from kairon.cli.mail_channel import process_channel_mails + data = {"mail": "test_mail"} + data = json.dumps(data) + with patch('argparse.ArgumentParser.parse_args', + return_value=argparse.Namespace(func=process_channel_mails, + bot="test_bot", + user="test_user", mails=data)): + with pytest.raises(ValueError): + cli() + mock_execute.assert_not_called() + class TestMessageBroadcastCli: @pytest.fixture(autouse=True, scope="class") diff --git a/tests/unit_test/events/definitions_test.py b/tests/unit_test/events/definitions_test.py index 01d0fe7a3..fccef2737 100644 --- a/tests/unit_test/events/definitions_test.py +++ b/tests/unit_test/events/definitions_test.py @@ -1200,6 +1200,41 @@ def test_delete_message_broadcast(self): MessageBroadcastProcessor.get_settings(setting_id, bot) + + @responses.activate + def test_validate_mail_channel_schedule_event(self): + from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + bot = "test_add_schedule_event" + user = "test_user" + url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False" + responses.add( + "POST", url, + json={"message": "test msg", "success": True, "error_code": 400, "data": None} + ) + with patch('kairon.shared.channels.mail.processor.MailProcessor.__init__', return_value=None) as mp: + with patch('kairon.shared.channels.mail.processor.MailProcessor.login_smtp', return_value=None) as mock_login: + with patch('kairon.shared.channels.mail.processor.MailProcessor.logout_smtp', return_value=None) as mock_logout: + + event = MailChannelScheduleEvent(bot, user) + status = event.validate() + assert status + + @responses.activate + def test_validate_mail_channel_schedule_event_fail(self): + from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + bot = "test_add_schedule_event" + user = "test_user" + url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False" + responses.add( + "POST", url, + json={"message": "test msg", "success": True, "error_code": 400, "data": None} + ) + event = MailChannelScheduleEvent(bot, user) + status = event.validate() + assert not status + + + @responses.activate def test_trigger_mail_channel_schedule_event_enqueue(self): from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent From 1620374b0c8055987e093fde410117b3e2cf6372 Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Wed, 4 Dec 2024 15:40:39 +0530 Subject: [PATCH 17/27] more tests --- kairon/shared/channels/mail/processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index 7fe2bc716..8841e7111 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -74,7 +74,7 @@ def validate_smpt_connection(bot): mp.login_smtp() mp.logout_smtp() return True - except Exception as e: + except: return False async def send_mail(self, to: str, subject: str, body: str): From fd49bf2b72e5af459be5c75c15f6152d2eadc362 Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Wed, 4 Dec 2024 17:12:10 +0530 Subject: [PATCH 18/27] more tests --- kairon/shared/channels/mail/processor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index 8841e7111..4ff1c2669 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -74,7 +74,8 @@ def validate_smpt_connection(bot): mp.login_smtp() mp.logout_smtp() return True - except: + except Exception as e: + logger.log(str(e)) return False async def send_mail(self, to: str, subject: str, body: str): From 51642ca0b04a7824fdb7873bdf29980c6863fe0d Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Wed, 4 Dec 2024 18:28:55 +0530 Subject: [PATCH 19/27] fix --- kairon/shared/channels/mail/processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index 4ff1c2669..821b6cd28 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -75,7 +75,7 @@ def validate_smpt_connection(bot): mp.logout_smtp() return True except Exception as e: - logger.log(str(e)) + logger.error(str(e)) return False async def send_mail(self, to: str, subject: str, body: str): From d4eb5d0d5fbb08b745fd361f2f3ca727755db561 Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Fri, 6 Dec 2024 11:11:57 +0530 Subject: [PATCH 20/27] fix --- kairon/__init__.py | 5 +- kairon/api/app/routers/bot/bot.py | 62 +----- ...ail_channel.py => mail_channel_process.py} | 8 +- kairon/cli/mail_channel_read.py | 29 +++ kairon/events/definitions/factory.py | 5 +- kairon/events/definitions/mail_channel.py | 99 +++++++++ .../definitions/mail_channel_schedule.py | 51 ----- kairon/events/server.py | 9 +- kairon/events/utility.py | 25 +++ kairon/shared/channels/mail/data_objects.py | 148 +++---------- kairon/shared/channels/mail/processor.py | 201 ++++++++++-------- kairon/shared/channels/mail/scheduler.py | 67 +----- kairon/shared/chat/processor.py | 2 +- kairon/shared/constants.py | 3 +- system.yaml | 2 +- tests/unit_test/channels/mail_channel_test.py | 4 +- .../unit_test/channels/mail_scheduler_test.py | 4 +- tests/unit_test/cli_test.py | 4 +- tests/unit_test/events/definitions_test.py | 32 +-- 19 files changed, 345 insertions(+), 415 deletions(-) rename kairon/cli/{mail_channel.py => mail_channel_process.py} (84%) create mode 100644 kairon/cli/mail_channel_read.py create mode 100644 kairon/events/definitions/mail_channel.py delete mode 100644 kairon/events/definitions/mail_channel_schedule.py diff --git a/kairon/__init__.py b/kairon/__init__.py index cb4cc9f2d..7f04b40b1 100644 --- a/kairon/__init__.py +++ b/kairon/__init__.py @@ -45,7 +45,7 @@ def create_argument_parser(): from kairon.cli import importer, training, testing, conversations_deletion, translator, delete_logs,\ - message_broadcast,content_importer, mail_channel + message_broadcast,content_importer, mail_channel_process, mail_channel_read parser = ArgumentParser( prog="kairon", @@ -63,7 +63,8 @@ def create_argument_parser(): delete_logs.add_subparser(subparsers, parents=parent_parsers) message_broadcast.add_subparser(subparsers, parents=parent_parsers) content_importer.add_subparser(subparsers, parents=parent_parsers) - mail_channel.add_subparser(subparsers, parents=parent_parsers) + mail_channel_process.add_subparser(subparsers, parents=parent_parsers) + mail_channel_read.add_subparser(subparsers, parents=parent_parsers) return parser diff --git a/kairon/api/app/routers/bot/bot.py b/kairon/api/app/routers/bot/bot.py index d267a3b84..6fe85cb2d 100644 --- a/kairon/api/app/routers/bot/bot.py +++ b/kairon/api/app/routers/bot/bot.py @@ -25,7 +25,7 @@ from kairon.shared.account.activity_log import UserActivityLogger from kairon.shared.actions.data_objects import ActionServerLogs from kairon.shared.auth import Authentication -from kairon.shared.channels.mail.data_objects import MailClassificationConfig +from kairon.shared.channels.mail.processor import MailProcessor from kairon.shared.constants import TESTER_ACCESS, DESIGNER_ACCESS, CHAT_ACCESS, UserActivityType, ADMIN_ACCESS, \ EventClass, AGENT_ACCESS from kairon.shared.content_importer.content_processor import ContentImporterLogProcessor @@ -1661,59 +1661,13 @@ async def get_slot_actions( return Response(data=llm_models) -@router.get("/mail/config", response_model=Response) -async def get_all_mail_configs( - current_user: User = Security(Authentication.get_current_user_and_bot, scopes=TESTER_ACCESS)): - """ - Fetches mail config - """ - data = MailClassificationConfig.objects(bot=current_user.get_bot(), status=True) - formatted_data = [ - {key: value for key, value in item.to_mongo().items() if key not in {"_id", "user"}} - for item in data - ] - - return {"data": formatted_data} - - - -@router.post("/mail/config", response_model=Response) -async def set_mail_config( - request_data: MailConfigRequest, - current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) -): - """ - Applies the mail config - """ - request_dict = request_data.dict() - MailClassificationConfig.create_doc(**request_dict, bot=current_user.get_bot(), user=current_user.get_user()) - return {"message": "Config applied!"} - - -@router.put("/mail/config", response_model=Response) -async def update_mail_config( - request_data: MailConfigRequest, - current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) -): - """ - update the mail config - """ - request_dict = request_data.dict() - MailClassificationConfig.update_doc(**request_dict, bot=current_user.get_bot()) - return {"message": "Config updated!"} - - -@router.delete("/mail/config/{intent}", response_model=Response) -async def del_soft_mail_config( - intent: str, - current_user: User = Security(Authentication.get_current_user_and_bot, scopes=DESIGNER_ACCESS) -): +@router.get("/mail_channel/logs", response_model=Response) +async def get_action_server_logs(start_idx: int = 0, page_size: int = 10, + current_user: User = Security(Authentication.get_current_user_and_bot, + scopes=TESTER_ACCESS)): """ - delete the mail config + Retrieves mail channel related logs for the bot. """ - MailClassificationConfig.soft_delete_doc(current_user.get_bot(), intent) - return {"message": "Config deleted!"} - - - + data = MailProcessor.get_log(current_user.get_bot(), start_idx, page_size) + return Response(data=data) diff --git a/kairon/cli/mail_channel.py b/kairon/cli/mail_channel_process.py similarity index 84% rename from kairon/cli/mail_channel.py rename to kairon/cli/mail_channel_process.py index 589b4c8c0..21a734b32 100644 --- a/kairon/cli/mail_channel.py +++ b/kairon/cli/mail_channel_process.py @@ -3,23 +3,23 @@ from typing import List from rasa.cli import SubParsersAction -from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent +from kairon.events.definitions.mail_channel import MailProcessEvent def process_channel_mails(args): mails = json.loads(args.mails) if not isinstance(mails, list): raise ValueError("Mails should be a list") - MailChannelScheduleEvent(args.bot, args.user).execute(mails=mails) + MailProcessEvent(args.bot, args.user).execute(mails=mails) def add_subparser(subparsers: SubParsersAction, parents: List[ArgumentParser]): mail_parser = subparsers.add_parser( - "mail-channel", + "mail-channel-process", conflict_handler="resolve", formatter_class=ArgumentDefaultsHelpFormatter, parents=parents, - help="Mail channel" + help="Mail channel process mails" ) mail_parser.add_argument('bot', type=str, diff --git a/kairon/cli/mail_channel_read.py b/kairon/cli/mail_channel_read.py new file mode 100644 index 000000000..274bae61b --- /dev/null +++ b/kairon/cli/mail_channel_read.py @@ -0,0 +1,29 @@ +import json +from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter +from typing import List +from rasa.cli import SubParsersAction + +from kairon.events.definitions.mail_channel import MailProcessEvent, MailReadEvent + + +def read_channel_mails(args): + MailReadEvent(args.bot, args.user).execute() + + +def add_subparser(subparsers: SubParsersAction, parents: List[ArgumentParser]): + mail_parser = subparsers.add_parser( + "mail-channel-read", + conflict_handler="resolve", + formatter_class=ArgumentDefaultsHelpFormatter, + parents=parents, + help="Mail channel initiate reading" + ) + mail_parser.add_argument('bot', + type=str, + help="Bot id for which command is executed", action='store') + + mail_parser.add_argument('user', + type=str, + help="Kairon user who is initiating the command", action='store') + + mail_parser.set_defaults(func=read_channel_mails) \ No newline at end of file diff --git a/kairon/events/definitions/factory.py b/kairon/events/definitions/factory.py index dd754a92f..31adda787 100644 --- a/kairon/events/definitions/factory.py +++ b/kairon/events/definitions/factory.py @@ -2,7 +2,7 @@ from kairon.events.definitions.data_importer import TrainingDataImporterEvent from kairon.events.definitions.faq_importer import FaqDataImporterEvent from kairon.events.definitions.history_delete import DeleteHistoryEvent -from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent +from kairon.events.definitions.mail_channel import MailProcessEvent, MailReadEvent from kairon.events.definitions.message_broadcast import MessageBroadcastEvent from kairon.events.definitions.model_testing import ModelTestingEvent from kairon.events.definitions.model_training import ModelTrainingEvent @@ -22,7 +22,8 @@ class EventFactory: EventClass.faq_importer: FaqDataImporterEvent, EventClass.message_broadcast: MessageBroadcastEvent, EventClass.content_importer: DocContentImporterEvent, - EventClass.email_channel_scheduler: MailChannelScheduleEvent + EventClass.mail_channel_read_mails: MailReadEvent, + EventClass.mail_channel_process_mails: MailProcessEvent } @staticmethod diff --git a/kairon/events/definitions/mail_channel.py b/kairon/events/definitions/mail_channel.py new file mode 100644 index 000000000..30d5b98cf --- /dev/null +++ b/kairon/events/definitions/mail_channel.py @@ -0,0 +1,99 @@ +from typing import Text +from loguru import logger +from kairon import Utility +from kairon.events.definitions.base import EventsBase +from kairon.exceptions import AppException +from kairon.shared.channels.mail.processor import MailProcessor +from kairon.shared.constants import EventClass + + +class MailProcessEvent(EventsBase): + """ + Event to start mail channel scheduler if not already running. + """ + + def __init__(self, bot: Text, user: Text, **kwargs): + """ + Initialise event. + """ + self.bot = bot + self.user = user + + def validate(self): + """ + validate mail channel exists + """ + return MailProcessor.validate_smtp_connection(self.bot) + + + def enqueue(self, **kwargs): + """ + Send event to event server. + """ + try: + mails: list = kwargs.get('mails', []) + payload = {'bot': self.bot, 'user': self.user, 'mails': mails} + Utility.request_event_server(EventClass.mail_channel_process_mails, payload) + except Exception as e: + logger.error(str(e)) + raise AppException(e) + + def execute(self, **kwargs): + """ + Execute the event. + """ + try: + mails = kwargs.get('mails') + if mails: + MailProcessor.process_message_task(self.bot, mails) + except Exception as e: + logger.error(str(e)) + raise AppException(e) + + + +class MailReadEvent(EventsBase): + """ + Event to read mails from mail channel and create events for each mail tp process them via bot + """ + + def __init__(self, bot: Text, user: Text, **kwargs): + """ + Initialise event. + """ + self.bot = bot + self.user = user + + def validate(self): + """ + validate mail channel exists + """ + return MailProcessor.validate_imap_connection(self.bot) + + def enqueue(self, **kwargs): + """ + Send event to event server. + """ + try: + payload = {'bot': self.bot, 'user': self.user} + Utility.request_event_server(EventClass.mail_channel_read_mails, payload) + except Exception as e: + logger.error(str(e)) + raise AppException(e) + + def execute(self, **kwargs): + """ + Execute the event. + """ + try: + vals = MailProcessor.read_mails(self.bot) + print(vals) + emails, user, next_delay = vals + for email in emails: + ev = MailProcessEvent(self.bot, self.user) + ev.validate() + ev.enqueue(mails=[email]) + + except Exception as e: + raise AppException(f"Failed to schedule mail reading for bot {self.bot}. Error: {str(e)}") + is_initialized = False \ No newline at end of file diff --git a/kairon/events/definitions/mail_channel_schedule.py b/kairon/events/definitions/mail_channel_schedule.py deleted file mode 100644 index 6212f4740..000000000 --- a/kairon/events/definitions/mail_channel_schedule.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import Text -from loguru import logger -from kairon import Utility -from kairon.events.definitions.base import EventsBase -from kairon.exceptions import AppException -from kairon.shared.channels.mail.processor import MailProcessor -from kairon.shared.constants import EventClass - - -class MailChannelScheduleEvent(EventsBase): - """ - Event to start mail channel scheduler if not already running. - """ - - def __init__(self, bot: Text, user: Text, **kwargs): - """ - Initialise event. - """ - self.bot = bot - self.user = user - - def validate(self): - """ - validate mail channel exists - """ - return MailProcessor.validate_smpt_connection(self.bot) - - - def enqueue(self, **kwargs): - """ - Send event to event server. - """ - try: - mails: list = kwargs.get('mails', []) - payload = {'bot': self.bot, 'user': self.user, 'mails': mails} - Utility.request_event_server(EventClass.email_channel_scheduler, payload) - except Exception as e: - logger.error(str(e)) - raise AppException(e) - - def execute(self, **kwargs): - """ - Execute the event. - """ - try: - mails = kwargs.get('mails') - if mails: - MailProcessor.process_message_task(self.bot, mails) - except Exception as e: - logger.error(str(e)) - raise AppException(e) \ No newline at end of file diff --git a/kairon/events/server.py b/kairon/events/server.py index 7aef6fffe..3afa312b4 100644 --- a/kairon/events/server.py +++ b/kairon/events/server.py @@ -56,8 +56,6 @@ async def lifespan(app: FastAPI): """ MongoDB is connected on the bot trainer startup """ config: dict = Utility.mongoengine_connection(Utility.environment['database']["url"]) connect(**config) - from kairon.shared.channels.mail.scheduler import MailScheduler - MailScheduler.epoch() yield disconnect() @@ -148,8 +146,7 @@ def dispatch_scheduled_event(event_id: Text = Path(description="Event id")): return {"data": None, "message": "Scheduled event dispatch!"} -@app.get('/api/mail/request_epoch', response_model=Response) -def request_epoch(): - from kairon.shared.channels.mail.scheduler import MailScheduler - MailScheduler.epoch() +@app.get('/api/mail/schedule/{bot}', response_model=Response) +def request_epoch(bot: Text = Path(description="Bot id")): + EventUtility.schedule_channel_mail_reading(bot) return {"data": None, "message": "Mail scheduler epoch request!"} \ No newline at end of file diff --git a/kairon/events/utility.py b/kairon/events/utility.py index 2d9202b36..469de9f7b 100644 --- a/kairon/events/utility.py +++ b/kairon/events/utility.py @@ -1,8 +1,11 @@ from typing import Dict, Text +from uuid6 import uuid7 + from kairon.events.executors.factory import ExecutorFactory from kairon.events.scheduler.kscheduler import KScheduler from kairon.exceptions import AppException +from kairon.shared.constants import EventClass from kairon.shared.data.constant import TASK_TYPE @@ -32,3 +35,25 @@ def update_job(event_type: Text, request_data: Dict, is_scheduled: bool): KScheduler().update_job(event_class=event_type, event_id=event_id, task_type=TASK_TYPE.EVENT.value, **request_data) return None, 'Scheduled event updated!' + + @staticmethod + def schedule_channel_mail_reading(bot: str): + try: + from kairon.shared.channels.mail.processor import MailProcessor + mail_processor = MailProcessor(bot) + interval = mail_processor.config.get("interval", 60) + event_id = mail_processor.config.get("event_id", None) + if event_id: + KScheduler().update_job(event_id, + TASK_TYPE.EVENT, + f"*/{interval} * * * *", + EventClass.mail_channel_read_mails, {"bot": bot}) + else: + event_id = uuid7().hex + MailProcessor.update_event_id(bot, event_id) + KScheduler().add_job(event_id, + TASK_TYPE.EVENT, + f"*/{interval} * * * *", + EventClass.mail_channel_read_mails, {"bot": bot}) + except Exception as e: + raise AppException(f"Failed to schedule mail reading for bot {bot}. Error: {str(e)}") diff --git a/kairon/shared/channels/mail/data_objects.py b/kairon/shared/channels/mail/data_objects.py index 513c95c43..391443e33 100644 --- a/kairon/shared/channels/mail/data_objects.py +++ b/kairon/shared/channels/mail/data_objects.py @@ -1,125 +1,27 @@ import time -from mongoengine import Document, StringField, ListField, FloatField, BooleanField -from kairon.exceptions import AppException - - - - -class MailClassificationConfig(Document): - intent: str = StringField(required=True) - entities: list[str] = ListField(StringField()) - subjects: list[str] = ListField(StringField()) - classification_prompt: str = StringField() - reply_template: str = StringField() - bot: str = StringField(required=True) - user: str = StringField() - timestamp: float = FloatField() - status: bool = BooleanField(default=True) - - - @staticmethod - def create_doc( - intent: str, - entities: list[str], - subjects: list[str], - classification_prompt: str, - reply_template: str, - bot: str, - user: str - ): - mail_config = None - try: - exists = MailClassificationConfig.objects(bot=bot, intent=intent).first() - if exists and exists.status: - raise AppException(f"Mail configuration already exists for intent [{intent}]") - elif exists and not exists.status: - exists.update( - entities=entities, - subjects=subjects, - classification_prompt=classification_prompt, - reply_template=reply_template, - timestamp=time.time(), - status=True, - user=user - ) - mail_config = exists - else: - mail_config = MailClassificationConfig( - intent=intent, - entities=entities, - subjects=subjects, - classification_prompt=classification_prompt, - reply_template=reply_template, - bot=bot, - timestamp=time.time(), - status=True, - user=user - ) - mail_config.save() - - except Exception as e: - raise AppException(str(e)) - - return mail_config - - @staticmethod - def get_docs(bot: str): - try: - objs = MailClassificationConfig.objects(bot=bot, status=True) - return_data = [] - for obj in objs: - data = obj.to_mongo().to_dict() - data.pop('_id') - data.pop('timestamp') - data.pop('status') - data.pop('user') - return_data.append(data) - return return_data - except Exception as e: - raise AppException(str(e)) - - @staticmethod - def get_doc(bot: str, intent: str): - try: - obj = MailClassificationConfig.objects(bot=bot, intent=intent, status=True).first() - if not obj: - raise AppException(f"Mail configuration does not exist for intent [{intent}]") - data = obj.to_mongo().to_dict() - data.pop('_id') - data.pop('timestamp') - data.pop('status') - data.pop('user') - return data - except Exception as e: - raise AppException(str(e)) - - - @staticmethod - def delete_doc(bot: str, intent: str): - try: - MailClassificationConfig.objects(bot=bot, intent=intent).delete() - except Exception as e: - raise AppException(str(e)) - - @staticmethod - def soft_delete_doc(bot: str, intent: str): - try: - MailClassificationConfig.objects(bot=bot, intent=intent).update(status=False) - except Exception as e: - raise AppException(str(e)) - - @staticmethod - def update_doc(bot: str, intent: str, **kwargs): - keys = ['entities', 'subjects', 'classification_prompt', 'reply_template'] - for key in kwargs.keys(): - if key not in keys: - raise AppException(f"Invalid key [{key}] provided for updating mail config") - try: - MailClassificationConfig.objects(bot=bot, intent=intent).update(**kwargs) - except Exception as e: - raise AppException(str(e)) - - - - +from enum import Enum +from mongoengine import Document, StringField, ListField, FloatField, BooleanField, DictField +from kairon.exceptions import AppException +from kairon.shared.data.audit.data_objects import Auditlog +from kairon.shared.data.signals import auditlog, push_notification + + +class MailStatus(Enum): + Processing = "processing" + SUCCESS = "success" + FAILED = "failed" + +class MailResponseLog(Auditlog): + """ + Mail response log + """ + sender_id = StringField(required=True) + subject = StringField(required=True) + body = StringField(required=True) + responses = ListField() + slots = DictField() + bot = StringField(required=True) + user = StringField(required=True) + timestamp = FloatField(required=True) + status = StringField(required=True, default=MailStatus.Processing.value) diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index 821b6cd28..155748968 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -1,6 +1,4 @@ import asyncio -import re - from loguru import logger from pydantic.schema import timedelta from pydantic.validators import datetime @@ -8,12 +6,11 @@ from kairon.exceptions import AppException from kairon.shared.account.data_objects import Bot from kairon.shared.channels.mail.constants import MailConstants -from kairon.shared.channels.mail.data_objects import MailClassificationConfig +from kairon.shared.channels.mail.data_objects import MailResponseLog, MailStatus +from kairon.shared.chat.data_objects import Channels from kairon.shared.chat.processor import ChatDataProcessor from kairon.shared.constants import ChannelTypes from kairon.shared.data.data_objects import BotSettings -from kairon.shared.llm.processor import LLMProcessor -import json from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart import smtplib @@ -22,19 +19,22 @@ class MailProcessor: def __init__(self, bot): - self.config = ChatDataProcessor.get_channel_config(ChannelTypes.MAIL, bot, False)['config'] - self.llm_type = self.config.get('llm_type', "openai") - self.hyperparameters = self.config.get('hyperparameters', MailConstants.DEFAULT_HYPERPARAMETERS) self.bot = bot + self.config = ChatDataProcessor.get_channel_config(ChannelTypes.MAIL, bot, False)['config'] + self.intent = self.config.get('intent') + self.mail_template = self.config.get('mail_template', MailConstants.DEFAULT_TEMPLATE) + self.bot_settings = BotSettings.objects(bot=self.bot).get() bot_info = Bot.objects.get(id=bot) self.account = bot_info.account - self.llm_processor = LLMProcessor(self.bot, self.llm_type) - self.mail_configs = list(MailClassificationConfig.objects(bot=self.bot)) - self.mail_configs_dict = {item.intent: item for item in self.mail_configs} - self.bot_settings = BotSettings.objects(bot=self.bot).get() self.mailbox = None self.smtp = None + @staticmethod + def update_event_id(bot, event_id): + channel_config = Channels.objects(bot=bot, connector_type=ChannelTypes.MAIL).get() + channel_config.config['event_id'] = event_id + channel_config.save() + def login_imap(self): if self.mailbox: @@ -68,7 +68,7 @@ def logout_smtp(self): @staticmethod - def validate_smpt_connection(bot): + def validate_smtp_connection(bot): try: mp = MailProcessor(bot) mp.login_smtp() @@ -78,7 +78,18 @@ def validate_smpt_connection(bot): logger.error(str(e)) return False - async def send_mail(self, to: str, subject: str, body: str): + @staticmethod + def validate_imap_connection(bot): + try: + mp = MailProcessor(bot) + mp.login_imap() + mp.logout_imap() + return True + except Exception as e: + logger.error(str(e)) + return False + + async def send_mail(self, to: str, subject: str, body: str, log_id: str): try: email_account = self.config['email_account'] msg = MIMEMultipart() @@ -87,10 +98,16 @@ async def send_mail(self, to: str, subject: str, body: str): msg['Subject'] = subject msg.attach(MIMEText(body, 'html')) self.smtp.sendmail(email_account, to, msg.as_string()) + mail_log = MailResponseLog.objects.get(id=log_id) + mail_log.status = MailStatus.SUCCESS.value + mail_log.save() except Exception as e: logger.error(f"Error sending mail to {to}: {str(e)}") + mail_log = MailResponseLog.objects.get(id=log_id) + mail_log.status = MailStatus.FAILED.value + mail_log.save() - def process_mail(self, intent: str, rasa_chat_response: dict): + def process_mail(self, rasa_chat_response: dict): slots = rasa_chat_response.get('slots', []) slots = {key.strip(): value.strip() for slot_str in slots for split_result in [slot_str.split(":", 1)] @@ -100,72 +117,69 @@ def process_mail(self, intent: str, rasa_chat_response: dict): responses = '

'.join(response.get('text', '') for response in rasa_chat_response.get('response', [])) slots['bot_response'] = responses - mail_template = self.mail_configs_dict.get(intent, None) - if mail_template and mail_template.reply_template: - mail_template = mail_template.reply_template - else: - mail_template = MailConstants.DEFAULT_TEMPLATE - + mail_template = self.mail_template + mail_log = MailResponseLog.objects.get(id=slots['log_id']) + mail_log.responses = rasa_chat_response.get('response', []) + mail_log.slots = slots + mail_log.save() return mail_template.format(**{key: str(value) for key, value in slots.items()}) - async def classify_messages(self, messages: [dict]) -> [dict]: - if self.bot_settings.llm_settings['enable_faq']: - try: - system_prompt = self.config.get('system_prompt', MailConstants.DEFAULT_SYSTEM_PROMPT) - system_prompt += '\n return json format: [{"intent": "intent_name", "entities": {"entity_name": "value"}, "mail_id": "mail_id", "subject": "subject"}], if not classifiable set intent and not-found entity values as null' - context_prompt = self.get_context_prompt() - messages = json.dumps(messages) - info = await self.llm_processor.predict(messages, - self.bot_settings.user, - system_prompt=system_prompt, - context_prompt=context_prompt, - similarity_prompt=[], - hyperparameters=self.hyperparameters) - classifications = MailProcessor.extract_jsons_from_text(info["content"])[0] - return classifications - except Exception as e: - logger.error(str(e)) - raise AppException(str(e)) + @staticmethod + def get_log(bot_id: str, offset: int, limit: int) -> dict: + """ + Get logs for a bot + """ + try: + count = MailResponseLog.objects(bot=bot_id).count() + logs = MailResponseLog.objects(bot=bot_id).order_by('-timestamp').skip(offset).limit(limit) + result = [] + for log in logs: + log = log.to_mongo().to_dict() + log.pop('_id') + log.pop('bot') + log.pop('user') + result.append(log) + return { + "logs": result, + "count": count + } + except Exception as e: + raise AppException(str(e)) @staticmethod async def process_messages(bot: str, batch: [dict]): """ - classify and respond to a batch of messages + Pass messages to bot and send responses """ try: from kairon.chat.utils import ChatUtils mp = MailProcessor(bot) - classifications = await mp.classify_messages(batch) user_messages: [str] = [] responses = [] - intents = [] - for classification in classifications: + for mail in batch: try: - intent = classification['intent'] - if not intent or intent == 'null': - continue - entities = classification['entities'] - sender_id = classification['mail_id'] - subject = f"{classification['subject']}" - - # mail_id is in the format "name " or "email" - if '<' in sender_id: - sender_id = sender_id.split('<')[1].split('>')[0] - + entities = { + 'mail_id': mail['mail_id'], + 'subject': mail['subject'], + 'date': mail['date'], + 'body': mail['body'] + } entities_str = ', '.join([f'"{key}": "{value}"' for key, value in entities.items() if value and value != 'null']) - user_msg = f'/{intent}{{{entities_str}}}' - logger.info(user_msg) - + user_msg = f'/{mp.intent}{{{entities_str}}}' user_messages.append(user_msg) + subject = mail.get('subject', 'Reply') + if not subject.startswith('Re:'): + subject = f"Re: {subject}" + responses.append({ - 'to': sender_id, + 'to': mail['mail_id'], 'subject': subject, + 'body': '', + 'log_id': mail['log_id'] }) - intents.append(intent) except Exception as e: - logger.exception(e) - logger.info(responses) + logger.error(str(e)) chat_responses = await ChatUtils.process_messages_via_bot(user_messages, mp.account, @@ -178,7 +192,7 @@ async def process_messages(bot: str, batch: [dict]): logger.info(chat_responses) for index, response in enumerate(chat_responses): - responses[index]['body'] = mp.process_mail(intents[index], response) + responses[index]['body'] = mp.process_mail(response) mp.login_smtp() tasks = [mp.send_mail(**response) for response in responses] @@ -188,17 +202,6 @@ async def process_messages(bot: str, batch: [dict]): except Exception as e: raise AppException(str(e)) - def get_context_prompt(self) -> str: - context_prompt = "" - for item in self.mail_configs: - context_prompt += f"intent: {item['intent']} \n" - context_prompt += f"entities: {item['entities']} \n" - context_prompt += "\nclassification criteria: \n" - context_prompt += f"subjects: {item['subjects']} \n" - context_prompt += f"rule: {item['classification_prompt']} \n" - context_prompt += "\n\n" - return context_prompt - @staticmethod def process_message_task(bot: str, message_batch: [dict]): @@ -221,6 +224,7 @@ def read_mails(bot: str) -> ([dict], str, int): - subject - date - body + - log_id - user - time_shift @@ -230,24 +234,42 @@ def read_mails(bot: str) -> ([dict], str, int): last_read_timestamp = datetime.now() - timedelta(seconds=time_shift) messages = [] is_logged_in = False + last_processed_uid = mp.config.get('last_processed_uid', 0) + query = f'{last_processed_uid + 1}:*' try: mp.login_imap() is_logged_in = True - msgs = mp.mailbox.fetch(AND(seen=False, date_gte=last_read_timestamp.date())) + msgs = mp.mailbox.fetch(mark_seen=False, query=AND(date_gte=last_read_timestamp.date(), uid=query)) for msg in msgs: + last_processed_uid = msg.uid subject = msg.subject sender_id = msg.from_ date = msg.date body = msg.text or msg.html or "" + #attachments = msg.attachments logger.info(subject, sender_id, date) + mail_log = MailResponseLog(sender_id = sender_id, + subject = subject, + body = body, + bot = bot, + user = mp.bot_settings.user, + status=MailStatus.Processing.value, + timestamp = date.now()) + mail_log.save() message_entry = { 'mail_id': sender_id, 'subject': subject, 'date': str(date), - 'body': body + 'body': body, + 'log_id': str(mail_log.id) } messages.append(message_entry) mp.logout_imap() + + config_obj = Channels.objects(bot=bot, connector_type=ChannelTypes.MAIL).get() + config_obj.config['last_processed_uid'] = last_processed_uid + config_obj.save() + is_logged_in = False return messages, mp.bot_settings.user, time_shift except Exception as e: @@ -256,17 +278,18 @@ def read_mails(bot: str) -> ([dict], str, int): mp.logout_imap() return [], mp.bot_settings.user, time_shift - @staticmethod - def extract_jsons_from_text(text) -> list: - """ - Extract json objects from text as a list - """ - json_pattern = re.compile(r'(\{.*?\}|\[.*?\])', re.DOTALL) - jsons = [] - for match in json_pattern.findall(text): - try: - json_obj = json.loads(match) - jsons.append(json_obj) - except json.JSONDecodeError: - continue - return jsons + + # @staticmethod + # def extract_jsons_from_text(text) -> list: + # """ + # Extract json objects from text as a list + # """ + # json_pattern = re.compile(r'(\{.*?\}|\[.*?\])', re.DOTALL) + # jsons = [] + # for match in json_pattern.findall(text): + # try: + # json_obj = json.loads(match) + # jsons.append(json_obj) + # except json.JSONDecodeError: + # continue + # return jsons diff --git a/kairon/shared/channels/mail/scheduler.py b/kairon/shared/channels/mail/scheduler.py index 4d447bb0a..e85315046 100644 --- a/kairon/shared/channels/mail/scheduler.py +++ b/kairon/shared/channels/mail/scheduler.py @@ -4,86 +4,35 @@ from apscheduler.jobstores.mongodb import MongoDBJobStore from apscheduler.schedulers.background import BackgroundScheduler from pymongo import MongoClient +from uuid6 import uuid7 from kairon import Utility -from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent from kairon.exceptions import AppException from kairon.shared.channels.mail.processor import MailProcessor -from kairon.shared.chat.data_objects import Channels -from kairon.shared.constants import ChannelTypes -from loguru import logger class MailScheduler: - scheduler = None - scheduled_bots = set() @staticmethod - def epoch(): - is_initialized = False - if not MailScheduler.scheduler: - is_initialized = True - client = MongoClient(Utility.environment['database']['url']) - events_db = Utility.environment['events']['queue']['mail_queue_name'] - job_store_name = Utility.environment['events']['scheduler']['mail_scheduler_collection'] - - MailScheduler.scheduler = BackgroundScheduler( - jobstores={job_store_name: MongoDBJobStore(events_db, job_store_name, client)}, - job_defaults={'coalesce': True, 'misfire_grace_time': 7200}) - - bots = Channels.objects(connector_type= ChannelTypes.MAIL) - bots_list = [bot['bot'] for bot in bots] - bots = set(bots_list) - - unscheduled_bots = bots - MailScheduler.scheduled_bots - for bot in unscheduled_bots: - first_schedule_time = datetime.now() + timedelta(seconds=5) - MailScheduler.scheduler.add_job(MailScheduler.process_mails, - 'date', args=[bot, MailScheduler.scheduler], run_date=first_schedule_time) - MailScheduler.scheduled_bots.add(bot) - - MailScheduler.scheduled_bots = MailScheduler.scheduled_bots.intersection(bots) - if is_initialized: - MailScheduler.scheduler.start() - return True - return False + def request_epoch(bot: str): + if not MailProcessor.validate_smtp_connection(bot): + raise AppException("Failed to validate smtp connection, please revise mail channel configuration") + + if not MailProcessor.validate_imap_connection(bot): + raise AppException("Failed to validate imap connection, please revise mail channel configuration") - @staticmethod - def request_epoch(): event_server_url = Utility.get_event_server_url() resp = Utility.execute_http_request( "GET", urljoin( event_server_url, - "/api/mail/request_epoch", + f"/api/mail/schedule/{bot}", ), err_msg="Failed to request epoch", ) if not resp['success']: raise AppException("Failed to request email channel epoch") - @staticmethod - def process_mails(bot, scheduler: BackgroundScheduler = None): - - if bot not in MailScheduler.scheduled_bots: - return - logger.info(f"MailScheduler: Processing mails for bot {bot}") - next_timestamp = MailScheduler.read_mailbox_and_schedule_events(bot) - MailScheduler.scheduler.add_job(MailScheduler.process_mails, 'date', args=[bot, scheduler], run_date=next_timestamp) - MailScheduler.epoch() - - @staticmethod - def read_mailbox_and_schedule_events(bot) -> datetime: - vals = MailProcessor.read_mails(bot) - print(vals) - emails, user, next_delay = vals - for email in emails: - ev = MailChannelScheduleEvent(bot, user) - ev.validate() - ev.enqueue(mails=[email]) - next_timestamp = datetime.now() + timedelta(seconds=next_delay) - return next_timestamp - diff --git a/kairon/shared/chat/processor.py b/kairon/shared/chat/processor.py index 208331b35..77fc10500 100644 --- a/kairon/shared/chat/processor.py +++ b/kairon/shared/chat/processor.py @@ -41,7 +41,7 @@ def save_channel_config(configuration: Dict, bot: Text, user: Text): channel.save() if configuration['connector_type'] == ChannelTypes.MAIL.value: from kairon.shared.channels.mail.scheduler import MailScheduler - MailScheduler.request_epoch() + MailScheduler.request_epoch(bot) if primary_slack_config_changed: ChatDataProcessor.delete_channel_config(bot, connector_type="slack", config__is_primary=False) channel_endpoint = DataUtility.get_channel_endpoint(channel) diff --git a/kairon/shared/constants.py b/kairon/shared/constants.py index 66419bde0..877ca25f1 100644 --- a/kairon/shared/constants.py +++ b/kairon/shared/constants.py @@ -80,7 +80,8 @@ class EventClass(str, Enum): web_search = "web_search" scheduler_evaluator = "scheduler_evaluator" content_importer = "content_importer" - email_channel_scheduler = "email_channel_scheduler" + mail_channel_process_mails = "email_channel_process_mails" + mail_channel_read_mails = "email_channel_read_mails" class EventRequestType(str, Enum): diff --git a/system.yaml b/system.yaml index f4e7e4a60..cb030254f 100644 --- a/system.yaml +++ b/system.yaml @@ -120,7 +120,7 @@ notifications: events: server_url: ${EVENT_SERVER_ENDPOINT:"http://localhost:5056"} executor: - type: ${EVENTS_EXECUTOR_TYPE} + type: ${EVENTS_EXECUTOR_TYPE:"standalone"} region: ${EVENTS_EXECUTOR_REGION} timeout: ${EVENTS_EXECUTOR_TIMEOUT_MINUTES:60} queue: diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py index b8ad61c5d..c59f76dba 100644 --- a/tests/unit_test/channels/mail_channel_test.py +++ b/tests/unit_test/channels/mail_channel_test.py @@ -711,7 +711,7 @@ def test_validate_smpt_connection(self, mp, mock_logout_smtp, mock_login_smtp): mock_logout_smtp.return_value = None # Call the static method validate_smpt_connection - result = MailProcessor.validate_smpt_connection('test_bot_id') + result = MailProcessor.validate_smtp_connection('test_bot_id') # Assert that the method returns True assert result @@ -727,7 +727,7 @@ def test_validate_smpt_connection_failure(self, mock_logout_smtp, mock_login_smt mock_login_smtp.side_effect = Exception("SMTP login failed") # Call the static method validate_smpt_connection - result = MailProcessor.validate_smpt_connection('test_bot_id') + result = MailProcessor.validate_smtp_connection('test_bot_id') # Assert that the method returns False assert not result diff --git a/tests/unit_test/channels/mail_scheduler_test.py b/tests/unit_test/channels/mail_scheduler_test.py index 656d46093..2cca06d8c 100644 --- a/tests/unit_test/channels/mail_scheduler_test.py +++ b/tests/unit_test/channels/mail_scheduler_test.py @@ -38,7 +38,7 @@ async def test_mail_scheduler_epoch(setup_environment): MailScheduler.scheduler = mock_scheduler # Act - MailScheduler.epoch() + MailScheduler.schedule_channel_mail_reading() # Assert mock_scheduler.add_job.assert_called() @@ -80,7 +80,7 @@ async def test_mail_scheduler_epoch_creates_scheduler(setup_environment2): patch("apscheduler.schedulers.background.BackgroundScheduler.add_job", autospec=True) as mock_add_job: MailScheduler.scheduler = None - started = MailScheduler.epoch() + started = MailScheduler.schedule_channel_mail_reading() assert started assert MailScheduler.scheduler is not None diff --git a/tests/unit_test/cli_test.py b/tests/unit_test/cli_test.py index 5cf37b51f..900e1f2fb 100644 --- a/tests/unit_test/cli_test.py +++ b/tests/unit_test/cli_test.py @@ -326,7 +326,7 @@ def init_connection(self): @mock.patch("kairon.cli.mail_channel.MailChannelScheduleEvent.execute") def test_start_mail_channel(self, mock_execute): - from kairon.cli.mail_channel import process_channel_mails + from kairon.cli.mail_channel_process import process_channel_mails data = [{"mail": "test_mail"}] data = json.dumps(data) with patch('argparse.ArgumentParser.parse_args', @@ -339,7 +339,7 @@ def test_start_mail_channel(self, mock_execute): @mock.patch("kairon.cli.mail_channel.MailChannelScheduleEvent.execute") def test_start_mail_channel_wrong_format(self, mock_execute): - from kairon.cli.mail_channel import process_channel_mails + from kairon.cli.mail_channel_process import process_channel_mails data = {"mail": "test_mail"} data = json.dumps(data) with patch('argparse.ArgumentParser.parse_args', diff --git a/tests/unit_test/events/definitions_test.py b/tests/unit_test/events/definitions_test.py index fccef2737..2b117e873 100644 --- a/tests/unit_test/events/definitions_test.py +++ b/tests/unit_test/events/definitions_test.py @@ -1203,10 +1203,10 @@ def test_delete_message_broadcast(self): @responses.activate def test_validate_mail_channel_schedule_event(self): - from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + from kairon.events.definitions.mail_channel import MailProcessEvent bot = "test_add_schedule_event" user = "test_user" - url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False" + url = f"http://localhost:5001/api/events/execute/{EventClass.mail_channel_process_mails}?is_scheduled=False" responses.add( "POST", url, json={"message": "test msg", "success": True, "error_code": 400, "data": None} @@ -1215,21 +1215,21 @@ def test_validate_mail_channel_schedule_event(self): with patch('kairon.shared.channels.mail.processor.MailProcessor.login_smtp', return_value=None) as mock_login: with patch('kairon.shared.channels.mail.processor.MailProcessor.logout_smtp', return_value=None) as mock_logout: - event = MailChannelScheduleEvent(bot, user) + event = MailProcessEvent(bot, user) status = event.validate() assert status @responses.activate def test_validate_mail_channel_schedule_event_fail(self): - from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + from kairon.events.definitions.mail_channel import MailProcessEvent bot = "test_add_schedule_event" user = "test_user" - url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False" + url = f"http://localhost:5001/api/events/execute/{EventClass.mail_channel_process_mails}?is_scheduled=False" responses.add( "POST", url, json={"message": "test msg", "success": True, "error_code": 400, "data": None} ) - event = MailChannelScheduleEvent(bot, user) + event = MailProcessEvent(bot, user) status = event.validate() assert not status @@ -1237,15 +1237,15 @@ def test_validate_mail_channel_schedule_event_fail(self): @responses.activate def test_trigger_mail_channel_schedule_event_enqueue(self): - from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + from kairon.events.definitions.mail_channel import MailProcessEvent bot = "test_add_schedule_event" user = "test_user" - url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False" + url = f"http://localhost:5001/api/events/execute/{EventClass.mail_channel_process_mails}?is_scheduled=False" responses.add( "POST", url, json={"message": "test msg", "success": True, "error_code": 400, "data": None} ) - event = MailChannelScheduleEvent(bot, user) + event = MailProcessEvent(bot, user) try: event.enqueue() except AppException as e: @@ -1253,36 +1253,36 @@ def test_trigger_mail_channel_schedule_event_enqueue(self): @responses.activate def test_trigger_mail_channel_schedule_event_enqueue_exception(self): - from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + from kairon.events.definitions.mail_channel import MailProcessEvent from kairon.exceptions import AppException from unittest.mock import patch bot = "test_add_schedule_event" user = "test_user" - url = f"http://localhost:5001/api/events/execute/{EventClass.email_channel_scheduler}?is_scheduled=False" + url = f"http://localhost:5001/api/events/execute/{EventClass.mail_channel_process_mails}?is_scheduled=False" responses.add( "POST", url, json={"message": "test msg", "success": False, "error_code": 400, "data": None} ) - event = MailChannelScheduleEvent(bot, user) + event = MailProcessEvent(bot, user) with pytest.raises(AppException, match="Failed to trigger email_channel_scheduler event: test msg"): event.enqueue() @responses.activate def test_trigger_mail_channel_schedule_event_execute(self): - from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + from kairon.events.definitions.mail_channel import MailProcessEvent try: - MailChannelScheduleEvent("", "").execute() + MailProcessEvent("", "").execute() except AppException as e: pytest.fail(f"Unexpected exception: {e}") @responses.activate def test_trigger_mail_channel_schedule_event_execute_exception(self): - from kairon.events.definitions.mail_channel_schedule import MailChannelScheduleEvent + from kairon.events.definitions.mail_channel import MailProcessEvent from kairon.exceptions import AppException from unittest.mock import patch with patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task", side_effect=Exception("Test")): with pytest.raises(AppException, match="Test"): - MailChannelScheduleEvent("", "").execute(mails=["test@mail.com"]) \ No newline at end of file + MailProcessEvent("", "").execute(mails=["test@mail.com"]) \ No newline at end of file From d7d712887098fb81ce355ca55e4edfaef9b6d6ad Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Sun, 8 Dec 2024 15:03:21 +0530 Subject: [PATCH 21/27] changes and test cases --- kairon/events/server.py | 1 + kairon/events/utility.py | 22 +- kairon/shared/channels/mail/constants.py | 18 +- kairon/shared/channels/mail/data_objects.py | 25 +- kairon/shared/channels/mail/processor.py | 56 +- kairon/shared/constants.py | 4 + metadata/integrations.yml | 2 +- tests/integration_test/event_service_test.py | 4 +- tests/integration_test/services_test.py | 130 +--- tests/unit_test/channels/mail_channel_test.py | 560 +++++------------- .../unit_test/channels/mail_scheduler_test.py | 101 +--- tests/unit_test/cli_test.py | 18 +- .../data_processor/data_processor_test.py | 89 +-- tests/unit_test/events/definitions_test.py | 57 +- 14 files changed, 384 insertions(+), 703 deletions(-) diff --git a/kairon/events/server.py b/kairon/events/server.py index 3afa312b4..781c5c2fc 100644 --- a/kairon/events/server.py +++ b/kairon/events/server.py @@ -56,6 +56,7 @@ async def lifespan(app: FastAPI): """ MongoDB is connected on the bot trainer startup """ config: dict = Utility.mongoengine_connection(Utility.environment['database']["url"]) connect(**config) + EventUtility.reschedule_all_bots_channel_mail_reading() yield disconnect() diff --git a/kairon/events/utility.py b/kairon/events/utility.py index 469de9f7b..aae90e4e1 100644 --- a/kairon/events/utility.py +++ b/kairon/events/utility.py @@ -5,8 +5,10 @@ from kairon.events.executors.factory import ExecutorFactory from kairon.events.scheduler.kscheduler import KScheduler from kairon.exceptions import AppException -from kairon.shared.constants import EventClass +from kairon.shared.chat.data_objects import Channels +from kairon.shared.constants import EventClass, ChannelTypes from kairon.shared.data.constant import TASK_TYPE +from loguru import logger class EventUtility: @@ -42,18 +44,28 @@ def schedule_channel_mail_reading(bot: str): from kairon.shared.channels.mail.processor import MailProcessor mail_processor = MailProcessor(bot) interval = mail_processor.config.get("interval", 60) - event_id = mail_processor.config.get("event_id", None) + event_id = mail_processor.state.event_id if event_id: KScheduler().update_job(event_id, TASK_TYPE.EVENT, f"*/{interval} * * * *", - EventClass.mail_channel_read_mails, {"bot": bot}) + EventClass.mail_channel_read_mails, {"bot": bot, "user": mail_processor.bot_settings.user}) else: event_id = uuid7().hex - MailProcessor.update_event_id(bot, event_id) + mail_processor.update_event_id(event_id) KScheduler().add_job(event_id, TASK_TYPE.EVENT, f"*/{interval} * * * *", - EventClass.mail_channel_read_mails, {"bot": bot}) + EventClass.mail_channel_read_mails, {"bot": bot, "user": mail_processor.bot_settings.user}) except Exception as e: raise AppException(f"Failed to schedule mail reading for bot {bot}. Error: {str(e)}") + + @staticmethod + def reschedule_all_bots_channel_mail_reading(): + try: + bots = list(Channels.objects(connector_type= ChannelTypes.MAIL.value).distinct("bot")) + for bot in bots: + logger.info(f"Rescheduling mail reading for bot {bot}") + EventUtility.schedule_channel_mail_reading(bot) + except Exception as e: + raise AppException(f"Failed to reschedule mail reading events. Error: {str(e)}") \ No newline at end of file diff --git a/kairon/shared/channels/mail/constants.py b/kairon/shared/channels/mail/constants.py index a4775f15c..737448543 100644 --- a/kairon/shared/channels/mail/constants.py +++ b/kairon/shared/channels/mail/constants.py @@ -4,22 +4,8 @@ class MailConstants: DEFAULT_SMTP_SERVER = 'smtp.gmail.com' DEFAULT_IMAP_SERVER = 'imap.gmail.com' DEFAULT_SMTP_PORT = 587 - DEFAULT_LLM_TYPE = "openai" - DEFAULT_HYPERPARAMETERS = { - "frequency_penalty": 0, - "logit_bias": {}, - "max_tokens": 300, - "model": "gpt-4o-mini", - "n": 1, - "presence_penalty": 0, - "stop": None, - "stream": False, - "temperature": 0, - "top_p": 0 - } - DEFAULT_TEMPLATE = "

Dear {name},

{bot_response}



Generated by kAIron AI.\n" - DEFAULT_SYSTEM_PROMPT = 'Classify into one of the intents and extract entities as given in the context.' \ - 'If the mail does not belong to any of the intents, classify intent as null.' + + DEFAULT_TEMPLATE = "

{bot_response}



Generated by kAIron AI.\n" PROCESS_MESSAGE_BATCH_SIZE = 4 diff --git a/kairon/shared/channels/mail/data_objects.py b/kairon/shared/channels/mail/data_objects.py index 391443e33..93d49a6c6 100644 --- a/kairon/shared/channels/mail/data_objects.py +++ b/kairon/shared/channels/mail/data_objects.py @@ -1,12 +1,25 @@ import time from enum import Enum -from mongoengine import Document, StringField, ListField, FloatField, BooleanField, DictField +from mongoengine import Document, StringField, ListField, FloatField, BooleanField, DictField, IntField from kairon.exceptions import AppException from kairon.shared.data.audit.data_objects import Auditlog from kairon.shared.data.signals import auditlog, push_notification + +class MailChannelStateData(Document): + event_id = StringField() + last_email_uid = IntField(default=0) + bot = StringField(required=True) + timestamp = FloatField(default=time.time()) + + meta = {"indexes": ["bot"]} + + def save(self, *args, **kwargs): + self.timestamp = time.time() + super(MailChannelStateData, self).save(*args, **kwargs) + class MailStatus(Enum): Processing = "processing" SUCCESS = "success" @@ -17,11 +30,17 @@ class MailResponseLog(Auditlog): Mail response log """ sender_id = StringField(required=True) - subject = StringField(required=True) - body = StringField(required=True) + subject = StringField() + body = StringField() responses = ListField() slots = DictField() bot = StringField(required=True) user = StringField(required=True) timestamp = FloatField(required=True) status = StringField(required=True, default=MailStatus.Processing.value) + + meta = {"indexes": ["bot"]} + + def save(self, *args, **kwargs): + self.timestamp = time.time() + super(MailResponseLog, self).save(*args, **kwargs) diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index 155748968..9e5bf00c5 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -1,4 +1,6 @@ import asyncio +import time + from loguru import logger from pydantic.schema import timedelta from pydantic.validators import datetime @@ -6,7 +8,7 @@ from kairon.exceptions import AppException from kairon.shared.account.data_objects import Bot from kairon.shared.channels.mail.constants import MailConstants -from kairon.shared.channels.mail.data_objects import MailResponseLog, MailStatus +from kairon.shared.channels.mail.data_objects import MailResponseLog, MailStatus, MailChannelStateData from kairon.shared.chat.data_objects import Channels from kairon.shared.chat.processor import ChatDataProcessor from kairon.shared.constants import ChannelTypes @@ -24,17 +26,29 @@ def __init__(self, bot): self.intent = self.config.get('intent') self.mail_template = self.config.get('mail_template', MailConstants.DEFAULT_TEMPLATE) self.bot_settings = BotSettings.objects(bot=self.bot).get() + self.state = MailProcessor.get_mail_channel_state_data(bot) bot_info = Bot.objects.get(id=bot) self.account = bot_info.account self.mailbox = None self.smtp = None - @staticmethod - def update_event_id(bot, event_id): - channel_config = Channels.objects(bot=bot, connector_type=ChannelTypes.MAIL).get() - channel_config.config['event_id'] = event_id - channel_config.save() + def update_event_id(self, event_id): + self.state.event_id = event_id + self.state.save() + @staticmethod + def get_mail_channel_state_data(bot): + """ + Get mail channel state data + """ + try: + state = MailChannelStateData.objects(bot=bot).first() + if not state: + state = MailChannelStateData(bot=bot) + state.save() + return state + except Exception as e: + raise AppException(str(e)) def login_imap(self): if self.mailbox: @@ -107,7 +121,7 @@ async def send_mail(self, to: str, subject: str, body: str, log_id: str): mail_log.status = MailStatus.FAILED.value mail_log.save() - def process_mail(self, rasa_chat_response: dict): + def process_mail(self, rasa_chat_response: dict, log_id: str): slots = rasa_chat_response.get('slots', []) slots = {key.strip(): value.strip() for slot_str in slots for split_result in [slot_str.split(":", 1)] @@ -118,7 +132,7 @@ def process_mail(self, rasa_chat_response: dict): responses = '

'.join(response.get('text', '') for response in rasa_chat_response.get('response', [])) slots['bot_response'] = responses mail_template = self.mail_template - mail_log = MailResponseLog.objects.get(id=slots['log_id']) + mail_log = MailResponseLog.objects.get(id=log_id) mail_log.responses = rasa_chat_response.get('response', []) mail_log.slots = slots mail_log.save() @@ -149,6 +163,7 @@ def get_log(bot_id: str, offset: int, limit: int) -> dict: @staticmethod async def process_messages(bot: str, batch: [dict]): + logger.info(f"processing messages: {bot}, {batch}") """ Pass messages to bot and send responses """ @@ -189,10 +204,10 @@ async def process_messages(bot: str, batch: [dict]): { 'channel': ChannelTypes.MAIL.value }) - logger.info(chat_responses) + # logger.info(chat_responses) for index, response in enumerate(chat_responses): - responses[index]['body'] = mp.process_mail(response) + responses[index]['body'] = mp.process_mail(response, log_id=batch[index]['log_id']) mp.login_smtp() tasks = [mp.send_mail(**response) for response in responses] @@ -229,32 +244,36 @@ def read_mails(bot: str) -> ([dict], str, int): - time_shift """ + logger.info(f"reading mails for {bot}") mp = MailProcessor(bot) time_shift = int(mp.config.get('interval', 20 * 60)) last_read_timestamp = datetime.now() - timedelta(seconds=time_shift) messages = [] is_logged_in = False - last_processed_uid = mp.config.get('last_processed_uid', 0) - query = f'{last_processed_uid + 1}:*' + last_processed_uid = mp.state.last_email_uid + query = f'{int(last_processed_uid) + 1}:*' + logger.info(query) try: mp.login_imap() is_logged_in = True - msgs = mp.mailbox.fetch(mark_seen=False, query=AND(date_gte=last_read_timestamp.date(), uid=query)) + msgs = mp.mailbox.fetch(AND(date_gte=last_read_timestamp.date(), uid=query), mark_seen=False) for msg in msgs: - last_processed_uid = msg.uid + if int(msg.uid) <= last_processed_uid: + continue + last_processed_uid = int(msg.uid) subject = msg.subject sender_id = msg.from_ date = msg.date body = msg.text or msg.html or "" #attachments = msg.attachments - logger.info(subject, sender_id, date) + logger.info(f"reading: {subject}, {sender_id}, {date}") mail_log = MailResponseLog(sender_id = sender_id, subject = subject, body = body, bot = bot, user = mp.bot_settings.user, status=MailStatus.Processing.value, - timestamp = date.now()) + timestamp = time.time()) mail_log.save() message_entry = { 'mail_id': sender_id, @@ -266,9 +285,8 @@ def read_mails(bot: str) -> ([dict], str, int): messages.append(message_entry) mp.logout_imap() - config_obj = Channels.objects(bot=bot, connector_type=ChannelTypes.MAIL).get() - config_obj.config['last_processed_uid'] = last_processed_uid - config_obj.save() + mp.state.last_email_uid = last_processed_uid + mp.state.save() is_logged_in = False return messages, mp.bot_settings.user, time_shift diff --git a/kairon/shared/constants.py b/kairon/shared/constants.py index 877ca25f1..b4151ea8d 100644 --- a/kairon/shared/constants.py +++ b/kairon/shared/constants.py @@ -158,6 +158,10 @@ class KaironSystemSlots(str, Enum): flow_reply = "flow_reply" quick_reply = "quick_reply" http_status_code = "http_status_code" + mail_id = "mail_id" + subject = "subject" + body = "body" + class VectorEmbeddingsDatabases(str, Enum): diff --git a/metadata/integrations.yml b/metadata/integrations.yml index f2792000c..d433d18f4 100644 --- a/metadata/integrations.yml +++ b/metadata/integrations.yml @@ -84,7 +84,7 @@ channels: - smtp_port optional_fields: - interval - - llm_type + - intent actions: pipedrive: diff --git a/tests/integration_test/event_service_test.py b/tests/integration_test/event_service_test.py index 55e859ace..bfe20d5cc 100644 --- a/tests/integration_test/event_service_test.py +++ b/tests/integration_test/event_service_test.py @@ -534,9 +534,9 @@ def test_scheduled_event_request_dispatch(mock_dispatch_event): assert isinstance(args[0], BackgroundScheduler) -@patch('kairon.shared.channels.mail.scheduler.MailScheduler.epoch') +@patch('kairon.events.utility.EventUtility.schedule_channel_mail_reading') def test_request_epoch(mock_epoch): - response = client.get('/api/mail/request_epoch') + response = client.get('/api/mail/schedule/test_bot') mock_epoch.assert_called_once() assert response.status_code == 200 resp = response.json() diff --git a/tests/integration_test/services_test.py b/tests/integration_test/services_test.py index d4af7f20e..bdf533494 100644 --- a/tests/integration_test/services_test.py +++ b/tests/integration_test/services_test.py @@ -4408,127 +4408,6 @@ def test_get_live_agent_after_disabled(): -def test_add_mail_config(): - data = { - "intent": "greet", - "entities": ["name", "subject", "summery"], - "classification_prompt": "any personal mail of greeting" - } - - response = client.post( - f"/api/bot/{pytest.bot}/mail/config", - json=data, - headers={"Authorization": pytest.token_type + " " + pytest.access_token}, - ) - actual = response.json() - print(actual) - assert actual["success"] - assert actual['message'] == 'Config applied!' - assert actual['error_code'] == 0 - -def test_add_mail_config_missing_field(): - data = { - "entities": ["name", "subject", "summery"], - } - - response = client.post( - f"/api/bot/{pytest.bot}/mail/config", - json=data, - headers={"Authorization": pytest.token_type + " " + pytest.access_token}, - ) - actual = response.json() - print(actual) - assert not actual["success"] - assert len(actual['message']) - assert actual['error_code'] == 422 - -def test_add_mail_config_same_intent(): - data = { - "intent": "greet", - "entities": ["name", "subject", "summery"], - "classification_prompt": "any personal mail of greeting" - } - - response = client.post( - f"/api/bot/{pytest.bot}/mail/config", - json=data, - headers={"Authorization": pytest.token_type + " " + pytest.access_token}, - ) - actual = response.json() - print(actual) - assert not actual["success"] - assert actual['message'] == 'Mail configuration already exists for intent [greet]' - assert actual['error_code'] == 422 - - -def test_get_mail_config(): - - response = client.get( - f"/api/bot/{pytest.bot}/mail/config", - headers={"Authorization": pytest.token_type + " " + pytest.access_token}, - ) - actual = response.json() - print(actual) - assert actual["success"] - assert len(actual['data']) == 1 - assert actual['data'][0]['intent'] == 'greet' - assert actual['error_code'] == 0 - - - -def test_update_mail_config(): - data = { - "intent": "greet", - "entities": ["name", "subject", "summery"], - "classification_prompt": "any personal email of greeting" - } - - response = client.put( - f"/api/bot/{pytest.bot}/mail/config", - json=data, - headers={"Authorization": pytest.token_type + " " + pytest.access_token}, - ) - actual = response.json() - print(actual) - assert actual["success"] - assert actual['message'] == 'Config updated!' - assert actual['error_code'] == 0 - - response = client.get( - f"/api/bot/{pytest.bot}/mail/config", - headers={"Authorization": pytest.token_type + " " + pytest.access_token}, - ) - actual = response.json() - print(actual) - assert actual["success"] - assert len(actual['data']) == 1 - assert actual['data'][0]['intent'] == 'greet' - assert actual['data'][0]['classification_prompt'] == 'any personal email of greeting' - assert actual['error_code'] == 0 - - -def test_delete_mail_config(): - response = client.delete( - f"/api/bot/{pytest.bot}/mail/config/greet", - headers={"Authorization": pytest.token_type + " " + pytest.access_token}, - ) - - actual = response.json() - assert actual['success'] - assert actual['message'] == 'Config deleted!' - - response = client.get( - f"/api/bot/{pytest.bot}/mail/config", - headers={"Authorization": pytest.token_type + " " + pytest.access_token}, - ) - actual = response.json() - print(actual) - assert actual["success"] - assert len(actual['data']) == 0 - - - - def test_callback_config_add_syntax_error(): request_body = { "name": "callback_1", @@ -8419,7 +8298,7 @@ def test_list_entities_empty(): ) actual = response.json() assert actual["error_code"] == 0 - assert len(actual['data']) == 14 + assert len(actual['data']) == 17 assert actual["success"] @@ -9186,7 +9065,8 @@ def test_list_entities(): expected = {'bot', 'file', 'category', 'file_text', 'ticketid', 'file_error', 'priority', 'requested_slot', 'fdresponse', 'kairon_action_response', 'audio', 'image', 'doc_url', 'document', 'video', 'order', 'payment', 'latitude', - 'longitude', 'flow_reply', 'http_status_code', 'name', 'quick_reply'} + 'longitude', 'flow_reply', 'http_status_code', 'name', 'quick_reply', 'mail_id', + 'subject', 'body'} assert not DeepDiff({item['name'] for item in actual['data']}, expected, ignore_order=True) assert actual["success"] @@ -9824,12 +9704,12 @@ def test_get_slots(): ) actual = response.json() assert "data" in actual - assert len(actual["data"]) == 21 + assert len(actual["data"]) == 24 assert actual["success"] assert actual["error_code"] == 0 assert Utility.check_empty_string(actual["message"]) default_slots_count = sum(slot.get('is_default') for slot in actual["data"]) - assert default_slots_count == 14 + assert default_slots_count == 17 def test_add_slots(): diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py index c59f76dba..6cc3aa750 100644 --- a/tests/unit_test/channels/mail_channel_test.py +++ b/tests/unit_test/channels/mail_channel_test.py @@ -6,8 +6,11 @@ from imap_tools import MailMessage from mongoengine import connect, disconnect +from uuid6 import uuid7 from kairon import Utility +from kairon.shared.channels.mail.data_objects import MailResponseLog, MailChannelStateData + os.environ["system_file"] = "./tests/testing_data/system.yaml" Utility.load_environment() Utility.load_system_metadata() @@ -16,234 +19,42 @@ from kairon.shared.channels.mail.constants import MailConstants from kairon.shared.channels.mail.processor import MailProcessor from kairon.shared.chat.data_objects import Channels -from kairon.shared.chat.processor import ChatDataProcessor from kairon.shared.data.data_objects import BotSettings -from kairon.shared.channels.mail.data_objects import MailClassificationConfig from kairon.exceptions import AppException from kairon.shared.constants import ChannelTypes +bot_data_created = False + class TestMailChannel: @pytest.fixture(autouse=True, scope='class') def setup(self): + global bot_data_created connect(**Utility.mongoengine_connection(Utility.environment['database']["url"])) - - yield - - self.remove_basic_data() - disconnect() - - def create_basic_data(self): a = Account.objects.create(name="mail_channel_test_user_acc", user="mail_channel_test_user_acc") - bot = Bot.objects.create(name="mail_channel_test_bot", user="mail_channel_test_user_acc", status=True, account=a.id) + bot = Bot.objects.create(name="mail_channel_test_bot", user="mail_channel_test_user_acc", status=True, + account=a.id) pytest.mail_test_bot = str(bot.id) - b = BotSettings.objects.create(bot=pytest.mail_test_bot, user="mail_channel_test_user_acc") - # b.llm_settings.enable_faq = True - b.save() - ChatDataProcessor.save_channel_config( - { - "connector_type": ChannelTypes.MAIL.value, - "config": { - 'email_account': "mail_channel_test_user_acc@testuser.com", - 'email_password': "password", - 'imap_server': "imap.testuser.com", - 'smtp_server': "smtp.testuser.com", - 'smtp_port': "587", - } - }, - pytest.mail_test_bot, - user="mail_channel_test_user_acc", - ) + BotSettings(bot=pytest.mail_test_bot, user="mail_channel_test_user_acc").save() + yield - def remove_basic_data(self): - MailClassificationConfig.objects.delete() BotSettings.objects(user="mail_channel_test_user_acc").delete() Bot.objects(user="mail_channel_test_user_acc").delete() Account.objects(user="mail_channel_test_user_acc").delete() Channels.objects(connector_type=ChannelTypes.MAIL.value).delete() - @patch("kairon.shared.utils.Utility.execute_http_request") - def test_create_doc_new_entry(self, execute_http_request): - self.create_basic_data() - execute_http_request.return_value = {"success": True} - print(pytest.mail_test_bot) - doc = MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.mail_test_bot, - user="mail_channel_test_user_acc" - ) - assert doc.intent == "greeting" - assert doc.bot == pytest.mail_test_bot - assert doc.status is True - MailClassificationConfig.objects.delete() - - - - def test_create_doc_existing_active_entry(self): - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.mail_test_bot, - user="mail_channel_test_user_acc" - ) - with pytest.raises(AppException, match=r"Mail configuration already exists for intent \[greeting\]"): - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_email"], - subjects=["hi"], - classification_prompt="Another greeting.", - reply_template="Hello!", - bot=pytest.mail_test_bot, - user="mail_channel_test_user_acc" - ) - MailClassificationConfig.objects.delete() - - - - def test_get_docs(self): - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.mail_test_bot, - user="mail_channel_test_user_acc" - ) - MailClassificationConfig.create_doc( - intent="goodbye", - entities=["farewell"], - subjects=["bye"], - classification_prompt="Classify this email as a goodbye.", - reply_template="Goodbye!", - bot=pytest.mail_test_bot, - user="mail_channel_test_user_acc" - ) - docs = MailClassificationConfig.get_docs(bot=pytest.mail_test_bot) - assert len(docs) == 2 - assert docs[0]["intent"] == "greeting" - assert docs[1]["intent"] == "goodbye" - MailClassificationConfig.objects.delete() - - - - def test_get_doc(self): - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.mail_test_bot, - user="mail_channel_test_user_acc" - ) - doc = MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") - assert doc["intent"] == "greeting" - assert doc["classification_prompt"] == "Classify this email as a greeting." - MailClassificationConfig.objects.delete() - - - def test_get_doc_nonexistent(self): - """Test retrieving a non-existent document.""" - with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): - MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") - - MailClassificationConfig.objects.delete() - - - def test_delete_doc(self): - """Test deleting a document.""" - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.mail_test_bot, - user="mail_channel_test_user_acc" - ) - MailClassificationConfig.delete_doc(bot=pytest.mail_test_bot, intent="greeting") - with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): - MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") - - MailClassificationConfig.objects.delete() - - - def test_soft_delete_doc(self): - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.mail_test_bot, - user="mail_channel_test_user_acc" - ) - MailClassificationConfig.soft_delete_doc(bot=pytest.mail_test_bot, intent="greeting") - with pytest.raises(AppException, match=r"Mail configuration does not exist for intent \[greeting\]"): - MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") - - MailClassificationConfig.objects.delete() - - - - def test_update_doc(self): - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.mail_test_bot, - user="mail_channel_test_user_acc" - ) - MailClassificationConfig.update_doc( - bot=pytest.mail_test_bot, - intent="greeting", - entities=["user_name", "greeting"], - reply_template="Hello there!" - ) - doc = MailClassificationConfig.get_doc(bot=pytest.mail_test_bot, intent="greeting") - assert doc["entities"] == ["user_name", "greeting"] - assert doc["reply_template"] == "Hello there!" - - MailClassificationConfig.objects.delete() - - def test_update_doc_invalid_key(self): - MailClassificationConfig.create_doc( - intent="greeting", - entities=["user_name"], - subjects=["hello"], - classification_prompt="Classify this email as a greeting.", - reply_template="Hi, how can I help?", - bot=pytest.mail_test_bot, - user="mail_channel_test_user_acc" - ) - with pytest.raises(AppException, match=r"Invalid key \[invalid_key\] provided for updating mail config"): - MailClassificationConfig.update_doc( - bot=pytest.mail_test_bot, - intent="greeting", - invalid_key="value" - ) - - MailClassificationConfig.objects.delete() - - - @patch("kairon.shared.channels.mail.processor.LLMProcessor") + + disconnect() + + + + @patch("kairon.shared.channels.mail.processor.MailBox") @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") @patch("kairon.shared.utils.Utility.execute_http_request") - def test_login_imap(self, execute_http_req, mock_get_channel_config, mock_mailbox, mock_llm_processor): - self.create_basic_data() + def test_login_imap(self, execute_http_req, mock_get_channel_config, mock_mailbox): execute_http_req.return_value = {"success": True} mock_mailbox_instance = MagicMock() mock_mailbox.return_value = mock_mailbox_instance @@ -251,9 +62,6 @@ def test_login_imap(self, execute_http_req, mock_get_channel_config, mock_mailbo mock_mailbox_instance._simple_command.return_value = ("OK", ["Logged in"]) mock_mailbox_instance.select.return_value = ("OK", ["INBOX"]) - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance - mock_get_channel_config.return_value = { 'config': { 'email_account': "mail_channel_test_user_acc@testuser.com", @@ -264,22 +72,18 @@ def test_login_imap(self, execute_http_req, mock_get_channel_config, mock_mailbo bot_id = pytest.mail_test_bot mp = MailProcessor(bot=bot_id) - mp.login_imap() mock_get_channel_config.assert_called_once_with(ChannelTypes.MAIL, bot_id, False) mock_mailbox.assert_called_once_with("imap.testuser.com") mock_mailbox_instance.login.assert_called_once_with("mail_channel_test_user_acc@testuser.com", "password") - mock_llm_processor.assert_called_once_with(bot_id, mp.llm_type) - @patch("kairon.shared.channels.mail.processor.LLMProcessor") @patch("kairon.shared.channels.mail.processor.MailBox") @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") @patch("kairon.shared.utils.Utility.execute_http_request") - def test_login_imap_logout(self,execute_http_request, mock_get_channel_config, mock_mailbox, mock_llm_processor): - self.create_basic_data() + def test_login_imap_logout(self,execute_http_request, mock_get_channel_config, mock_mailbox): execute_http_request.return_value = {"success": True} mock_mailbox_instance = MagicMock() mock_mailbox.return_value = mock_mailbox_instance @@ -287,8 +91,6 @@ def test_login_imap_logout(self,execute_http_request, mock_get_channel_config, m mock_mailbox_instance._simple_command.return_value = ("OK", ["Logged in"]) mock_mailbox_instance.select.return_value = ("OK", ["INBOX"]) - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance mock_get_channel_config.return_value = { 'config': { @@ -308,16 +110,12 @@ def test_login_imap_logout(self,execute_http_request, mock_get_channel_config, m @patch("kairon.shared.channels.mail.processor.smtplib.SMTP") - @patch("kairon.shared.channels.mail.processor.LLMProcessor") @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") - def test_login_smtp(self, mock_get_channel_config, mock_llm_processor, mock_smtp): + def test_login_smtp(self, mock_get_channel_config, mock_smtp): # Arrange mock_smtp_instance = MagicMock() mock_smtp.return_value = mock_smtp_instance - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance - mock_get_channel_config.return_value = { 'config': { 'email_account': "mail_channel_test_user_acc@testuser.com", @@ -339,15 +137,11 @@ def test_login_smtp(self, mock_get_channel_config, mock_llm_processor, mock_smtp @patch("kairon.shared.channels.mail.processor.smtplib.SMTP") - @patch("kairon.shared.channels.mail.processor.LLMProcessor") @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") - def test_logout_smtp(self, mock_get_channel_config, mock_llm_processor, mock_smtp): + def test_logout_smtp(self, mock_get_channel_config, mock_smtp): mock_smtp_instance = MagicMock() mock_smtp.return_value = mock_smtp_instance - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance - mock_get_channel_config.return_value = { 'config': { 'email_account': "mail_channel_test_user_acc@testuser.com", @@ -366,16 +160,23 @@ def test_logout_smtp(self, mock_get_channel_config, mock_llm_processor, mock_smt mock_smtp_instance.quit.assert_called_once() assert mp.smtp is None + + @patch("kairon.shared.channels.mail.processor.smtplib.SMTP") - @patch("kairon.shared.channels.mail.processor.LLMProcessor") @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") @pytest.mark.asyncio - async def test_send_mail(self, mock_get_channel_config, mock_llm_processor, mock_smtp): + async def test_send_mail(self, mock_get_channel_config, mock_smtp): mock_smtp_instance = MagicMock() mock_smtp.return_value = mock_smtp_instance - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance + mail_response_log = MailResponseLog(bot=pytest.mail_test_bot, + sender_id="recipient@test.com", + user="mail_channel_test_user_acc", + subject="Test Subject", + body="Test Body", + ) + mail_response_log.save() + mail_response_log.save() mock_get_channel_config.return_value = { 'config': { @@ -385,12 +186,13 @@ async def test_send_mail(self, mock_get_channel_config, mock_llm_processor, mock 'smtp_port': 587 } } - bot_id = pytest.mail_test_bot mp = MailProcessor(bot=bot_id) mp.login_smtp() - await mp.send_mail("recipient@test.com", "Test Subject", "Test Body") + await mp.send_mail("recipient@test.com", "Test Subject", "Test Body", mail_response_log.id) + + MailResponseLog.objects().delete() mock_smtp_instance.sendmail.assert_called_once() assert mock_smtp_instance.sendmail.call_args[0][0] == "mail_channel_test_user_acc@testuser.com" @@ -399,10 +201,9 @@ async def test_send_mail(self, mock_get_channel_config, mock_llm_processor, mock assert "Test Body" in mock_smtp_instance.sendmail.call_args[0][2] - @patch("kairon.shared.channels.mail.processor.MailClassificationConfig") - @patch("kairon.shared.channels.mail.processor.LLMProcessor") + @patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config") - def test_process_mail(self, mock_get_channel_config, llm_processor, mock_mail_classification_config): + def test_process_mail(self, mock_get_channel_config): mock_get_channel_config.return_value = { 'config': { 'email_account': "mail_channel_test_user_acc@testuser.com", @@ -411,142 +212,41 @@ def test_process_mail(self, mock_get_channel_config, llm_processor, mock_mail_c } } + mail_response_log = MailResponseLog(bot=pytest.mail_test_bot, + sender_id="recipient@test.com", + user="mail_channel_test_user_acc", + subject="Test Subject", + body="Test Body", + ) + mail_response_log.save() + bot_id = pytest.mail_test_bot mp = MailProcessor(bot=bot_id) - mp.mail_configs_dict = { - "greeting": MagicMock(reply_template="Hello {name}, {bot_response}") - } rasa_chat_response = { "slots": ["name: John Doe"], "response": [{"text": "How can I help you today?"}] } + result = mp.process_mail( rasa_chat_response, mail_response_log.id) + assert result == MailConstants.DEFAULT_TEMPLATE.format(bot_response="How can I help you today?") - result = mp.process_mail("greeting", rasa_chat_response) - - assert result == "Hello John Doe, How can I help you today?" rasa_chat_response = { "slots": ["name: John Doe"], "response": [{"text": "How can I help you today?"}] } - mp.mail_configs_dict = {} # No template for the intent - result = mp.process_mail("greeting", rasa_chat_response) - assert result == MailConstants.DEFAULT_TEMPLATE.format(name="John Doe", bot_response="How can I help you today?") - - - - @patch("kairon.shared.channels.mail.processor.LLMProcessor") - @patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config") - @patch("kairon.shared.channels.mail.processor.BotSettings.objects") - @patch("kairon.shared.channels.mail.processor.MailClassificationConfig.objects") - @patch("kairon.shared.channels.mail.processor.Bot.objects") - @pytest.mark.asyncio - async def test_classify_messages(self, mock_bot_objects, mock_mail_classification_config_objects, - mock_bot_settings_objects, mock_get_channel_config, mock_llm_processor): - mock_get_channel_config.return_value = { - 'config': { - 'email_account': "mail_channel_test_user_acc@testuser.com", - 'email_password': "password", - 'imap_server': "imap.testuser.com", - 'llm_type': "openai", - 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, - 'system_prompt': "Test system prompt" - } - } - - mock_bot_settings = MagicMock() - mock_bot_settings.llm_settings = {'enable_faq': True} - mock_bot_settings_objects.get.return_value = mock_bot_settings - - mock_bot = MagicMock() - mock_bot_objects.get.return_value = mock_bot - - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance - - future = asyncio.Future() - future.set_result({"content": '[{"intent": "greeting", "entities": {"name": "John Doe"}, "mail_id": "123", "subject": "Hello"}]'}) - mock_llm_processor_instance.predict.return_value = future - - bot_id = pytest.mail_test_bot - mp = MailProcessor(bot=bot_id) - - messages = [{"mail_id": "123", "subject": "Hello", "body": "Hi there"}] - - result = await mp.classify_messages(messages) - - assert result == [{"intent": "greeting", "entities": {"name": "John Doe"}, "mail_id": "123", "subject": "Hello"}] - mock_llm_processor_instance.predict.assert_called_once() - - - @patch("kairon.shared.channels.mail.processor.LLMProcessor") - def test_get_context_prompt(self, llm_processor): - bot_id = pytest.mail_test_bot - mail_configs = [ - { - 'intent': 'greeting', - 'entities': 'name', - 'subjects': 'Hello', - 'classification_prompt': 'If the email says hello, classify it as greeting' - }, - { - 'intent': 'farewell', - 'entities': 'name', - 'subjects': 'Goodbye', - 'classification_prompt': 'If the email says goodbye, classify it as farewell' - } - ] - - mp = MailProcessor(bot=bot_id) - mp.mail_configs = mail_configs - - expected_context_prompt = ( - "intent: greeting \n" - "entities: name \n" - "\nclassification criteria: \n" - "subjects: Hello \n" - "rule: If the email says hello, classify it as greeting \n\n\n" - "intent: farewell \n" - "entities: name \n" - "\nclassification criteria: \n" - "subjects: Goodbye \n" - "rule: If the email says goodbye, classify it as farewell \n\n\n" - ) - - context_prompt = mp.get_context_prompt() - - assert context_prompt == expected_context_prompt - - - def test_extract_jsons_from_text(self): - text = ''' - Here is some text with JSON objects. - {"key1": "value1", "key2": "value2"} - Some more text. - [{"key3": "value3"}, {"key4": "value4"}] - And some final text. - ''' - expected_output = [ - {"key1": "value1", "key2": "value2"}, - [{"key3": "value3"}, {"key4": "value4"}] - ] - - result = MailProcessor.extract_jsons_from_text(text) - - assert result == expected_output - - - + mp.mail_template = "Hello {name}, {bot_response}" + result = mp.process_mail(rasa_chat_response, mail_response_log.id) + MailResponseLog.objects().delete() + assert result == "Hello John Doe, How can I help you today?" @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap") @patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task") @patch("kairon.shared.channels.mail.processor.MailBox") - @patch("kairon.shared.channels.mail.processor.LLMProcessor") @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") @pytest.mark.asyncio - async def test_read_mails(self, mock_get_channel_config, mock_llm_processor, + async def test_read_mails(self, mock_get_channel_config, mock_mailbox, mock_process_message_task, mock_logout_imap): bot_id = pytest.mail_test_bot @@ -556,14 +256,9 @@ async def test_read_mails(self, mock_get_channel_config, mock_llm_processor, 'email_account': "mail_channel_test_user_acc@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com", - 'llm_type': "openai", - 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, } } - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance - mock_mailbox_instance = MagicMock() mock_mailbox.return_value = mock_mailbox_instance @@ -594,10 +289,9 @@ async def test_read_mails(self, mock_get_channel_config, mock_llm_processor, @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_imap") @patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task") @patch("kairon.shared.channels.mail.processor.MailBox") - @patch("kairon.shared.channels.mail.processor.LLMProcessor") @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") @pytest.mark.asyncio - async def test_read_mails_no_messages(self, mock_get_channel_config, mock_llm_processor, + async def test_read_mails_no_messages(self, mock_get_channel_config, mock_mailbox, mock_process_message_task, mock_logout_imap): bot_id = pytest.mail_test_bot @@ -607,14 +301,9 @@ async def test_read_mails_no_messages(self, mock_get_channel_config, mock_llm_pr 'email_account': "mail_channel_test_user_acc@testuser.com", 'email_password': "password", 'imap_server': "imap.testuser.com", - 'llm_type': "openai", - 'hyperparameters': MailConstants.DEFAULT_HYPERPARAMETERS, - } + } } - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance - mock_mailbox_instance = MagicMock() mock_mailbox.return_value = mock_mailbox_instance @@ -631,71 +320,56 @@ async def test_read_mails_no_messages(self, mock_get_channel_config, mock_llm_pr - @patch("kairon.shared.channels.mail.processor.LLMProcessor") @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") - @pytest.mark.asyncio - async def test_classify_messages_invalid_llm_response(self, mock_get_channel_config, mock_llm_processor): - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance - - future = asyncio.Future() - future.set_result({"content": 'invalid json content'}) - mock_llm_processor_instance.predict.return_value = future - - mp = MailProcessor(bot=pytest.mail_test_bot) - messages = [{"mail_id": "123", "subject": "Hello", "body": "Hi there"}] - - - ans = await mp.classify_messages(messages) - assert not ans - - - @patch("kairon.shared.channels.mail.processor.MailProcessor.classify_messages") @patch("kairon.shared.channels.mail.processor.MailProcessor.login_smtp") @patch("kairon.shared.channels.mail.processor.MailProcessor.logout_smtp") @patch("kairon.shared.channels.mail.processor.MailProcessor.send_mail") @patch("kairon.chat.utils.ChatUtils.process_messages_via_bot") - @patch("kairon.shared.channels.mail.processor.LLMProcessor") @pytest.mark.asyncio - async def test_process_messages(self, mock_llm_processor, mock_process_messages_via_bot, mock_send_mail, mock_logout_smtp, mock_login_smtp, - mock_classify_messages): + async def test_process_messages(self, mock_process_messages_via_bot, mock_send_mail, mock_logout_smtp, mock_login_smtp, mock_get_channel_config): + mail_response_log = MailResponseLog(bot=pytest.mail_test_bot, + sender_id="recipient@test.com", + user="mail_channel_test_user_acc", + subject="Test Subject", + body="Test Body", + ) + mail_response_log.save() + + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'imap_server': "imap.testuser.com", + } + } - # Arrange bot = pytest.mail_test_bot - batch = [{"mail_id": "test@example.com", "subject": "Test Subject", "date": "2023-10-10", "body": "Test Body"}] - mock_classify_messages.return_value = [{ - "intent": "test_intent", - "entities": {"entity_name": "value"}, - "mail_id": "test@example.com", - "subject": "Test Subject", - "name": "spandan" - }] + batch = [{"mail_id": "test@example.com", "subject": "Test Subject", "date": "2023-10-10", "body": "Test Body", "log_id": str(mail_response_log.id)}] + mock_process_messages_via_bot.return_value = [{ "slots": ["name: spandan"], "response": [{"text": "Test Response"}] }] - mock_llm_processor_instance = MagicMock() - mock_llm_processor.return_value = mock_llm_processor_instance - - # Act await MailProcessor.process_messages(bot, batch) # Assert - mock_classify_messages.assert_called_once_with(batch) mock_process_messages_via_bot.assert_called_once() mock_login_smtp.assert_called_once() mock_send_mail.assert_called_once() mock_logout_smtp.assert_called_once() + MailResponseLog.objects().delete() + - @patch("kairon.shared.channels.mail.processor.MailProcessor.classify_messages") + @patch("kairon.shared.channels.mail.processor.MailProcessor.login_smtp") @pytest.mark.asyncio - async def test_process_messages_exception(self, mock_classify_messages): + async def test_process_messages_exception(self, mock_exc): # Arrange bot = "test_bot" batch = [{"mail_id": "test@example.com", "subject": "Test Subject", "date": "2023-10-10", "body": "Test Body"}] - mock_classify_messages.side_effect = Exception("Test Exception") + mock_exc.side_effect = Exception("Test Exception") # Act & Assert with pytest.raises(AppException): @@ -734,7 +408,87 @@ def test_validate_smpt_connection_failure(self, mock_logout_smtp, mock_login_smt + def test_get_mail_channel_state_data_existing_state(self): + bot_id = pytest.mail_test_bot + mock_state = MagicMock() + + with patch.object(MailChannelStateData, 'objects') as mock_objects: + mock_objects.return_value.first.return_value = mock_state + result = MailProcessor.get_mail_channel_state_data(bot_id) + + assert result == mock_state + mock_objects.return_value.first.assert_called_once() + + def test_get_mail_channel_state_data_new_state(self): + bot_id = pytest.mail_test_bot + mock_state = MagicMock() + mock_state.bot = bot_id + mock_state.state = "some_state" + mock_state.timestamp = "some_timestamp" + + with patch.object(MailChannelStateData, 'objects') as mock_objects: + mock_objects.return_value.first.return_value = None + with patch.object(MailChannelStateData, 'save', return_value=None) as mock_save: + with patch('kairon.shared.channels.mail.data_objects.MailChannelStateData', return_value=mock_state): + result = MailProcessor.get_mail_channel_state_data(bot_id) + + assert result.bot == mock_state.bot + + def test_get_mail_channel_state_data_exception(self): + bot_id = "test_bot" + with patch.object(MailChannelStateData, 'objects') as mock_objects: + mock_objects.side_effect = Exception("Test Exception") + with pytest.raises(AppException) as excinfo: + MailProcessor.get_mail_channel_state_data(bot_id) + assert str(excinfo.value) == "Test Exception" + + def test_get_log(self): + bot_id = "test_bot" + offset = 0 + limit = 10 + + mock_log = MagicMock() + mock_log.to_mongo.return_value.to_dict.return_value = { + '_id': 'some_id', + 'bot': bot_id, + 'user': 'test_user', + 'timestamp': 1234567890, + 'subject': 'Test Subject', + 'body': 'Test Body', + 'status': 'SUCCESS' + } + + with patch.object(MailResponseLog, 'objects') as mock_objects: + mock_objects.return_value.count.return_value = 1 + mock_objects.return_value.order_by.return_value.skip.return_value.limit.return_value = [mock_log] + + result = MailProcessor.get_log(bot_id, offset, limit) + + assert result['count'] == 1 + assert len(result['logs']) == 1 + assert result['logs'][0]['timestamp'] == 1234567890 + assert result['logs'][0]['subject'] == 'Test Subject' + assert result['logs'][0]['body'] == 'Test Body' + assert result['logs'][0]['status'] == 'SUCCESS' + + def test_get_log_exception(self): + bot_id = "test_bot" + offset = 0 + limit = 10 + + with patch.object(MailResponseLog, 'objects') as mock_objects: + mock_objects.side_effect = Exception("Test Exception") + + with pytest.raises(AppException) as excinfo: + MailProcessor.get_log(bot_id, offset, limit) + + assert str(excinfo.value) == "Test Exception" + + BotSettings.objects(user="mail_channel_test_user_acc").delete() + Bot.objects(user="mail_channel_test_user_acc").delete() + Account.objects(user="mail_channel_test_user_acc").delete() + Channels.objects(connector_type=ChannelTypes.MAIL.value).delete() \ No newline at end of file diff --git a/tests/unit_test/channels/mail_scheduler_test.py b/tests/unit_test/channels/mail_scheduler_test.py index 2cca06d8c..ad7fb123e 100644 --- a/tests/unit_test/channels/mail_scheduler_test.py +++ b/tests/unit_test/channels/mail_scheduler_test.py @@ -1,7 +1,6 @@ -from datetime import datetime, timedelta import pytest -from unittest.mock import patch, MagicMock, AsyncMock +from unittest.mock import patch import os from kairon import Utility from kairon.exceptions import AppException @@ -30,71 +29,17 @@ def setup_environment(): 'mock_scheduler': mock_scheduler_instance } -@pytest.mark.asyncio -async def test_mail_scheduler_epoch(setup_environment): - # Arrange - mock_scheduler = setup_environment['mock_scheduler'] - # MailScheduler.mail_queue_name = "test_queue" - MailScheduler.scheduler = mock_scheduler - - # Act - MailScheduler.schedule_channel_mail_reading() - - # Assert - mock_scheduler.add_job.assert_called() - -def test_mail_scheduler_process_mails(setup_environment): - mock_read_mails = setup_environment['mock_read_mails'] - mock_scheduler = setup_environment['mock_scheduler'] - MailScheduler.scheduled_bots.add("test_bot_1") - MailScheduler.scheduler = mock_scheduler - - MailScheduler.process_mails("test_bot_1", mock_scheduler) - - mock_read_mails.assert_called_once_with('test_bot_1') - assert "test_bot_1" in MailScheduler.scheduled_bots - - -@pytest.fixture -def setup_environment2(): - with patch("pymongo.MongoClient") as mock_client, \ - patch("kairon.shared.chat.data_objects.Channels.objects") as mock_channels, \ - patch("kairon.shared.channels.mail.processor.MailProcessor.read_mails", new_callable=AsyncMock) as mock_read_mails, \ - patch("apscheduler.jobstores.mongodb.MongoDBJobStore.__init__", return_value=None) as mock_jobstore_init: - - mock_client_instance = mock_client.return_value - mock_channels.return_value = MagicMock(values_list=MagicMock(return_value=[{'bot': 'test_bot_1'}, {'bot': 'test_bot_2'}])) - mock_read_mails.return_value = ([], 60) - - yield { - 'mock_client': mock_client_instance, - 'mock_channels': mock_channels, - 'mock_read_mails': mock_read_mails, - 'mock_jobstore_init': mock_jobstore_init, - } - - -@pytest.mark.asyncio -async def test_mail_scheduler_epoch_creates_scheduler(setup_environment2): - with patch("apscheduler.schedulers.background.BackgroundScheduler.start", autospec=True) as mock_start, \ - patch("apscheduler.schedulers.background.BackgroundScheduler.add_job", autospec=True) as mock_add_job: - MailScheduler.scheduler = None - - started = MailScheduler.schedule_channel_mail_reading() - - assert started - assert MailScheduler.scheduler is not None - mock_start.assert_called_once() - +@patch('kairon.shared.channels.mail.processor.MailProcessor.validate_smtp_connection') +@patch('kairon.shared.channels.mail.processor.MailProcessor.validate_imap_connection') @patch('kairon.shared.channels.mail.scheduler.Utility.get_event_server_url') @patch('kairon.shared.channels.mail.scheduler.Utility.execute_http_request') -def test_request_epoch_success(mock_execute_http_request, mock_get_event_server_url): +def test_request_epoch_success(mock_execute_http_request, mock_get_event_server_url, mock_imp, mock_smpt): mock_get_event_server_url.return_value = "http://localhost" mock_execute_http_request.return_value = {'success': True} - + bot = "test_bot" try: - MailScheduler.request_epoch() + MailScheduler.request_epoch(bot) except AppException: pytest.fail("request_epoch() raised AppException unexpectedly!") @@ -105,20 +50,20 @@ def test_request_epoch_failure(mock_execute_http_request, mock_get_event_server_ mock_execute_http_request.return_value = {'success': False} with pytest.raises(AppException): - MailScheduler.request_epoch() - - -@patch("kairon.shared.channels.mail.processor.MailProcessor.read_mails") -@patch("kairon.shared.channels.mail.scheduler.MailChannelScheduleEvent.enqueue") -@patch("kairon.shared.channels.mail.scheduler.datetime") -def test_read_mailbox_and_schedule_events(mock_datetime, mock_enqueue, mock_read_mails): - bot = "test_bot" - fixed_now = datetime(2024, 12, 1, 20, 41, 55, 390288) - mock_datetime.now.return_value = fixed_now - mock_read_mails.return_value = ([ - {"subject": "Test Subject", "mail_id": "test@example.com", "date": "2023-10-10", "body": "Test Body"} - ], "mail_channel_test_user_acc", 1200) - next_timestamp = MailScheduler.read_mailbox_and_schedule_events(bot) - mock_read_mails.assert_called_once_with(bot) - mock_enqueue.assert_called_once() - assert next_timestamp == fixed_now + timedelta(seconds=1200) \ No newline at end of file + MailScheduler.request_epoch("test_bot") + + +# @patch("kairon.shared.channels.mail.processor.MailProcessor.read_mails") +# @patch("kairon.shared.channels.mail.scheduler.MailChannelScheduleEvent.enqueue") +# @patch("kairon.shared.channels.mail.scheduler.datetime") +# def test_read_mailbox_and_schedule_events(mock_datetime, mock_enqueue, mock_read_mails): +# bot = "test_bot" +# fixed_now = datetime(2024, 12, 1, 20, 41, 55, 390288) +# mock_datetime.now.return_value = fixed_now +# mock_read_mails.return_value = ([ +# {"subject": "Test Subject", "mail_id": "test@example.com", "date": "2023-10-10", "body": "Test Body"} +# ], "mail_channel_test_user_acc", 1200) +# next_timestamp = MailScheduler.read_mailbox_and_schedule_events(bot) +# mock_read_mails.assert_called_once_with(bot) +# mock_enqueue.assert_called_once() +# assert next_timestamp == fixed_now + timedelta(seconds=1200) \ No newline at end of file diff --git a/tests/unit_test/cli_test.py b/tests/unit_test/cli_test.py index 900e1f2fb..3dce1d99f 100644 --- a/tests/unit_test/cli_test.py +++ b/tests/unit_test/cli_test.py @@ -324,8 +324,8 @@ def init_connection(self): Utility.load_environment() connect(**Utility.mongoengine_connection(Utility.environment['database']["url"])) - @mock.patch("kairon.cli.mail_channel.MailChannelScheduleEvent.execute") - def test_start_mail_channel(self, mock_execute): + @mock.patch("kairon.cli.mail_channel_process.MailProcessEvent.execute") + def test_start_mail_channel_process(self, mock_execute): from kairon.cli.mail_channel_process import process_channel_mails data = [{"mail": "test_mail"}] data = json.dumps(data) @@ -337,8 +337,8 @@ def test_start_mail_channel(self, mock_execute): mock_execute.assert_called_once() - @mock.patch("kairon.cli.mail_channel.MailChannelScheduleEvent.execute") - def test_start_mail_channel_wrong_format(self, mock_execute): + @mock.patch("kairon.cli.mail_channel_process.MailProcessEvent.execute") + def test_start_mail_channel_process_wrong_format(self, mock_execute): from kairon.cli.mail_channel_process import process_channel_mails data = {"mail": "test_mail"} data = json.dumps(data) @@ -350,6 +350,16 @@ def test_start_mail_channel_wrong_format(self, mock_execute): cli() mock_execute.assert_not_called() + @mock.patch("kairon.cli.mail_channel_read.MailReadEvent.execute") + def test_start_mail_channel_read(self, mock_execute): + from kairon.cli.mail_channel_read import read_channel_mails + with patch('argparse.ArgumentParser.parse_args', + return_value=argparse.Namespace(func=read_channel_mails, + bot="test_bot", + user="test_user")): + cli() + mock_execute.assert_called_once() + class TestMessageBroadcastCli: @pytest.fixture(autouse=True, scope="class") diff --git a/tests/unit_test/data_processor/data_processor_test.py b/tests/unit_test/data_processor/data_processor_test.py index 22c2846ec..0cefe9571 100644 --- a/tests/unit_test/data_processor/data_processor_test.py +++ b/tests/unit_test/data_processor/data_processor_test.py @@ -1350,7 +1350,7 @@ async def test_save_from_path_yml(self): assert len(list(Intents.objects(bot="test_load_yml", user="testUser", use_entities=False))) == 5 assert len(list(Intents.objects(bot="test_load_yml", user="testUser", use_entities=True))) == 27 assert len( - list(Slots.objects(bot="test_load_yml", user="testUser", influence_conversation=True, status=True))) == 12 + list(Slots.objects(bot="test_load_yml", user="testUser", influence_conversation=True, status=True))) == 15 assert len( list(Slots.objects(bot="test_load_yml", user="testUser", influence_conversation=False, status=True))) == 10 multiflow_stories = processor.load_multiflow_stories_yaml(bot='test_load_yml') @@ -3880,13 +3880,13 @@ async def test_upload_case_insensitivity(self): assert all(slot.name in ['user', 'location', 'email_id', 'application_name', 'bot', 'kairon_action_response', 'order', 'payment', 'http_status_code', 'image', 'audio', 'video', 'document', 'doc_url', 'longitude', 'latitude', 'flow_reply', 'quick_reply', - 'session_started_metadata', 'requested_slot'] for slot in domain.slots) + 'session_started_metadata', 'requested_slot', 'mail_id', 'subject', 'body'] for slot in domain.slots) assert not DeepDiff(list(domain.responses.keys()), ['utter_please_rephrase', 'utter_greet', 'utter_goodbye', 'utter_default'], ignore_order=True) assert not DeepDiff(domain.entities, ['user', 'location', 'email_id', 'application_name', 'bot', 'kairon_action_response', 'order', 'payment', 'http_status_code', 'image', 'audio', 'video', 'document', 'doc_url', - 'longitude', 'latitude', 'flow_reply', 'quick_reply'], ignore_order=True) + 'longitude', 'latitude', 'flow_reply', 'quick_reply', 'mail_id', 'subject', 'body'], ignore_order=True) assert domain.forms == {'ask_user': {'required_slots': ['user', 'email_id']}, 'ask_location': {'required_slots': ['location', 'application_name']}} assert domain.user_actions == ['ACTION_GET_GOOGLE_APPLICATION', 'ACTION_GET_MICROSOFT_APPLICATION', @@ -3985,8 +3985,8 @@ async def test_load_from_path_yml_training_files(self): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = processor.load_domain("test_load_from_path_yml_training_files") assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 - assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12 + assert domain.slots.__len__() == 27 + assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15 assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12 assert domain.intent_properties.__len__() == 32 assert len([intent for intent in domain.intent_properties.keys() if @@ -3994,7 +3994,7 @@ async def test_load_from_path_yml_training_files(self): assert len([intent for intent in domain.intent_properties.keys() if not domain.intent_properties.get(intent)['used_entities']]) == 5 assert domain.responses.keys().__len__() == 29 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.forms.__len__() == 2 assert domain.forms.__len__() == 2 assert domain.forms['ticket_attributes_form'] == { @@ -4056,11 +4056,11 @@ async def test_load_from_path_all_scenario(self): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = processor.load_domain("all") assert isinstance(domain, Domain) - assert domain.slots.__len__() == 23 + assert domain.slots.__len__() == 26 assert all(slot.mappings[0]['type'] == 'from_entity' and slot.mappings[0]['entity'] == slot.name for slot in domain.slots if slot.name not in ['requested_slot', 'session_started_metadata']) assert domain.responses.keys().__len__() == 27 - assert domain.entities.__len__() == 23 + assert domain.entities.__len__() == 26 assert domain.forms.__len__() == 2 assert domain.forms['ticket_attributes_form'] == {'required_slots': {}} assert isinstance(domain.forms, dict) @@ -4099,9 +4099,9 @@ async def test_load_from_path_all_scenario_append(self): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = processor.load_domain("all") assert isinstance(domain, Domain) - assert domain.slots.__len__() == 23 + assert domain.slots.__len__() == 26 assert domain.responses.keys().__len__() == 27 - assert domain.entities.__len__() == 23 + assert domain.entities.__len__() == 26 assert domain.forms.__len__() == 2 assert isinstance(domain.forms, dict) assert domain.user_actions.__len__() == 27 @@ -4126,10 +4126,10 @@ def test_load_domain(self): processor = MongoProcessor() domain = processor.load_domain("tests") assert isinstance(domain, Domain) - assert domain.slots.__len__() == 15 + assert domain.slots.__len__() == 18 assert [s.name for s in domain.slots if s.name == 'kairon_action_response' and s.value is None] assert domain.responses.keys().__len__() == 11 - assert domain.entities.__len__() == 14 + assert domain.entities.__len__() == 17 assert domain.form_names.__len__() == 0 assert domain.user_actions.__len__() == 11 assert domain.intents.__len__() == 14 @@ -4376,7 +4376,7 @@ def test_add_training_example_with_entity(self): ) slots = Slots.objects(bot="tests") new_slot = slots.get(name="priority") - assert slots.__len__() == 15 + assert slots.__len__() == 18 assert new_slot.name == "priority" assert new_slot.type == "text" assert new_training_example.text == "Log a critical issue" @@ -4409,7 +4409,7 @@ def test_get_training_examples_with_entities(self): for value in actual ] ) - assert slots.__len__() == 16 + assert slots.__len__() == 19 assert new_slot.name == "ticketid" assert new_slot.type == "text" expected = ["hey", "hello", "hi", "good morning", "good evening", "hey there"] @@ -4453,7 +4453,7 @@ def test_get_entities(self): processor = MongoProcessor() expected = ["bot", "priority", "file_text", "ticketid", 'kairon_action_response', 'image', 'video', 'audio', 'doc_url', 'document', 'order', 'payment', 'quick_reply', 'longitude', 'latitude', 'flow_reply', - 'http_status_code'] + 'http_status_code', 'mail_id', 'subject', 'body'] actual = processor.get_entities("tests") assert actual.__len__() == expected.__len__() assert all(item["name"] in expected for item in actual) @@ -7130,8 +7130,8 @@ def _mock_bot_info(*args, **kwargs): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 - assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12 + assert domain.slots.__len__() == 27 + assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15 assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12 assert domain.intent_properties.__len__() == 32 assert len([intent for intent in domain.intent_properties.keys() if @@ -7139,7 +7139,7 @@ def _mock_bot_info(*args, **kwargs): assert len([intent for intent in domain.intent_properties.keys() if not domain.intent_properties.get(intent)['used_entities']]) == 5 assert domain.responses.keys().__len__() == 29 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 48 assert domain.intents.__len__() == 32 @@ -7195,9 +7195,9 @@ def _mock_bot_info(*args, **kwargs): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 23 + assert domain.slots.__len__() == 26 assert domain.responses.keys().__len__() == 27 - assert domain.entities.__len__() == 23 + assert domain.entities.__len__() == 26 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 27 assert domain.intents.__len__() == 29 @@ -7275,8 +7275,8 @@ def _mock_bot_info(*args, **kwargs): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 - assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12 + assert domain.slots.__len__() == 27 + assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15 assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12 assert domain.intent_properties.__len__() == 32 assert len([intent for intent in domain.intent_properties.keys() if @@ -7284,7 +7284,7 @@ def _mock_bot_info(*args, **kwargs): assert len([intent for intent in domain.intent_properties.keys() if not domain.intent_properties.get(intent)['used_entities']]) == 5 assert domain.responses.keys().__len__() == 29 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 48 assert domain.intents.__len__() == 32 @@ -7340,8 +7340,8 @@ def _mock_bot_info(*args, **kwargs): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 - assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12 + assert domain.slots.__len__() == 27 + assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15 assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12 assert domain.intent_properties.__len__() == 33 assert len([intent for intent in domain.intent_properties.keys() if @@ -7349,7 +7349,7 @@ def _mock_bot_info(*args, **kwargs): assert len([intent for intent in domain.intent_properties.keys() if not domain.intent_properties.get(intent)['used_entities']]) == 6 assert domain.responses.keys().__len__() == 31 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 50 assert domain.intents.__len__() == 33 @@ -7390,8 +7390,8 @@ def test_delete_nlu_only(self): assert story_graph.story_steps[15].events[2].entities[0]['entity'] == 'fdresponse' domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 - assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12 + assert domain.slots.__len__() == 27 + assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15 assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12 assert domain.intent_properties.__len__() == 33 assert len([intent for intent in domain.intent_properties.keys() if @@ -7399,7 +7399,7 @@ def test_delete_nlu_only(self): assert len([intent for intent in domain.intent_properties.keys() if not domain.intent_properties.get(intent)['used_entities']]) == 6 assert domain.responses.keys().__len__() == 31 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 50 assert domain.intents.__len__() == 33 @@ -7448,8 +7448,8 @@ def test_delete_stories_only(self): assert story_graph.story_steps.__len__() == 0 domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 - assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12 + assert domain.slots.__len__() == 27 + assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15 assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12 assert domain.intent_properties.__len__() == 33 assert len([intent for intent in domain.intent_properties.keys() if @@ -7457,7 +7457,7 @@ def test_delete_stories_only(self): assert len([intent for intent in domain.intent_properties.keys() if not domain.intent_properties.get(intent)['used_entities']]) == 6 assert domain.responses.keys().__len__() == 31 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 50 assert domain.intents.__len__() == 33 @@ -7493,8 +7493,8 @@ def test_delete_multiflow_stories_only(self): assert story_graph.story_steps.__len__() == 0 domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 - assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 12 + assert domain.slots.__len__() == 27 + assert len([slot for slot in domain.slots if slot.influence_conversation is True]) == 15 assert len([slot for slot in domain.slots if slot.influence_conversation is False]) == 12 assert domain.intent_properties.__len__() == 33 assert len([intent for intent in domain.intent_properties.keys() if @@ -7502,7 +7502,7 @@ def test_delete_multiflow_stories_only(self): assert len([intent for intent in domain.intent_properties.keys() if not domain.intent_properties.get(intent)['used_entities']]) == 6 assert domain.responses.keys().__len__() == 31 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 50 assert domain.intents.__len__() == 33 @@ -7548,10 +7548,10 @@ def test_delete_config_and_actions_only(self): assert story_graph.story_steps.__len__() == 16 domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 + assert domain.slots.__len__() == 27 assert domain.intent_properties.__len__() == 33 assert domain.responses.keys().__len__() == 31 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 31 assert domain.intents.__len__() == 33 @@ -7626,10 +7626,10 @@ async def test_save_rules_and_domain_only(self, get_training_data): assert len(rules) == 3 domain = mongo_processor.load_domain(bot) assert isinstance(domain, Domain) - assert domain.slots.__len__() == 24 + assert domain.slots.__len__() == 27 assert domain.intent_properties.__len__() == 32 assert domain.responses.keys().__len__() == 27 - assert domain.entities.__len__() == 24 + assert domain.entities.__len__() == 27 assert domain.form_names.__len__() == 2 assert domain.user_actions.__len__() == 46 assert domain.intents.__len__() == 32 @@ -9375,9 +9375,16 @@ def test_get_slot(self): {'name': 'age', 'type': 'float', 'max_value': 1.0, 'min_value': 0.0, 'influence_conversation': True, '_has_been_set': False, 'is_default': False}, {'name': 'occupation', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, 'is_default': False}, - {'name': 'quick_reply', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, 'is_default': True} + {'name': 'quick_reply', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, 'is_default': True}, + {'name': 'mail_id', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, + 'is_default': True}, + {'name': 'subject', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, + 'is_default': True}, + {'name': 'body', 'type': 'text', 'influence_conversation': True, '_has_been_set': False, + 'is_default': True}, + ] - assert len(slots) == 24 + assert len(slots) == 27 assert not DeepDiff(slots, expected, ignore_order=True) def test_update_slot_add_value_intent_and_not_intent(self): diff --git a/tests/unit_test/events/definitions_test.py b/tests/unit_test/events/definitions_test.py index 2b117e873..da5d630a7 100644 --- a/tests/unit_test/events/definitions_test.py +++ b/tests/unit_test/events/definitions_test.py @@ -1236,7 +1236,7 @@ def test_validate_mail_channel_schedule_event_fail(self): @responses.activate - def test_trigger_mail_channel_schedule_event_enqueue(self): + def test_trigger_mail_channel_process_event_enqueue(self): from kairon.events.definitions.mail_channel import MailProcessEvent bot = "test_add_schedule_event" user = "test_user" @@ -1252,7 +1252,7 @@ def test_trigger_mail_channel_schedule_event_enqueue(self): pytest.fail(f"Unexpected exception: {e}") @responses.activate - def test_trigger_mail_channel_schedule_event_enqueue_exception(self): + def test_trigger_mail_channel_process_event_enqueue_exception(self): from kairon.events.definitions.mail_channel import MailProcessEvent from kairon.exceptions import AppException from unittest.mock import patch @@ -1265,11 +1265,12 @@ def test_trigger_mail_channel_schedule_event_enqueue_exception(self): json={"message": "test msg", "success": False, "error_code": 400, "data": None} ) event = MailProcessEvent(bot, user) - with pytest.raises(AppException, match="Failed to trigger email_channel_scheduler event: test msg"): + with pytest.raises(AppException, match="Failed to trigger email_channel_process_mails event: test msg"): event.enqueue() + @responses.activate - def test_trigger_mail_channel_schedule_event_execute(self): + def test_trigger_mail_channel_process_event_execute(self): from kairon.events.definitions.mail_channel import MailProcessEvent try: MailProcessEvent("", "").execute() @@ -1277,7 +1278,7 @@ def test_trigger_mail_channel_schedule_event_execute(self): pytest.fail(f"Unexpected exception: {e}") @responses.activate - def test_trigger_mail_channel_schedule_event_execute_exception(self): + def test_trigger_mail_channel_process_event_execute_exception(self): from kairon.events.definitions.mail_channel import MailProcessEvent from kairon.exceptions import AppException from unittest.mock import patch @@ -1285,4 +1286,48 @@ def test_trigger_mail_channel_schedule_event_execute_exception(self): with patch("kairon.shared.channels.mail.processor.MailProcessor.process_message_task", side_effect=Exception("Test")): with pytest.raises(AppException, match="Test"): - MailProcessEvent("", "").execute(mails=["test@mail.com"]) \ No newline at end of file + MailProcessEvent("", "").execute(mails=["test@mail.com"]) + + @responses.activate + def test_mail_channel_read_event_enqueue(self): + from kairon.events.definitions.mail_channel import MailReadEvent + bot = "test_add_schedule_event" + user = "test_user" + url = f"http://localhost:5001/api/events/execute/{EventClass.mail_channel_read_mails}?is_scheduled=False" + responses.add( + "POST", url, + json={"message": "test msg", "success": True, "error_code": 400, "data": None} + ) + event = MailReadEvent(bot, user) + try: + event.enqueue() + except AppException as e: + pytest.fail(f"Unexpected exception: {e}") + + @patch('kairon.shared.channels.mail.processor.MailProcessor.read_mails') + @patch('kairon.events.definitions.mail_channel.MailProcessEvent.enqueue') + @patch('kairon.events.definitions.mail_channel.MailProcessEvent.validate') + def test_mail_read_event_execute(self, mock_validate, mock_enqueue, mock_read_mails): + from kairon.events.definitions.mail_channel import MailReadEvent + bot = "test_add_schedule_event" + user = "test_user" + mock_read_mails.return_value = (["test@mail.com"], user, 10) + mock_validate.return_value = True + + event = MailReadEvent(bot, user) + event.execute() + + mock_read_mails.assert_called_once_with(bot) + mock_validate.assert_called_once() + mock_enqueue.assert_called_once_with(mails=["test@mail.com"]) + + def test_mail_read_event_execute_exception(self): + bot = "test_add_schedule_event" + user = "test_user" + + with patch('kairon.shared.channels.mail.processor.MailProcessor.read_mails', + side_effect=Exception("Test error")): + from kairon.events.definitions.mail_channel import MailReadEvent + event = MailReadEvent(bot, user) + with pytest.raises(AppException, match=f"Failed to schedule mail reading for bot {bot}. Error: Test error"): + event.execute() \ No newline at end of file From 10c39eab57c9bb8a49633db661d022b6c9e89686 Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Sun, 8 Dec 2024 15:11:07 +0530 Subject: [PATCH 22/27] changes and test cases --- kairon/shared/channels/mail/processor.py | 42 ++++++++---------------- kairon/shared/channels/mail/scheduler.py | 5 --- system.yaml | 2 +- 3 files changed, 14 insertions(+), 35 deletions(-) diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index 9e5bf00c5..cd09bc959 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -9,7 +9,6 @@ from kairon.shared.account.data_objects import Bot from kairon.shared.channels.mail.constants import MailConstants from kairon.shared.channels.mail.data_objects import MailResponseLog, MailStatus, MailChannelStateData -from kairon.shared.chat.data_objects import Channels from kairon.shared.chat.processor import ChatDataProcessor from kairon.shared.constants import ChannelTypes from kairon.shared.data.data_objects import BotSettings @@ -105,16 +104,17 @@ def validate_imap_connection(bot): async def send_mail(self, to: str, subject: str, body: str, log_id: str): try: - email_account = self.config['email_account'] - msg = MIMEMultipart() - msg['From'] = email_account - msg['To'] = to - msg['Subject'] = subject - msg.attach(MIMEText(body, 'html')) - self.smtp.sendmail(email_account, to, msg.as_string()) - mail_log = MailResponseLog.objects.get(id=log_id) - mail_log.status = MailStatus.SUCCESS.value - mail_log.save() + if body and len(body) > 0: + email_account = self.config['email_account'] + msg = MIMEMultipart() + msg['From'] = email_account + msg['To'] = to + msg['Subject'] = subject + msg.attach(MIMEText(body, 'html')) + self.smtp.sendmail(email_account, to, msg.as_string()) + mail_log = MailResponseLog.objects.get(id=log_id) + mail_log.status = MailStatus.SUCCESS.value + mail_log.save() except Exception as e: logger.error(f"Error sending mail to {to}: {str(e)}") mail_log = MailResponseLog.objects.get(id=log_id) @@ -128,8 +128,9 @@ def process_mail(self, rasa_chat_response: dict, log_id: str): if len(split_result) == 2 for key, value in [split_result]} - responses = '

'.join(response.get('text', '') for response in rasa_chat_response.get('response', [])) + if len(responses) == 0: + return '' slots['bot_response'] = responses mail_template = self.mail_template mail_log = MailResponseLog.objects.get(id=log_id) @@ -163,7 +164,6 @@ def get_log(bot_id: str, offset: int, limit: int) -> dict: @staticmethod async def process_messages(bot: str, batch: [dict]): - logger.info(f"processing messages: {bot}, {batch}") """ Pass messages to bot and send responses """ @@ -252,7 +252,6 @@ def read_mails(bot: str) -> ([dict], str, int): is_logged_in = False last_processed_uid = mp.state.last_email_uid query = f'{int(last_processed_uid) + 1}:*' - logger.info(query) try: mp.login_imap() is_logged_in = True @@ -266,7 +265,6 @@ def read_mails(bot: str) -> ([dict], str, int): date = msg.date body = msg.text or msg.html or "" #attachments = msg.attachments - logger.info(f"reading: {subject}, {sender_id}, {date}") mail_log = MailResponseLog(sender_id = sender_id, subject = subject, body = body, @@ -297,17 +295,3 @@ def read_mails(bot: str) -> ([dict], str, int): return [], mp.bot_settings.user, time_shift - # @staticmethod - # def extract_jsons_from_text(text) -> list: - # """ - # Extract json objects from text as a list - # """ - # json_pattern = re.compile(r'(\{.*?\}|\[.*?\])', re.DOTALL) - # jsons = [] - # for match in json_pattern.findall(text): - # try: - # json_obj = json.loads(match) - # jsons.append(json_obj) - # except json.JSONDecodeError: - # continue - # return jsons diff --git a/kairon/shared/channels/mail/scheduler.py b/kairon/shared/channels/mail/scheduler.py index e85315046..adff58319 100644 --- a/kairon/shared/channels/mail/scheduler.py +++ b/kairon/shared/channels/mail/scheduler.py @@ -1,10 +1,5 @@ -from datetime import datetime, timedelta from urllib.parse import urljoin -from apscheduler.jobstores.mongodb import MongoDBJobStore -from apscheduler.schedulers.background import BackgroundScheduler -from pymongo import MongoClient -from uuid6 import uuid7 from kairon import Utility from kairon.exceptions import AppException diff --git a/system.yaml b/system.yaml index cb030254f..f4e7e4a60 100644 --- a/system.yaml +++ b/system.yaml @@ -120,7 +120,7 @@ notifications: events: server_url: ${EVENT_SERVER_ENDPOINT:"http://localhost:5056"} executor: - type: ${EVENTS_EXECUTOR_TYPE:"standalone"} + type: ${EVENTS_EXECUTOR_TYPE} region: ${EVENTS_EXECUTOR_REGION} timeout: ${EVENTS_EXECUTOR_TIMEOUT_MINUTES:60} queue: From fa135b88ce28a9169297da47afaee745d5378a41 Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Sun, 8 Dec 2024 21:35:28 +0530 Subject: [PATCH 23/27] changes and test cases --- kairon/api/app/routers/bot/bot.py | 2 +- kairon/cli/mail_channel_read.py | 3 +- kairon/events/definitions/mail_channel.py | 1 - kairon/events/utility.py | 3 +- kairon/shared/channels/mail/data_objects.py | 4 +- kairon/shared/channels/mail/processor.py | 1 + tests/unit_test/channels/mail_channel_test.py | 69 +++++++++++-- .../unit_test/channels/mail_scheduler_test.py | 98 ++++++++++++++++--- 8 files changed, 151 insertions(+), 30 deletions(-) diff --git a/kairon/api/app/routers/bot/bot.py b/kairon/api/app/routers/bot/bot.py index 6fe85cb2d..318981640 100644 --- a/kairon/api/app/routers/bot/bot.py +++ b/kairon/api/app/routers/bot/bot.py @@ -1663,7 +1663,7 @@ async def get_slot_actions( @router.get("/mail_channel/logs", response_model=Response) -async def get_action_server_logs(start_idx: int = 0, page_size: int = 10, +async def get_mail_channel_logs(start_idx: int = 0, page_size: int = 10, current_user: User = Security(Authentication.get_current_user_and_bot, scopes=TESTER_ACCESS)): """ diff --git a/kairon/cli/mail_channel_read.py b/kairon/cli/mail_channel_read.py index 274bae61b..2ee345955 100644 --- a/kairon/cli/mail_channel_read.py +++ b/kairon/cli/mail_channel_read.py @@ -1,9 +1,8 @@ -import json from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter from typing import List from rasa.cli import SubParsersAction -from kairon.events.definitions.mail_channel import MailProcessEvent, MailReadEvent +from kairon.events.definitions.mail_channel import MailReadEvent def read_channel_mails(args): diff --git a/kairon/events/definitions/mail_channel.py b/kairon/events/definitions/mail_channel.py index 30d5b98cf..533a1ea0f 100644 --- a/kairon/events/definitions/mail_channel.py +++ b/kairon/events/definitions/mail_channel.py @@ -96,4 +96,3 @@ def execute(self, **kwargs): except Exception as e: raise AppException(f"Failed to schedule mail reading for bot {self.bot}. Error: {str(e)}") - is_initialized = False \ No newline at end of file diff --git a/kairon/events/utility.py b/kairon/events/utility.py index aae90e4e1..1d4f0be24 100644 --- a/kairon/events/utility.py +++ b/kairon/events/utility.py @@ -40,8 +40,9 @@ def update_job(event_type: Text, request_data: Dict, is_scheduled: bool): @staticmethod def schedule_channel_mail_reading(bot: str): + from kairon.shared.channels.mail.processor import MailProcessor + try: - from kairon.shared.channels.mail.processor import MailProcessor mail_processor = MailProcessor(bot) interval = mail_processor.config.get("interval", 60) event_id = mail_processor.state.event_id diff --git a/kairon/shared/channels/mail/data_objects.py b/kairon/shared/channels/mail/data_objects.py index 93d49a6c6..ab387e0eb 100644 --- a/kairon/shared/channels/mail/data_objects.py +++ b/kairon/shared/channels/mail/data_objects.py @@ -1,10 +1,8 @@ import time from enum import Enum -from mongoengine import Document, StringField, ListField, FloatField, BooleanField, DictField, IntField -from kairon.exceptions import AppException +from mongoengine import Document, StringField, ListField, FloatField, DictField, IntField from kairon.shared.data.audit.data_objects import Auditlog -from kairon.shared.data.signals import auditlog, push_notification diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index cd09bc959..e17d33ded 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -119,6 +119,7 @@ async def send_mail(self, to: str, subject: str, body: str, log_id: str): logger.error(f"Error sending mail to {to}: {str(e)}") mail_log = MailResponseLog.objects.get(id=log_id) mail_log.status = MailStatus.FAILED.value + mail_log.responses.append(str(e)) mail_log.save() def process_mail(self, rasa_chat_response: dict, log_id: str): diff --git a/tests/unit_test/channels/mail_channel_test.py b/tests/unit_test/channels/mail_channel_test.py index 6cc3aa750..c4cd2f127 100644 --- a/tests/unit_test/channels/mail_channel_test.py +++ b/tests/unit_test/channels/mail_channel_test.py @@ -9,7 +9,7 @@ from uuid6 import uuid7 from kairon import Utility -from kairon.shared.channels.mail.data_objects import MailResponseLog, MailChannelStateData +from kairon.shared.channels.mail.data_objects import MailResponseLog, MailChannelStateData, MailStatus os.environ["system_file"] = "./tests/testing_data/system.yaml" Utility.load_environment() @@ -200,6 +200,44 @@ async def test_send_mail(self, mock_get_channel_config, mock_smtp): assert "Test Subject" in mock_smtp_instance.sendmail.call_args[0][2] assert "Test Body" in mock_smtp_instance.sendmail.call_args[0][2] + @patch("kairon.shared.channels.mail.processor.smtplib.SMTP") + @patch("kairon.shared.chat.processor.ChatDataProcessor.get_channel_config") + @pytest.mark.asyncio + async def test_send_mail_exception(self, mock_get_channel_config, mock_smtp): + mock_smtp_instance = MagicMock() + mock_smtp.return_value = mock_smtp_instance + + mail_response_log = MailResponseLog(bot=pytest.mail_test_bot, + sender_id="recipient@test.com", + user="mail_channel_test_user_acc", + subject="Test Subject", + body="Test Body", + ) + mail_response_log.save() + + mock_get_channel_config.return_value = { + 'config': { + 'email_account': "mail_channel_test_user_acc@testuser.com", + 'email_password': "password", + 'smtp_server': "smtp.testuser.com", + 'smtp_port': 587 + } + } + + bot_id = pytest.mail_test_bot + mp = MailProcessor(bot=bot_id) + mp.login_smtp() + + mock_smtp_instance.sendmail.side_effect = Exception("SMTP error") + + await mp.send_mail("recipient@test.com", "Test Subject", "Test Body", mail_response_log.id) + + log = MailResponseLog.objects.get(id=mail_response_log.id) + print(log.to_mongo()) + assert log.status == MailStatus.FAILED.value + assert log.responses == ['SMTP error'] + MailResponseLog.objects().delete() + @patch("kairon.shared.channels.mail.processor.ChatDataProcessor.get_channel_config") @@ -379,34 +417,49 @@ async def test_process_messages_exception(self, mock_exc): @patch('kairon.shared.channels.mail.processor.MailProcessor.login_smtp') @patch('kairon.shared.channels.mail.processor.MailProcessor.logout_smtp') def test_validate_smpt_connection(self, mp, mock_logout_smtp, mock_login_smtp): - # Mock the login and logout methods to avoid actual SMTP server interaction mp.return_value = None mock_login_smtp.return_value = None mock_logout_smtp.return_value = None - # Call the static method validate_smpt_connection result = MailProcessor.validate_smtp_connection('test_bot_id') - # Assert that the method returns True assert result - # Assert that login_smtp and logout_smtp were called once mock_login_smtp.assert_called_once() mock_logout_smtp.assert_called_once() @patch('kairon.shared.channels.mail.processor.MailProcessor.login_smtp') @patch('kairon.shared.channels.mail.processor.MailProcessor.logout_smtp') def test_validate_smpt_connection_failure(self, mock_logout_smtp, mock_login_smtp): - # Mock the login method to raise an exception mock_login_smtp.side_effect = Exception("SMTP login failed") - # Call the static method validate_smpt_connection result = MailProcessor.validate_smtp_connection('test_bot_id') - # Assert that the method returns False assert not result + @patch('kairon.shared.channels.mail.processor.MailProcessor.__init__') + @patch('kairon.shared.channels.mail.processor.MailProcessor.login_imap') + @patch('kairon.shared.channels.mail.processor.MailProcessor.logout_imap') + def test_validate_imap_connection(self, mp, mock_logout_imap, mock_login_imap): + mp.return_value = None + mock_login_imap.return_value = None + mock_logout_imap.return_value = None + + result = MailProcessor.validate_imap_connection('test_bot_id') + + assert result + mock_login_imap.assert_called_once() + mock_logout_imap.assert_called_once() + + @patch('kairon.shared.channels.mail.processor.MailProcessor.login_imap') + @patch('kairon.shared.channels.mail.processor.MailProcessor.logout_imap') + def test_validate_imap_connection_failure(self, mock_logout_imap, mock_login_imap): + mock_login_imap.side_effect = Exception("imap login failed") + + result = MailProcessor.validate_imap_connection('test_bot_id') + + assert not result def test_get_mail_channel_state_data_existing_state(self): bot_id = pytest.mail_test_bot diff --git a/tests/unit_test/channels/mail_scheduler_test.py b/tests/unit_test/channels/mail_scheduler_test.py index ad7fb123e..8df57f86d 100644 --- a/tests/unit_test/channels/mail_scheduler_test.py +++ b/tests/unit_test/channels/mail_scheduler_test.py @@ -43,6 +43,18 @@ def test_request_epoch_success(mock_execute_http_request, mock_get_event_server_ except AppException: pytest.fail("request_epoch() raised AppException unexpectedly!") +@patch('kairon.shared.channels.mail.processor.MailProcessor.validate_smtp_connection') +@patch('kairon.shared.channels.mail.processor.MailProcessor.validate_imap_connection') +@patch('kairon.shared.channels.mail.scheduler.Utility.get_event_server_url') +@patch('kairon.shared.channels.mail.scheduler.Utility.execute_http_request') +def test_request_epoch__response_not_success(mock_execute_http_request, mock_get_event_server_url, mock_imp, mock_smpt): + mock_get_event_server_url.return_value = "http://localhost" + mock_execute_http_request.return_value = {'success': False} + bot = "test_bot" + with pytest.raises(AppException): + MailScheduler.request_epoch(bot) + + @patch('kairon.shared.channels.mail.scheduler.Utility.get_event_server_url') @patch('kairon.shared.channels.mail.scheduler.Utility.execute_http_request') def test_request_epoch_failure(mock_execute_http_request, mock_get_event_server_url): @@ -53,17 +65,75 @@ def test_request_epoch_failure(mock_execute_http_request, mock_get_event_server_ MailScheduler.request_epoch("test_bot") -# @patch("kairon.shared.channels.mail.processor.MailProcessor.read_mails") -# @patch("kairon.shared.channels.mail.scheduler.MailChannelScheduleEvent.enqueue") -# @patch("kairon.shared.channels.mail.scheduler.datetime") -# def test_read_mailbox_and_schedule_events(mock_datetime, mock_enqueue, mock_read_mails): -# bot = "test_bot" -# fixed_now = datetime(2024, 12, 1, 20, 41, 55, 390288) -# mock_datetime.now.return_value = fixed_now -# mock_read_mails.return_value = ([ -# {"subject": "Test Subject", "mail_id": "test@example.com", "date": "2023-10-10", "body": "Test Body"} -# ], "mail_channel_test_user_acc", 1200) -# next_timestamp = MailScheduler.read_mailbox_and_schedule_events(bot) -# mock_read_mails.assert_called_once_with(bot) -# mock_enqueue.assert_called_once() -# assert next_timestamp == fixed_now + timedelta(seconds=1200) \ No newline at end of file + +@patch('kairon.events.utility.KScheduler.add_job') +@patch('kairon.events.utility.KScheduler.update_job') +@patch('kairon.events.utility.KScheduler.__init__', return_value=None) +@patch('kairon.shared.channels.mail.processor.MailProcessor') +@patch('pymongo.MongoClient', autospec=True) +def test_schedule_channel_mail_reading(mock_mongo, mock_mail_processor, mock_kscheduler, mock_update_job, mock_add_job): + from kairon.events.utility import EventUtility + + bot = "test_bot" + mock_mail_processor_instance = mock_mail_processor.return_value + mock_mail_processor_instance.config = {"interval": 1} + mock_mail_processor_instance.state.event_id = None + mock_mail_processor_instance.bot_settings.user = "test_user" + +# # Test case when event_id is None + EventUtility.schedule_channel_mail_reading(bot) + mock_add_job.assert_called_once() + mock_update_job.assert_not_called() + + mock_add_job.reset_mock() + mock_update_job.reset_mock() + mock_mail_processor_instance.state.event_id = "existing_event_id" + + # Test case when event_id exists + EventUtility.schedule_channel_mail_reading(bot) + mock_update_job.assert_called_once() + mock_add_job.assert_not_called() + +@patch('kairon.events.utility.KScheduler.add_job') +@patch('kairon.events.utility.KScheduler', autospec=True) +@patch('kairon.shared.channels.mail.processor.MailProcessor') +@patch('pymongo.MongoClient', autospec=True) +def test_schedule_channel_mail_reading_exception(mock_mongo_client, mock_mail_processor, mock_kscheduler, mock_add_job): + from kairon.events.utility import EventUtility + + bot = "test_bot" + mock_mail_processor.side_effect = Exception("Test Exception") + + with pytest.raises(AppException) as excinfo: + EventUtility.schedule_channel_mail_reading(bot) + assert str(excinfo.value) == f"Failed to schedule mail reading for bot {bot}. Error: Test Exception" + + +@patch('kairon.events.utility.EventUtility.schedule_channel_mail_reading') +@patch('kairon.shared.chat.data_objects.Channels.objects') +def test_reschedule_all_bots_channel_mail_reading(mock_channels_objects, mock_schedule_channel_mail_reading): + from kairon.events.utility import EventUtility + + mock_channels_objects.return_value.distinct.return_value = ['bot1', 'bot2'] + + EventUtility.reschedule_all_bots_channel_mail_reading() + + mock_channels_objects.return_value.distinct.assert_called_once_with("bot") + assert mock_schedule_channel_mail_reading.call_count == 2 + mock_schedule_channel_mail_reading.assert_any_call('bot1') + mock_schedule_channel_mail_reading.assert_any_call('bot2') + +@patch('kairon.events.utility.EventUtility.schedule_channel_mail_reading') +@patch('kairon.shared.chat.data_objects.Channels.objects') +def test_reschedule_all_bots_channel_mail_reading_exception(mock_channels_objects, mock_schedule_channel_mail_reading): + from kairon.events.utility import EventUtility + + mock_channels_objects.return_value.distinct.return_value = ['bot1', 'bot2'] + mock_schedule_channel_mail_reading.side_effect = Exception("Test Exception") + + with pytest.raises(AppException) as excinfo: + EventUtility.reschedule_all_bots_channel_mail_reading() + + assert str(excinfo.value) == "Failed to reschedule mail reading events. Error: Test Exception" + mock_channels_objects.return_value.distinct.assert_called_once_with("bot") + assert mock_schedule_channel_mail_reading.call_count == 1 \ No newline at end of file From 64d6da5141c035bc4fe1990052cd00198a0af166 Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Sun, 8 Dec 2024 21:59:04 +0530 Subject: [PATCH 24/27] changes and test cases --- kairon/api/app/routers/bot/bot.py | 1 - kairon/events/definitions/mail_channel.py | 2 +- kairon/shared/data/data_models.py | 7 ------- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/kairon/api/app/routers/bot/bot.py b/kairon/api/app/routers/bot/bot.py index 318981640..1e1580598 100644 --- a/kairon/api/app/routers/bot/bot.py +++ b/kairon/api/app/routers/bot/bot.py @@ -34,7 +34,6 @@ from kairon.shared.data.audit.processor import AuditDataProcessor from kairon.shared.data.constant import ENDPOINT_TYPE, ModelTestType, \ AuditlogActions -from kairon.shared.data.data_models import MailConfigRequest from kairon.shared.data.data_objects import TrainingExamples, ModelTraining, Rules from kairon.shared.data.model_processor import ModelProcessor from kairon.shared.data.processor import MongoProcessor diff --git a/kairon/events/definitions/mail_channel.py b/kairon/events/definitions/mail_channel.py index 533a1ea0f..07fc4b901 100644 --- a/kairon/events/definitions/mail_channel.py +++ b/kairon/events/definitions/mail_channel.py @@ -88,7 +88,7 @@ def execute(self, **kwargs): try: vals = MailProcessor.read_mails(self.bot) print(vals) - emails, user, next_delay = vals + emails, _, next_delay = vals for email in emails: ev = MailProcessEvent(self.bot, self.user) ev.validate() diff --git a/kairon/shared/data/data_models.py b/kairon/shared/data/data_models.py index 1fcc76791..8be28e7e7 100644 --- a/kairon/shared/data/data_models.py +++ b/kairon/shared/data/data_models.py @@ -1342,10 +1342,3 @@ def validate_name(cls, values): return values - -class MailConfigRequest(BaseModel): - intent: str - entities: list[str] = [] - subjects: list[str] = [] - classification_prompt: str - reply_template: str = None \ No newline at end of file From 8380bf60f8348020a4177f08bae3695b5c1b9dc0 Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Sun, 8 Dec 2024 22:04:16 +0530 Subject: [PATCH 25/27] changes and test cases --- kairon/events/definitions/mail_channel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kairon/events/definitions/mail_channel.py b/kairon/events/definitions/mail_channel.py index 07fc4b901..61b09fd7e 100644 --- a/kairon/events/definitions/mail_channel.py +++ b/kairon/events/definitions/mail_channel.py @@ -88,7 +88,7 @@ def execute(self, **kwargs): try: vals = MailProcessor.read_mails(self.bot) print(vals) - emails, _, next_delay = vals + emails, _, _ = vals for email in emails: ev = MailProcessEvent(self.bot, self.user) ev.validate() From d0753373f1e5c3b83db79cbe27f6be994aa10cd7 Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Mon, 9 Dec 2024 11:59:19 +0530 Subject: [PATCH 26/27] changes and test cases --- kairon/shared/channels/mail/processor.py | 1 - system.yaml | 2 - tests/integration_test/action_service_test.py | 116 ++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index e17d33ded..09f4e1a7b 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -205,7 +205,6 @@ async def process_messages(bot: str, batch: [dict]): { 'channel': ChannelTypes.MAIL.value }) - # logger.info(chat_responses) for index, response in enumerate(chat_responses): responses[index]['body'] = mp.process_mail(response, log_id=batch[index]['log_id']) diff --git a/system.yaml b/system.yaml index f4e7e4a60..3f4596832 100644 --- a/system.yaml +++ b/system.yaml @@ -127,7 +127,6 @@ events: type: ${EVENTS_QUEUE_TYPE:"mongo"} url: ${EVENTS_QUEUE_URL:"mongodb://localhost:27017/events"} name: ${EVENTS_DB_NAME:"kairon_events"} - mail_queue_name: ${EVENTS_MAIL_QUEUE_NAME:"mail_queue"} task_definition: model_training: ${MODEL_TRAINING_TASK_DEFINITION} model_testing: ${MODEL_TESTING_TASK_DEFINITION} @@ -142,7 +141,6 @@ events: content_importer: ${DOC_CONTENT_IMPORTER_TASK_DEFINITION} scheduler: collection: ${EVENT_SCHEDULER_COLLECTION:"kscheduler"} - mail_scheduler_collection: ${MAIL_SCHEDULER_COLLECTION:"mail_scheduler"} type: ${EVENT_SCHEDULER_TYPE:"kscheduler"} min_trigger_interval: ${MIN_SCHDULER_TRIGGER_INTERVAL:86340} audit_logs: diff --git a/tests/integration_test/action_service_test.py b/tests/integration_test/action_service_test.py index 35f50340c..0de5a2fde 100644 --- a/tests/integration_test/action_service_test.py +++ b/tests/integration_test/action_service_test.py @@ -11756,6 +11756,122 @@ def test_prompt_action_response_action_with_prompt_question_from_slot(mock_embed ] +@mock.patch.object(litellm, "aembedding", autospec=True) +@mock.patch.object(ActionUtility, 'execute_request_async', autospec=True) +def test_prompt_action_response_action_with_prompt_question_from_slot_perplexity(mock_execute_request_async, mock_embedding, aioresponses): + from uuid6 import uuid7 + llm_type = "perplexity" + action_name = "test_prompt_action_response_action_with_prompt_question_from_slot" + bot = "5f50fd0a56b69s8ca10d35d2l" + user = "udit.pandey" + value = "keyvalue" + user_msg = "What kind of language is python?" + bot_content = "Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation. Python is dynamically typed and garbage-collected." + generated_text = "Python is dynamically typed, garbage-collected, high level, general purpose programming." + llm_prompts = [ + {'name': 'System Prompt', + 'data': 'You are a personal assistant. Answer question based on the context below.', + 'type': 'system', 'source': 'static', 'is_enabled': True}, + {'name': 'History Prompt', 'type': 'user', 'source': 'history', 'is_enabled': True}, + {'name': 'Query Prompt', 'data': "What kind of language is python?", 'instructions': 'Rephrase the query.', + 'type': 'query', 'source': 'static', 'is_enabled': False}, + {'name': 'Similarity Prompt', + 'instructions': 'Answer question based on the context above, if answer is not in the context go check previous logs.', + 'type': 'user', 'source': 'bot_content', 'data': 'python', + 'hyperparameters': {"top_results": 10, "similarity_threshold": 0.70}, + 'is_enabled': True} + ] + mock_execute_request_async.return_value = ( + { + 'formatted_response': 'Python is dynamically typed, garbage-collected, high level, general purpose programming.', + 'response': 'Python is dynamically typed, garbage-collected, high level, general purpose programming.'}, + 200, + mock.ANY, + mock.ANY + ) + embedding = list(np.random.random(OPENAI_EMBEDDING_OUTPUT)) + mock_embedding.return_value = litellm.EmbeddingResponse(**{'data': [{'embedding': embedding}]}) + expected_body = {'messages': [ + {'role': 'system', 'content': 'You are a personal assistant. Answer question based on the context below.\n'}, + {'role': 'user', 'content': 'hello'}, {'role': 'assistant', 'content': 'how are you'}, {'role': 'user', + 'content': "\nInstructions on how to use Similarity Prompt:\n['Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation. Python is dynamically typed and garbage-collected.']\nAnswer question based on the context above, if answer is not in the context go check previous logs.\n \nQ: What kind of language is python? \nA:"}], + 'metadata': {'user': 'udit.pandey', 'bot': '5f50fd0a56b698ca10d35d2l', 'invocation': 'prompt_action'}, + 'api_key': 'keyvalue', + 'num_retries': 3, 'temperature': 0.0, 'max_tokens': 300, 'model': 'gpt-4o-mini', 'top_p': 0.0, 'n': 1, + 'stop': None, 'presence_penalty': 0.0, 'frequency_penalty': 0.0, 'logit_bias': {}} + aioresponses.add( + url=urljoin(Utility.environment['llm']['url'], + f"/{bot}/completion/{llm_type}"), + method="POST", + status=200, + payload={'formatted_response': generated_text, 'response': generated_text}, + body=json.dumps(expected_body) + ) + aioresponses.add( + url=f"{Utility.environment['vector']['db']}/collections/{bot}_python_faq_embd/points/search", + body={'vector': embedding}, + payload={'result': [{'id': uuid7().__str__(), 'score': 0.80, 'payload': {'content': bot_content}}]}, + method="POST", + status=200 + ) + hyperparameters = Utility.get_llm_hyperparameters("perplexity") + hyperparameters['search_domain_filter'] = ["domain1.com", "domain2.com"] + Actions(name=action_name, type=ActionType.prompt_action.value, bot=bot, user=user).save() + BotSettings(llm_settings=LLMSettings(enable_faq=True), bot=bot, user=user).save() + PromptAction(name=action_name, bot=bot, user=user, num_bot_responses=2, llm_prompts=llm_prompts, llm_type="perplexity", hyperparameters = hyperparameters, + user_question=UserQuestion(type="from_slot", value="prompt_question")).save() + llm_secret = LLMSecret( + llm_type=llm_type, + api_key=value, + models=["perplexity/llama-3.1-sonar-small-128k-online", "perplexity/llama-3.1-sonar-large-128k-online", "perplexity/llama-3.1-sonar-huge-128k-online"], + bot=bot, + user=user + ) + llm_secret.save() + llm_secret = LLMSecret( + llm_type="openai", + api_key="api_key", + models=["gpt-3.5-turbo", "gpt-4o-mini"], + bot=bot, + user=user + ) + llm_secret.save() + request_object = json.load(open("tests/testing_data/actions/action-request.json")) + request_object["tracker"]["slots"] = {"bot": bot, "prompt_question": user_msg} + request_object["next_action"] = action_name + request_object["tracker"]["sender_id"] = user + request_object['tracker']['events'] = [{"event": "user", 'text': 'hello', + "data": {"elements": '', "quick_replies": '', "buttons": '', + "attachment": '', "image": '', "custom": ''}}, + {'event': 'bot', "text": "how are you", + "data": {"elements": '', "quick_replies": '', "buttons": '', + "attachment": '', "image": '', "custom": ''}}] + response = client.post("/webhook", json=request_object) + response_json = response.json() + mock_execute_request_async.assert_called_once_with( + http_url=f"{Utility.environment['llm']['url']}/{urllib.parse.quote(bot)}/completion/{llm_type}", + request_method="POST", + request_body={ + 'messages': [{'role': 'system', 'content': 'You are a personal assistant. Answer question based on the context below.\n'}, + {'role': 'user', 'content': 'hello'}, + {'role': 'assistant', 'content': 'how are you'}, + {'role': 'user', 'content': "\nInstructions on how to use Similarity Prompt:\n['Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation. Python is dynamically typed and garbage-collected.']\nAnswer question based on the context above, if answer is not in the context go check previous logs.\n \nQ: What kind of language is python? inurl:domain1.com|domain2.com \nA:"}], + 'hyperparameters': hyperparameters, + 'user': user, + 'invocation': "prompt_action" + }, + timeout=Utility.environment['llm'].get('request_timeout', 30) + ) + called_args = mock_execute_request_async.call_args + user_message = called_args.kwargs['request_body']['messages'][-1]['content'] + assert "inurl:domain1.com|domain2.com" in user_message + assert response_json['events'] == [ + {'event': 'slot', 'timestamp': None, 'name': 'kairon_action_response', 'value': generated_text}] + assert response_json['responses'] == [ + {'text': generated_text, 'buttons': [], 'elements': [], 'custom': {}, 'template': None, + 'response': None, 'image': None, 'attachment': None} + ] + @mock.patch.object(litellm, "aembedding", autospec=True) def test_prompt_action_response_action_with_prompt_question_from_slot_different_embedding_completion(mock_embedding, aioresponses): from uuid6 import uuid7 From 97752a228f94714ab7ae85269ff7e5faea0f0737 Mon Sep 17 00:00:00 2001 From: spandan_mondal Date: Mon, 9 Dec 2024 13:16:58 +0530 Subject: [PATCH 27/27] changes and test cases --- kairon/shared/channels/mail/processor.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/kairon/shared/channels/mail/processor.py b/kairon/shared/channels/mail/processor.py index 09f4e1a7b..f68f286bb 100644 --- a/kairon/shared/channels/mail/processor.py +++ b/kairon/shared/channels/mail/processor.py @@ -103,6 +103,7 @@ def validate_imap_connection(bot): return False async def send_mail(self, to: str, subject: str, body: str, log_id: str): + exception = None try: if body and len(body) > 0: email_account = self.config['email_account'] @@ -112,14 +113,14 @@ async def send_mail(self, to: str, subject: str, body: str, log_id: str): msg['Subject'] = subject msg.attach(MIMEText(body, 'html')) self.smtp.sendmail(email_account, to, msg.as_string()) - mail_log = MailResponseLog.objects.get(id=log_id) - mail_log.status = MailStatus.SUCCESS.value - mail_log.save() except Exception as e: logger.error(f"Error sending mail to {to}: {str(e)}") + exception = str(e) + finally: mail_log = MailResponseLog.objects.get(id=log_id) - mail_log.status = MailStatus.FAILED.value - mail_log.responses.append(str(e)) + mail_log.status = MailStatus.FAILED.value if exception else MailStatus.SUCCESS.value + if exception: + mail_log.responses.append(exception) mail_log.save() def process_mail(self, rasa_chat_response: dict, log_id: str):