From f6636afe595ce9986a4dfdc3facd27c43f9243c6 Mon Sep 17 00:00:00 2001 From: khushal Date: Mon, 20 Jan 2025 14:14:02 +0530 Subject: [PATCH] (20250120) Mail sending API ready. --- api/blueprints/message/mail/send/send.py | 104 ++++++++-- api/main.py | 6 + controllers/core/ai/llm.py | 2 +- controllers_v2/message/mail/all_mail.py | 16 +- controllers_v2/message/mail/base.py | 19 +- controllers_v2/message/mail/gmail.py | 118 +++++++++++- models/api/message/mail/send.py | 230 ++++++++++++++++++----- models/core/ai/llm.py | 20 +- 8 files changed, 442 insertions(+), 73 deletions(-) diff --git a/api/blueprints/message/mail/send/send.py b/api/blueprints/message/mail/send/send.py index 7ee201c..14d9e93 100644 --- a/api/blueprints/message/mail/send/send.py +++ b/api/blueprints/message/mail/send/send.py @@ -6,7 +6,7 @@ DATE: - Thursday, 19th Dec., 2024 + Monday, 20th Jan., 2025. OBJECTIVE: @@ -45,6 +45,7 @@ from quart import Blueprint, current_app, g, request # My utils: from utils_v2.string import json from utils_v2.database.async_mongo_v2 import AsyncMongo +from utils_v2.logging.context import AsyncLoggerContext from utils_v2.api.codes import StatusCodes, HttpCodes from utils_v2.api.response import ResponseModel from utils_v2.api.async_quart import ( @@ -63,6 +64,7 @@ from utils_v2.api.async_quart import ( # GMail-related utils: from utils_v2.goog.controllers.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT +from utils_v2.goog.controllers.gmail.gmail_message import GmailMessage from utils_v2.goog.models.auth_tokens import GoogleAuthTokens # Common: @@ -70,7 +72,9 @@ from shared import constants # Data Models: from models.api.message.mail.send import MailSendRequestHeaders, MailSendRequestData +from models.message.mail.send import MailSendOneResult from models.core.user import CoreUserInfoModel +from models.core.auth_token import CoreAuthTokenModel # To work with datatypes: from typing import Literal @@ -124,6 +128,50 @@ def init(blueprint_setup_state): # --------------------------------------------------------------------------------------------------------------------- +@AsyncLoggerContext.log_it( + api_version = "1.0.0", + project = constants.PROJECT_NAME, + log_type = constants.MODULE_NAME, + operation = "mailGmailMsgCreate", + log_input = 2, + log_output = 1, + sensitive_keys = ["sessionToken", "X-Session-Token"] +) +async def create_gmail_mail_message( + from_email: str, + inbound_data: MailSendRequestData +) -> GmailMessage: + + """ + To create a mail message object of to be sent via Gmail. + :param from_email: The e-mail id of the sender. + :param inbound_data: The data that came in with the request. + :return: The mail message object that can be sent via Gmail. + """ + + # Create the instance of the message: + mail_message = GmailMessage( + from_email = from_email, + to_email = inbound_data.to, + subject = inbound_data.subject, + cc_emails = inbound_data.cc, + bcc_emails = inbound_data.bcc + ) + + # Add all the parts one-by-one: + for part in inbound_data.body: + if part.type == "plain": mail_message.add_text(part.part.content) + elif part.type == "html": mail_message.add_html(part.part.content) + elif part.type == "inline": mail_message.add_inline_image(part.part.content, part.part.fileName, part.part.cid) + elif part.type == "attachment": mail_message.add_attachment(part.part.content, part.part.fileName) + + # Done here: + return mail_message + + +# --------------------------------------------------------------------------------------------------------------------- + + @mail_send_bp.route("", methods = ["POST"]) @set_api_version(api_version = "1.0.0") @read_input(sanitize_headers = False, sanitize_data = False) @@ -133,9 +181,9 @@ def init(blueprint_setup_state): project = constants.PROJECT_NAME, log_type = constants.MODULE_NAME, operation = "mailSendApi", - log_input = True, + log_input = 1, log_output = True, - sensitive_keys = ["sessionToken", "X-Session-Token"] + sensitive_keys = ["sessionToken", "X-Session-Token", "tokenKey", "tokenId"] ) @log_chain_to_mongo(attr_name = "logs_mongo") @should_not_be_under_maintenance(attr_name = "is_under_maintenance") @@ -161,7 +209,10 @@ async def send_one_mail( """ # Start by assuming failure: - success = False + client_controller = None + client_connector = None + mail_message = None + send_result = MailSendOneResult() # ┏┓ ┓ ┏┓┓ ┓ # ┣┫┓┏╋┣┓ ┃ ┣┓┏┓┏┃┏ @@ -181,18 +232,15 @@ async def send_one_mail( # Get the token based on the key: auth_token = await current_app.mail_controller.get_token_from_key( - mongo_conn = current_app.data_mongo, + mongo_data_conn = current_app.data_mongo, token_key = inbound_data.tokenKey ) - - print("INBOUND DATA:", json.to_string(inbound_data.model_dump(), default=str)) - print("INBOUND FILES:", json.to_string(inbound_files, default=str)) - print("AUTH TOKEN:", json.to_string(auth_token, default=str)) + user_info = CoreUserInfoModel(**kwargs["session_info"]) # We check if the token that was used to fetch the mail is owned by this user: if not await token_check.is_authorized( - mongo_conn = current_app.data_mongo, - user_info = CoreUserInfoModel(**kwargs["session_info"]), + mongo_data_conn = current_app.data_mongo, + user_info = user_info, token_ids = [auth_token.authTokenId] ): return ResponseModel( status_code = StatusCodes.FAILED, @@ -204,7 +252,34 @@ async def send_one_mail( # ┗┓┏┓┏┓┏┫ ┃┃┃┏┓┓┃ # ┗┛┗ ┛┗┗┻ ┛ ┗┗┻┗┗ - pass + # Figure out the client connector: + match auth_token.client: + case "gmail": + client_controller = current_app.gmail_controller + client_connector = current_app.gmail_client + mail_message = await create_gmail_mail_message( + from_email = auth_token.clientUserId["email"], + inbound_data = inbound_data + ) + case _: + client_controller = None + client_connector = None + mail_message = None + send_result.message = f"Invalid/unimplemented client '{auth_token.client}'" + + # If the controller and connector were matched: + if client_controller is not None and client_connector is not None: + send_result = await client_controller.send_mail( + sql_conn = current_app.sql_writer, + mongo_data_conn = current_app.data_mongo, + mail_client = client_connector, + mail_message = mail_message, + auth_token = auth_token, + client_thread_id = inbound_data.clientThreadId, + user_info = user_info, + llm = current_app.llm, + session_token = inbound_headers["X-Session-Token"] + ) # ┳┓ # ┣┫┏┓┏┏┓┏┓┏┓┏┏┓ @@ -213,8 +288,9 @@ async def send_one_mail( # Done here: return ResponseModel( - status_code = StatusCodes.OK if success else StatusCodes.FAILED, - http_code = HttpCodes.SUCCESS if success else HttpCodes.INTERNAL_SERVER_ERROR + status_code = StatusCodes.OK if send_result.success else StatusCodes.FAILED, + http_code = HttpCodes.SUCCESS if send_result.success else HttpCodes.INTERNAL_SERVER_ERROR, + message = send_result.message ) diff --git a/api/main.py b/api/main.py index 317cbf9..da3766a 100644 --- a/api/main.py +++ b/api/main.py @@ -440,6 +440,7 @@ async def app_startup(**kwargs): alert_url = current_app.script_data["alerts"]["url"], debug = enable_debugging ) + current_app.printer("Auth-Token (C) ready.") # Messages / SMS Controllers: current_app.sms_controller = AllSMSController( @@ -460,6 +461,7 @@ async def app_startup(**kwargs): alert_url = current_app.script_data["alerts"]["url"], debug = enable_debugging ) + current_app.printer("Message/SMS (C) ready.") # Messages / Mail Controllers: current_app.mail_controller = AllMailController( @@ -474,6 +476,7 @@ async def app_startup(**kwargs): alert_url = current_app.script_data["alerts"]["url"], debug = enable_debugging ) + current_app.printer("Message/Mail (C) ready.") # Messages / Chat Controllers: current_app.chat_controller = AllChatController( @@ -488,6 +491,7 @@ async def app_startup(**kwargs): alert_url = current_app.script_data["alerts"]["url"], debug = enable_debugging ) + current_app.printer("Message/Chat (C) ready.") # Finstitutions / Trading Controllers: current_app.trading_controller = AllTradingController( @@ -514,6 +518,7 @@ async def app_startup(**kwargs): alert_url = current_app.script_data["alerts"]["url"], debug = enable_debugging ) + current_app.printer("Finstitutions/Trading (C) ready.") # Finstitutions / Payments Controllers: current_app.payments_controller = AllPaymentsController( @@ -528,6 +533,7 @@ async def app_startup(**kwargs): alert_url = current_app.script_data["alerts"]["url"], debug = enable_debugging ) + current_app.printer("Finstitutions/Payments (C) ready.") # ┏┓ ┓ ┏┓┓• # ┃ ┏┓┏┓┏┓┏┓┏╋┏┓┏┓┏ ┏┓┏┓┏┫ ┃ ┃┓┏┓┏┓╋┏ diff --git a/controllers/core/ai/llm.py b/controllers/core/ai/llm.py index 3b6baf7..be31dac 100644 --- a/controllers/core/ai/llm.py +++ b/controllers/core/ai/llm.py @@ -162,7 +162,7 @@ class CoreLLMController(BaseModel): collection = self.AI_USAGE_COLLECTION, document = mongo_document ) - if inserted_id: llm_response.invocationId = str(inserted_id) + if inserted_id: llm_response.invocationId = inserted_id # Done here: return llm_response diff --git a/controllers_v2/message/mail/all_mail.py b/controllers_v2/message/mail/all_mail.py index 463e185..349eb76 100644 --- a/controllers_v2/message/mail/all_mail.py +++ b/controllers_v2/message/mail/all_mail.py @@ -59,6 +59,7 @@ from models.message.mail.send import MailSendOneResult # Mail Client(s): from utils_v2.goog.controllers.gmail.gmail_client import AsyncGmailClient +from utils_v2.goog.controllers.gmail.gmail_message import GmailMessage # To work with datatypes: from typing import List, Any @@ -303,7 +304,20 @@ class AllMailController(MailController): # ┛ ┗┗┻┗┗ ┗┛┗ ┛┗┗┻┗┛┗┗┫ # ┛ - pass + async def send_mail( + self, + sql_conn: AsyncMySQL, + mongo_data_conn: AsyncMongo, + mail_client: AsyncGmailClient, + mail_message: GmailMessage, + auth_token: CoreAuthTokenModel, + client_thread_id: str | None, + user_info: CoreUserInfoModel | None, + llm: CoreLLMController = None, + session_token: str = None + ) -> MailSendOneResult: + + raise NotImplementedError # ***************************************************************************************************************** diff --git a/controllers_v2/message/mail/base.py b/controllers_v2/message/mail/base.py index 2c483c9..2c9722f 100644 --- a/controllers_v2/message/mail/base.py +++ b/controllers_v2/message/mail/base.py @@ -59,6 +59,7 @@ from models.message.mail.send import MailSendOneResult # Mail Client(s): from utils_v2.goog.controllers.gmail.gmail_client import AsyncGmailClient +from utils_v2.goog.controllers.gmail.gmail_message import GmailMessage # To work with datatypes: from typing import List, Any @@ -151,7 +152,7 @@ class MailController(CoreMessageController, ABC): role = "system", content = ( "You're an expert mail summary assistant. That summarizes sent mails in 150 chars or less. " - "The objective of your user is to be able to recollect what a mail they sent was about from a brief " + "The objective of your user is to be able to recollect what the mail they sent was about from a brief " "summary. Reply in a simple string, no formatting is allowed except emojis. Good luck :)" ) ) @@ -523,7 +524,21 @@ class MailController(CoreMessageController, ABC): # ┛ ┗┗┻┗┗ ┗┛┗ ┛┗┗┻┗┛┗┗┫ # ┛ - pass + @abstractmethod + async def send_mail( + self, + sql_conn: AsyncMySQL, + mongo_data_conn: AsyncMongo, + mail_client: AsyncGmailClient, + mail_message: GmailMessage, + auth_token: CoreAuthTokenModel, + client_thread_id: str | None, + user_info: CoreUserInfoModel | None, + llm: CoreLLMController = None, + session_token: str = None + ) -> MailSendOneResult: + + pass # ┳┳┓ •┓ ┳┳ ┓ • # ┃┃┃┏┓┓┃ ┃┃┏┓┏┫┏┓╋┓┏┓┏┓ diff --git a/controllers_v2/message/mail/gmail.py b/controllers_v2/message/mail/gmail.py index fc72764..d13e19a 100644 --- a/controllers_v2/message/mail/gmail.py +++ b/controllers_v2/message/mail/gmail.py @@ -36,6 +36,8 @@ sys.path.append(".") sys.path.append("..") # My async utils: +from utils_v2.string import json +from utils_v2.mail import mail_parser from utils_v2.date_time import date_time from utils_v2.database.async_mysql_v2 import AsyncMySQL from utils_v2.database.async_mongo_v2 import AsyncMongo @@ -58,6 +60,7 @@ from models.message.mail.send import MailSendOneResult # Mail Client(s): from utils_v2.goog.controllers.gmail.gmail_client import AsyncGmailClient, SCOPES_GMAIL_MAIL_MANAGEMENT +from utils_v2.goog.controllers.gmail.gmail_message import GmailMessage from utils_v2.goog.models.auth_tokens import GoogleAuthTokens # To work with datatypes: @@ -719,7 +722,120 @@ class GmailController(MailController): # ┛ ┗┗┻┗┗ ┗┛┗ ┛┗┗┻┗┛┗┗┫ # ┛ - pass + async def send_mail( + self, + sql_conn: AsyncMySQL, + mongo_data_conn: AsyncMongo, + mail_client: AsyncGmailClient, + mail_message: GmailMessage, + auth_token: CoreAuthTokenModel, + client_thread_id: str | None, + user_info: CoreUserInfoModel | None, + llm: CoreLLMController = None, + session_token: str = None + ) -> MailSendOneResult: + + # Start by assuming failure: + send_result = MailSendOneResult() + now = date_time.get_current_utc_date_time(as_string = False) + + # Refresh the access token(s) if needed: + auth_token = await self.refresh_authorization( + sql_conn = sql_conn, + mongo_data_conn = mongo_data_conn, + mail_client = mail_client, + http_client = mail_client.http_client, + auth_token = auth_token, + force_refresh = False, + session_token = session_token + ) + + # If the user info was not given, take it from the token model: + if user_info is None: user_info = auth_token.user + + # Extract the client's tokens from the full token model, + # and check if they are valid (not expired): + google_tokens = GoogleAuthTokens(**auth_token.token) + if google_tokens.expired: + send_result.message = "Gmail token(s) have expired." + return send_result + + # Try sending the message: + client_response = await mail_client.send_message( + tokens = google_tokens, + message = mail_message + ) + + # If the attempt failed: + if not client_response.success: + send_result.message = f"Gmail: {client_response.message}" + return send_result + + # Parse the mail to save it to the database: + mail_json = mail_parser.parse(mail_message.get_raw_message(as_base64 = False)) + mail_json["labels"] = client_response.data.get("labelIds", []) + mail_json["messageId"] = client_response.data["id"] + mail_json["threadId"] = client_response.data["threadId"] + mail_json["historyId"] = client_response.data.get("historyId") + mail_json["snippet"] = client_response.data.get("snippet", mail_message.subject) + mail_json["sizeEstimate"] = client_response.data.get("sizeEstimate") + + # HANDLE ATTACHMENTS HERE: + mail_json["payload"] = self.drop_attachments(mail_json["payload"]) + + # Now we structure the message into the model: + all_recipients = [] + for field in ["to", "cc", "bcc"]: all_recipients += [item["email"] for item in mail_json[field]] + mail_message = CoreMessageModel( + ts = mail_json["ts"] or now, + syncTs = now, + tokenId = auth_token.authTokenId, + serviceType = auth_token.serviceType, + client = auth_token.client, + clientMessageId = client_response.data["id"], + clientThreadId = client_response.data["threadId"], + isSent = True, + isBroadcast = False, + sentSuccessfully = True, + sender = [mail_json["from"][0]["name"]], + recipient = all_recipients, + chat = None, + message = mail_json, + snippet = mail_json["subject"], + aiSnippet = None, + tags = ["Email", "Gmail", "Sent"] + ) + + # Invoke the LLM: + try: + ai_snippet = await self.summarize_mail_with_ai( + mongo_data_conn = mongo_data_conn, + user_info = user_info, + llm = llm, + message = mail_message, + prompt_template = self.SENT_MAIL_SUMMARIZATION_PROMPT_TEMPLATE + ) + mail_message.aiSnippet = ai_snippet.summary + except Exception as exception: + self._printer(exception) + + # Save the message to the database: + success = await mongo_data_conn.replace_one( + collection = self.MESSAGES_COLLECTION, + filter = { + "tokenId": ObjectId(auth_token.authTokenId), + "serviceType": auth_token.serviceType, + "client": auth_token.client, + "clientMessageId": mail_message.clientMessageId + }, + replacement = mail_message.model_dump(), + upsert = True + ) + + # Done here: + send_result.success = True + send_result.message = f"Mail sent successfully." + return send_result # ┳┳┓ •┓ ┳┳ ┓ • # ┃┃┃┏┓┓┃ ┃┃┏┓┏┫┏┓╋┓┏┓┏┓ diff --git a/models/api/message/mail/send.py b/models/api/message/mail/send.py index f1e60d6..35ff663 100644 --- a/models/api/message/mail/send.py +++ b/models/api/message/mail/send.py @@ -21,8 +21,7 @@ N/A """ - - +import io # ***************************************************************************************************************** # ***** **** # *** IMPORT *** @@ -36,8 +35,8 @@ sys.path.append(".") sys.path.append("..") # For making data behaviour_models: -from pydantic import BaseModel, Field, field_validator, PastDatetime, EmailStr -from typing import Optional, Literal, List +from pydantic import BaseModel, Field, field_validator, PastDatetime, EmailStr, model_validator +from typing import Union, Literal, List # My utils: from utils_v2.string import json @@ -47,6 +46,9 @@ from utils_v2.date_time import date_time # To work with date and time: import datetime +# To work with Base64 data: +import base64 + # To work with MongoDB: from bson.objectid import ObjectId @@ -103,15 +105,10 @@ class MailSendRequestHeaders(BaseModel): # --------------------------------------------------------------------------------------------------------------------- -class MailSendInlineFiles(BaseModel): +class MailSendPlainText(BaseModel): - key: str = Field( - description = "the key in the form data under which the file has been sent", - frozen = True - ) - - cid: str = Field( - description = "the content id to assign to the file", + content: str = Field( + description = "The string to add to the mail as plain text.", frozen = True ) @@ -127,46 +124,189 @@ class MailSendInlineFiles(BaseModel): # --------------------------------------------------------------------------------------------------------------------- -class MailSendRequestData(BaseModel): +class MailSendHTMLText(BaseModel): - tokenKey: ObjectId = Field( - description = "the account identifier (Mongo ObjectId)", + content: str = Field( + description = "The HTML string to add to the mail.", frozen = True ) - html: str = Field( - description = "a valid html string that will become the mail's body", + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + + +# --------------------------------------------------------------------------------------------------------------------- + + +class MailSendAttachment(BaseModel): + + content: str | io.BytesIO = Field( + description = "The Base64 string to add to the mail as a file.", + frozen = True + ) + + fileName: str = Field( + description = "The name of the file that will be downloaded when the recipient tries to access the content.", + frozen = True + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + arbitrary_types_allowed = True + + # ┓┏ ┓• ┓ • + # ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓ + # ┗┛┗┻┗┗┗┻┗┻┗┗┗┛┛┗ + + @field_validator("content", mode = "before") + def parse_base64_file(cls, value): + if isinstance(value, str): + base64_parts = value.split(",", 1) + if len(base64_parts) == 1: header, base64_string = None, base64_parts[0] + else: header, base64_string = base64_parts[0], base64_parts[1] + value = io.BytesIO(base64.b64decode(base64_string)) + return value + + +# --------------------------------------------------------------------------------------------------------------------- + + +class MailSendInlineImage(BaseModel): + + content: str | io.BytesIO = Field( + description = "The image content to add to the mail as an inline image file.", + frozen = True + ) + + fileName: str = Field( + description = "The name of the file that will be downloaded when the recipient tries to access the content.", + frozen = True + ) + + cid: str | None = Field( + description = "A custom Content-Id to assign to the inline attachment.", + frozen = True, + default = None + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + arbitrary_types_allowed = True + + # ┓┏ ┓• ┓ • + # ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓ + # ┗┛┗┻┗┗┗┻┗┻┗┗┗┛┛┗ + + @field_validator("content", mode = "before") + def parse_base64_file(cls, value): + if isinstance(value, str): + base64_parts = value.split(",", 1) + if len(base64_parts) == 1: header, base64_string = None, base64_parts[0] + else: header, base64_string = base64_parts[0], base64_parts[1] + value = io.BytesIO(base64.b64decode(base64_string)) + return value + + +# --------------------------------------------------------------------------------------------------------------------- + + +class MailSendPart(BaseModel): + + type: Literal["plain", "html", "attachment", "inline"] = Field( + description = "The kind of part this is.", + frozen = True + ) + + part: dict | Union[ + MailSendPlainText, MailSendHTMLText, # ...... Textual content. + MailSendAttachment, MailSendInlineImage # ... Media content. + ] = Field( + description = "One of the structured types of data that can be put in the mail.", + frozen = True + ) + + # ┏┓ ┏• + # ┃ ┏┓┏┓╋┓┏┓ + # ┗┛┗┛┛┗┛┗┗┫ + # ┛ + + class Config: + extra = "forbid" + + # ┓┏ ┓• ┓ • + # ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓ + # ┗┛┗┻┗┗┗┻┗┻┗┗┗┛┛┗ + + @model_validator(mode = "before") + def ensure_harmony(cls, values): + kind_map = { + "plain": MailSendPlainText, + "html": MailSendHTMLText, + "attachment": MailSendAttachment, + "inline": MailSendInlineImage, + } + part_dict = values["part"] if isinstance(values["part"], dict) else values["part"].model_dump() + values["part"] = kind_map[values["type"]](**part_dict) + return values + + +# --------------------------------------------------------------------------------------------------------------------- + + +class MailSendRequestData(BaseModel): + + tokenKey: ObjectId = Field( + description = "The identifier (Mongo ObjectId) of the account from which the mail has to be sent.", frozen = True ) to: List[EmailStr] = Field( - description = "the recipient of your mail", - # default = None, - # validate_default = True, + description = "The list of e-mail addresses to send the mail to.", frozen = True ) cc: List[EmailStr] = Field( - description = "the list of ids to add as cc", + description = "The list of e-mail addresses to add as CC.", default = None, validate_default = True, frozen = True ) bcc: List[EmailStr] = Field( - description = "the list of ids to add as bcc", + description = "The list of e-mail addresses to add as BCC.", default = None, validate_default = True, frozen = True ) - inlineFiles: List[MailSendInlineFiles] = Field( - description = ( - "when sending files, this will be a list of keys whose " - "associated files will be treated as inline files" - ), - default = None, - validate_default = True, + subject: str = Field( + description = "The subject of the mail.", + frozen = True + ) + + clientThreadId: str | int | None = Field( + description = "The id of the mail-chain if you would like to reply in one.", + frozen = True, + default = None + ) + + body: List[MailSendPart] = Field( + description = "The actual payload to send as the mail.", frozen = True ) @@ -189,32 +329,20 @@ class MailSendRequestData(BaseModel): except: pass return value - @field_validator("inlineFiles", mode = "before") - def parse_json(cls, value): - - # If we get a null value or an empty string: - if value is None: return [] - if isinstance(value, str): - if not value.strip(): return [] - - # If we get a properly populated string: - try: value = json.from_string(value) - except: pass - - # Done here: - return value - @field_validator("to", "cc", "bcc", mode = "before") def parse_recipients(cls, value): - # If we get a null value or an empty string: - if value is None: return [] - if isinstance(value, str): - if not value.strip(): return [] + # Ensure that we are working with some kind of list: + if value is None: value = [] + if isinstance(value, str): value = [value] - # If we get a properly populated string: - try: value = json.from_string(value) - except: value = [value.strip()] + # # Ensure that all values of the list look like valid mails: + # for index, email_id in enumerate(value): + # if not regex.match( + # text = email_id, + # pattern = regex.REGEX_START + regex.REGEX_EMAIL_ID + regex.REGEX_END, + # case_sensitive = False, + # ): raise ValueError(f"'{email_id}' does not seem to be a valid e-mail id.") # Done here: return value diff --git a/models/core/ai/llm.py b/models/core/ai/llm.py index 247263f..b66b446 100644 --- a/models/core/ai/llm.py +++ b/models/core/ai/llm.py @@ -44,6 +44,9 @@ from utils_v2.string import json from utils_v2.string import regex from utils_v2.date_time import date_time +# To work with MongoDB: +from bson.objectid import ObjectId + # To work with date and time: import datetime @@ -133,8 +136,8 @@ class LLMInput(BaseModel): # Verify that there is AT MOST ONE 'system' message, # and verify that the 'system' message is the first message: - if system_message_count > 1: raise ValueError(f"there can be at most 1 'system' message, found {system_message_count}") - if system_message_index > 0: raise ValueError(f"'system' message must always be at index 0, found it at index {system_message_index}") + if system_message_count > 1: raise ValueError(f"There can be at most 1 'system' message: found {system_message_count}") + if system_message_index > 0: raise ValueError(f"The 'system' message must always be at index 0; found it at index {system_message_index}") # Done here: return value @@ -207,7 +210,7 @@ class LLMOutput(BaseModel): frozen = True ) - invocationId: Any | None = Field( + invocationId: ObjectId | str | None = Field( description = "the id of the document that notes this invocation; useful for reconciliation", frozen = False, default = None @@ -220,6 +223,17 @@ class LLMOutput(BaseModel): class Config: extra = "forbid" + arbitrary_types_allowed = True + + # ┓┏ ┓• ┓ • + # ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓ + # ┗┛┗┻┗┗┗┻┗┻┗┗┗┛┛┗ + + @field_validator("invocationId", mode = "before") + def parse_oid(cls, value): + try: value = ObjectId(value) + except: pass + return value # ┏┓ • # ┃┃┏┓┏┓┏┓┏┓┏┓╋┓┏┓┏