(20241212) Reorganizing code to perform core actions in one place.

This commit is contained in:
2024-12-12 18:34:51 +05:30
parent 70c817b3b8
commit fb95e0b38c
50 changed files with 1259 additions and 3166 deletions
View File
View File
+560
View File
@@ -0,0 +1,560 @@
"""
AUTHOR:
Khushal P Soonderji
DATE:
Thursday, 12th Dec., 2024
OBJECTIVE:
To handle all auth-tokens from one place.
REFERENCES:
N/A
DOWNLOADS:
N/A
"""
# *****************************************************************************************************************
# ***** ****
# *** IMPORT ***
# ***** ****
# *****************************************************************************************************************
# To make sibling directories accessible for imports:
import sys
sys.path.append(".")
sys.path.append("..")
# For Quart:
from quart import current_app
# My async utils:
from utils_v2.string import json
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, AsyncMongoStorage
# Base model:
from controllers.base import BaseModel
# Data models:
from models.core.user import CoreUserInfoModel
from models.core.auth_token import CoreAuthTokenModel
from models.core.message import CoreMessageModel
from models.api.mail.sync import MailSyncOneResult, MailSyncManyResults
# Mail Clients:
from utils_v2.goog.gmail.gmail_client import AsyncGMailClient
from utils_v2.goog.models.data.auth_tokens import GoogleAuthTokens
# To work with MongoDB:
from bson import ObjectId
from pymongo import InsertOne, UpdateOne, ReplaceOne
# To work with LLMs:
from controllers.core.ai.llm import LLMController
from models.core.ai.llm import LLMInput, LLMOutput
# To work with datatypes:
from typing import Literal, List, Dict, Any
# To parse the HTML content in the mail:
from bs4 import BeautifulSoup
# To work with date and time:
import datetime
# For asynchronous activities:
import asyncio
# *****************************************************************************************************************
# ***** ****
# *** MACROS / ONE-TIME INIT ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** VARIABLES ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** FUNCTIONS ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** CLASSES ***
# ***** ****
# *****************************************************************************************************************
class MailController:
# ┏┓┓ ┓┏
# ┃ ┃┏┓┏┏ ┃┃┏┓┏┓┏
# ┗┛┗┗┻┛┛ ┗┛┗┻┛ ┛
# For AI Magic through LLMs:
PROMPT_TEMPLATE = [
{
"role": "system",
"content": (
"You're a mail summary expert that summarizes mails in 150 chars or less. "
"If available, show login info like username and OTPs in your summary."
"If no login info is provided, please don't worry; just summarize what you see."
)
}
]
# ┓┏ ┓
# ┣┫┏┓┃┏┓┏┓┏┓┏
# ┛┗┗ ┗┣┛┗ ┛ ┛
# ┛
def extract_plaintext_parts(
self,
payload: dict
) -> List[str]:
# Start with just a holder:
text_parts = []
# If a direct text/plain part occurs,
# we just add it to the list:
if (
payload["contentMainType"] == "text" and
payload["contentSubType"] == "plain"
):
text_parts.append(payload["payload"])
# If a direct text/html part occurs,
# we just add it to the list:
if (
payload["contentMainType"] == "text" and
payload["contentSubType"] == "html"
):
html_parser = BeautifulSoup(payload["payload"], "html.parser")
text_parts.append(html_parser.get_text())
# If a multipart/alternative part occurs,
# we pick just the ready plaintext part:
if (
payload["contentMainType"] == "multipart" and
payload["contentSubType"] == "alternative"
):
for part in payload["payload"]:
if part["contentSubType"] == "plain":
text_parts.append(part["payload"])
# If a multipart/mixed or multipart/related part occurs,
# we use recursion to look for plaintext parts nested inside:
if (
payload["contentMainType"] == "multipart" and
(
payload["contentSubType"] == "mixed" or
payload["contentSubType"] == "related"
)
):
for part in payload["payload"]:
text_parts += self.extract_plaintext_parts(payload = part)
# Done here:
return text_parts
async def summarize_mail_with_ai(
self,
mongo_conn: AsyncMongo,
user_info: CoreUserInfoModel,
llm: LLMController,
message: CoreMessageModel
) -> LLMOutput:
# Extract the text from the message here:
text_parts = self.extract_plaintext_parts(payload = message.message["payload"])
text = "\n".join(text_parts)
# Invoke the LLM and return the response:
return await llm.invoke(
mongo_conn = mongo_conn,
user_info = user_info,
llm_input = LLMInput(
messages = self.PROMPT_TEMPLATE + [
{
"role": "human",
"content": f"Please summarize this mail: \"\"\"{text}\"\"\""
}
]
)
)
# ┏┓┏┓ ┓ ┏┓ ┏┓
# ┃┃┣┫┓┏╋┣┓┏┛ ┃┫
# ┗┛┛┗┗┻┗┛┗┗━•┗┛
@staticmethod
async def get_token_id(
db_conn: AsyncMySQL,
mongo_conn: AsyncMongo,
auth_token: CoreAuthTokenModel,
session_token: str = None
) -> ObjectId:
# Simply call the core model:
return await current_app.core_auth_token_controller.get_token_id(
db_conn = db_conn,
mongo_conn = mongo_conn,
auth_token = auth_token,
token_notes = {
"email": None
},
session_token = session_token,
)
@staticmethod
async def set_token(
db_conn: AsyncMySQL,
mongo_conn: AsyncMongo,
token_id: ObjectId | str,
auth_token: CoreAuthTokenModel,
session_token: str = None
) -> bool:
# Simply call the core model:
return await current_app.core_auth_token_controller.set_token(
db_conn = db_conn,
mongo_conn = mongo_conn,
token_id = token_id,
auth_token = auth_token,
token_notes = {
"email": auth_token.token["email"],
"displayName": auth_token.token.get("displayName"),
"displayPictureUrl": auth_token.token.get("displayPictureUrl"),
},
session_token = session_token,
)
@staticmethod
async def get_token(
mongo_conn: AsyncMongo,
token_id: ObjectId | str = None,
**kwargs
) -> CoreAuthTokenModel | None:
# Simply call the core model:
return await current_app.core_auth_token_controller.get_token(
mongo_conn = mongo_conn,
token_id = token_id,
kwargs = kwargs
)
# ┏┓ ┳┳┓
# ┗┓┓┏┏┓┏ ┃┃┃┏┓┏┏┏┓┏┓┏┓┏
# ┗┛┗┫┛┗┗ ┛ ┗┗ ┛┛┗┻┗┫┗ ┛
# ┛ ┛
# In this section, we pull mails from the third-party clients (like GMail), and store them on our server. This makes
# those mails available on the platform.
async def __sync_one_gmail(
self,
mongo_conn: AsyncMongo,
user_info: CoreUserInfoModel,
token_id: ObjectId,
auth_token: CoreAuthTokenModel,
mail_client: AsyncGMailClient,
google_tokens: GoogleAuthTokens,
message_id: str,
llm: LLMController = None,
force_sync: bool = False
) -> MailSyncOneResult:
# Start by assuming failure:
sync_result = MailSyncOneResult()
# If we've not been forced to re-sync the mail message,
# we first check if the mail already exists in our database:
if not force_sync:
mail_record = await current_app.core_message_controller.get_previews(
mongo_conn = mongo_conn,
token_ids = [ObjectId(token_id)],
limit = 1,
skip = 0,
additional_filter = {
"tokenId": ObjectId(token_id),
"serviceType": auth_token.serviceType,
"client": auth_token.client,
"clientMessageId": message_id
}
)
if mail_record:
sync_result.success = True
sync_result.message = f"gmail message '{message_id}' already sync'd on '{mail_record['readTs']} (UTC)'"
return sync_result
# Now that we know that we have to fetch the mail from GMail:
client_response = await mail_client.get_message(
tokens = google_tokens,
message_id = message_id,
return_raw = False
)
# If we didn't get the mail from GMail;
if not client_response.success:
sync_result.message = f"gmail (messageId: '{message_id}'): {client_response.message}"
return sync_result
# HANDLE ATTACHMENTS HERE:
pass
# Now we structure the message into the model:
mail_message = CoreMessageModel(
ts = client_response.data["ts"],
syncTs = date_time.get_current_utc_date_time(as_string = False),
tokenId = token_id,
serviceType = auth_token.serviceType,
client = auth_token.client,
clientMessageId = message_id,
clientThreadId = client_response.data["threadId"],
payload = client_response.data
)
# Give a quick indicator of whether this mail is an inbox mail or sent mail:
all_recipients = []
for field in ["to", "cc", "bcc"]: all_recipients += [item["email"] for item in client_response.data[field]]
if google_tokens.email in all_recipients: mail_message.isSent = False
else: mail_message.isSent = True
# Invoke the LLM:
mail_message.aiSnippet = await self.summarize_mail_with_ai(
mongo_conn = mongo_conn,
user_info = user_info,
llm = llm,
message = mail_message
)
# Done here:
print("ONE MAIL:", json.to_string(mail_message.model_dump(), default = str))
sync_result.success = True
return sync_result
async def __sync_many_gmail(
self,
db_conn: AsyncMySQL,
mongo_conn: AsyncMongo,
user_info: CoreUserInfoModel,
token_id: ObjectId,
auth_token: CoreAuthTokenModel,
mail_client: AsyncGMailClient,
llm: LLMController = None,
force_sync: bool = False,
start_date: datetime.datetime = None,
end_date: datetime.datetime = None,
max_count: int = 100,
session_token: str = None
) -> MailSyncManyResults:
# Start by assuming failure:
sync_results = MailSyncManyResults()
# Extract the client's tokens from the full token payload given by the database:
google_tokens = GoogleAuthTokens(**auth_token.token)
# Refresh the tokens (if needed):
tokens_refreshed = await google_tokens.arefresh(
http_client = current_app.http_client,
client_id = mail_client.client_id,
client_secret = mail_client.client_secret
)
if tokens_refreshed:
auth_token.token = google_tokens.model_dump()
auth_token.lastRefreshTs = date_time.get_current_utc_date_time(as_string = True)
await self.set_token(
db_conn = db_conn,
mongo_conn = mongo_conn,
token_id = token_id,
auth_token = auth_token,
session_token = session_token
)
# Let's build the query to send to Google:
sub_queries = []
if start_date: sub_queries.append(start_date.strftime("after:%Y/%m/%d"))
if end_date: sub_queries.append((end_date + datetime.timedelta(days = 1)).strftime("before:%Y/%m/%d"))
query_string = " ".join(sub_queries)
# Let's enlist all the mails that fall in the date range:
client_response = await mail_client.list_messages(
tokens = google_tokens,
max_count = max_count,
query = query_string
)
if not client_response.success:
sync_results["message"] = f"gmail: {client_response.message}"
return sync_results
messages_list = client_response.data["messages"]
# Now, for every mail in the list, we fetch the mail and note the results:
tasks = [
self.__sync_one_gmail(
mongo_conn = mongo_conn,
user_info = user_info,
token_id = token_id,
auth_token = auth_token,
mail_client = mail_client,
google_tokens = google_tokens,
message_id = v["id"],
llm = llm,
force_sync = force_sync
) for v in messages_list.values()
]
individual_sync_results = await asyncio.gather(*tasks)
# Now we create operations for each mail,
# and maintain success/failure counters:
sync_results.totalCount = len(individual_sync_results)
mongo_operations = []
for result in individual_sync_results:
if result.success: sync_results.successCount += 1
else: sync_results.failureCount += 1
if result.mailMessage: mongo_operations.append(ReplaceOne(
filter = {
"tokenId": token_id,
"serviceType": auth_token.serviceType,
"client": auth_token.client,
"clientMessageId": result.mailMessage.clientMessageId
},
replacement = result.mailMessage.model_dump(),
upsert = True
))
# Make the bulk insert operation:
if mongo_operations:
sync_count = await current_app.core_message_controller.bulk_write(
mongo_conn = mongo_conn,
requests = mongo_operations
)
# Apply the labels to the read messages:
try:
client_response = await mail_client.modify_messages(
tokens = google_tokens,
message_ids = [v["id"] for v in messages_list.values()],
add_label_ids = [google_tokens.labels.get("TCAOFF", {}).get("id")]
)
except Exception as exception:
pass
# Done here:
sync_results.message = f"{sync_results.successCount}/{sync_results.totalCount} mail(s) sync'd from gmail"
return sync_results
async def sync(
self,
db_conn: AsyncMySQL,
mongo_conn: AsyncMongo,
user_info: CoreUserInfoModel,
token_id: ObjectId | str,
llm: LLMController = None,
force_sync: bool = False,
start_date: datetime.datetime = None,
end_date: datetime.datetime = None,
max_count: int = 100,
session_token: str = None
) -> MailSyncManyResults:
# Start by assuming failure:
sync_results = MailSyncManyResults()
# ┏┓ ┓ ┏┳┓ ┓
# ┣ ┏┓╋┏┣┓ ┃ ┏┓┃┏┏┓┏┓┏
# ┻ ┗ ┗┗┛┗ ┻ ┗┛┛┗┗ ┛┗┛
# We first load the authorization tokens:
auth_token = await current_app.mail_oauth_model.get_token(
mongo_conn = mongo_conn,
token_id = token_id,
)
# If we failed to load the authorization tokens:
if not auth_token:
sync_results.message = f"no such token id '{token_id}'"
return sync_results
# ┏┓ ┏┓┳┳┓ •┓
# ┣ ┏┓┏┓ ┃┓┃┃┃┏┓┓┃
# ┻ ┗┛┛ ┗┛┛ ┗┗┻┗┗
if auth_token.client == "gmail":
return await self.__sync_many_gmail(
db_conn = db_conn,
mongo_conn = mongo_conn,
user_info = user_info,
token_id = token_id,
auth_token = auth_token,
mail_client = current_app.gmail_client,
llm = llm,
force_sync = force_sync,
start_date = start_date,
end_date = end_date,
max_count = max_count,
session_token = session_token,
)
# ┳ ┓• ┓ ┏┓┓•
# ┃┏┓┓┏┏┓┃┓┏┫ ┃ ┃┓┏┓┏┓╋
# ┻┛┗┗┛┗┻┗┗┗┻ ┗┛┗┗┗ ┛┗┗
# If we haven't been able to sync mail due to not entering any 'if' condition:
sync_results.message = f"no such mail client '{auth_token.client}'"
return sync_results
# *****************************************************************************************************************
# ***** ****
# *** MAIN PROGRAM ***
# ***** ****
# *****************************************************************************************************************
if __name__ == "__main__":
pass
# from utils_v2.string import json
#
# file_options = [
# r"/home/developer/Downloads/recursive parts parse - 20241210.json",
# r"/home/developer/Downloads/recursive parts parse (no attachment) - 20241210.json",
# ]
#
# raw_mail_json = json.from_file(file_options[1])
# print("FROM FILE:", json.to_string(raw_mail_json["payload"]))
# print("\n\n---------\n\n")
# mail_model = MailAPIModel()
# print(mail_model.extract_plaintext_parts(raw_mail_json["payload"]))
+388
View File
@@ -0,0 +1,388 @@
"""
AUTHOR:
Khushal P Soonderji
DATE:
Tuesday, 22nd Oct., 2024
OBJECTIVE:
To provide an easy way to create models to handle documents for Bicree.
This is the base model for this microservice. It will define the structure for all other models that will be
used in this particular microservice.
REFERENCES:
N/A
DOWNLOADS:
N/A
"""
# *****************************************************************************************************************
# ***** ****
# *** IMPORT ***
# ***** ****
# *****************************************************************************************************************
# To make sibling directories accessible for imports:
import sys
sys.path.append(".")
sys.path.append("..")
# My utils:
from utils_v2.database.async_mysql_v2 import AsyncMySQL
from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache
from utils_v2.api.codes import StatusCodes
# For asynchronous activities:
import asyncio
# For debugging:
from icecream import IceCreamDebugger
# To work with datatypes:
from typing import List
# *****************************************************************************************************************
# ***** ****
# *** MACROS / ONE-TIME INIT ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** VARIABLES ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** FUNCTIONS ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** CLASSES ***
# ***** ****
# *****************************************************************************************************************
class BaseModel:
PREVIEW_LENGTH = 250
def __init__(
self,
cache = None,
alert_url = None,
http_client = None,
debug = True,
debug_prefix = "Model | ",
debug_only_errors = True
):
"""
This is the base model.
:param cache: The object to use for caching results from database calls.
:param debug: Whether, or not, you would like to print debugging messages:
:param debug_prefix: The prefix to print with the debugging messages.
:param debug_only_errors: Whether you would like to print only error messages or all messages.
:return: None.
"""
# Prepare the caching utility:
self._cache = cache
# For sending alerts:
self._alert_url = alert_url
self._http_client = http_client
# Prepare the debugging utility:
self._debug_prefix = debug_prefix
self._printer = IceCreamDebugger(prefix = debug_prefix, includeContext = True)
if not debug: self._printer.disable()
self._debug_only_errors = debug_only_errors
# A semaphore for activities that must absolutely be done one at a time:
self.__exclusive_semaphore = asyncio.Semaphore(1)
# A simple debugging output:
self._printer("Model initialized.")
def enable_terminal_print(self):
self._printer.enable()
def disable_terminal_print(self):
self._printer.disable()
def debug_only_errors(self):
self._debug_only_errors = True
def debug_everything(self):
self._debug_only_errors = False
async def send_alert(
self,
message: str,
session_token = None,
alert_type = "error"
):
"""
Sends out an alert (ideally through the tech module). This is meant to be used when some exception occurs, and
you want to be informed before the client complains.
:param message: The message to send out to the admins.
:param session_token: The session token of the user (optional) so that the alert message can display the name of
the user who faced the trouble.
:param alert_type: The type of alert to throw ("error", "warning", or "info").
:return: None.
"""
if self._http_client is not None and self._alert_url is not None:
response = await self._http_client.post(
url = self._alert_url,
headers = {"X-Session-Token": session_token} if session_token else None,
json = {
"message": message,
"type": alert_type
}
)
async def call_cached_procedure(
self,
cache: AsyncRedisCache,
cache_key: str,
cache_expiry: int,
db_conn: AsyncMySQL,
proc_name: str,
proc_args: tuple,
retry_count: int = 1,
backoff_seconds: float = 0.5,
backoff_multiplier: float = 1.1,
session_token: str = None
):
"""
Calls a stored procedure and returns the response as a JSON-like object (dict or list).
:param cache: The caching object to use to set the session in cache memory.
:param cache_key: The string to use as the key when caching the response.
:param cache_expiry: The no. of seconds after which this information will be deleted from the cache.
:param db_conn: The connection instance to use to call the procedure.
:param proc_name: The name of the stored procedure that must be called.
:param proc_args: The args to be sent to the stored procedure.
:param retry_count: The max. number of times to try in case one or more attempts fail.
:param backoff_seconds: The time to wait before making the next attempt if the retry count is more than 1.
:param backoff_multiplier: The factor that dictates how much to modify the time delay by when waiting to retry.
:param session_token: A session token to share with the tech module when alerts need to be sent out for any
occurrence of exceptions. If this is passed, the tech module will be able to tell you which user faced the
issue.
:return: The response from the stored procedure.
"""
# check for the data in cache:
data = await cache.get(cache_key)
# If the data isn't in the cache, call the procedure:
if data is None:
# Make the database call:
data = await self.call_procedure(
db_conn = db_conn,
proc_name = proc_name,
proc_args = proc_args,
retry_count = retry_count,
backoff_seconds = backoff_seconds,
backoff_multiplier = backoff_multiplier,
session_token = session_token
)
# If the database call succeeded, cache the response:
if isinstance(data, dict) and data["status"] == 1:
await cache.set(key = cache_key, value = data, expiry = cache_expiry)
# Done here:
return data
async def call_procedure(
self,
db_conn: AsyncMySQL,
proc_name: str,
proc_args: tuple,
retry_count: int = 1,
backoff_seconds: float = 0.5,
backoff_multiplier: float = 1.1,
session_token: str = None
):
"""
Calls a stored procedure and returns the response as a JSON-like object (dict or list).
:param db_conn: The connection instance to use to call the procedure.
:param proc_name: The name of the stored procedure that must be called.
:param proc_args: The args to be sent to the stored procedure.
:param retry_count: The max. number of times to try in case one or more attempts fail.
:param backoff_seconds: The time to wait before making the next attempt if the retry count is more than 1.
:param backoff_multiplier: The factor that dictates how much to modify the time delay by when waiting to retry.
:param session_token: A session token to share with the tech module when alerts need to be sent out for any
occurrence of exceptions. If this is passed, the tech module will be able to tell you which user faced the
issue.
:return: The response from the stored procedure.
"""
# Call the stored procedure:
db_json, exception = await db_conn.call_procedure_and_get_json(
proc_name,
proc_args,
retry_count = retry_count,
backoff_seconds = backoff_seconds,
backoff_multiplier = backoff_multiplier,
return_exception = True
)
# Understand the response:
success = True if db_json["status"] == 1 else False
message = db_json.get("message")
# Debugging print:
if not success or not self._debug_only_errors:
self._printer(proc_name, proc_args, success, exception, message)
# Send an alert out on exceptions:
if exception is not None:
# Format the message in Markdown format:
exception_string = str(exception).replace("`", "'")
formatted_message = f"*Module:*\n`{self._debug_prefix}`\n\n"
formatted_message += f"*Proc:*\n`{proc_name}`\n\n"
formatted_message += f"*Args:*\n`({', '.join([str(_) for _ in proc_args])})`\n\n"
formatted_message += f"*Arg-Types:*\n`({', '.join([type(_).__name__ for _ in proc_args])})`\n\n"
formatted_message += f"*Message:*\n`{message}`\n\n"
formatted_message += f"*Success:*\n`{success}`\n\n"
formatted_message += f"*Exception:*\n`{exception_string}`\n\n"
# Send the alert:
await self.send_alert(formatted_message, session_token = session_token)
# Return the response:
db_json["status_code"] = StatusCodes.OK if success else StatusCodes.FAILED
return db_json
async def execute_one(
self,
db_conn: AsyncMySQL,
query: str,
session_token: str = None
):
"""
Runs one query and sends an alert if that fails.
:param db_conn: The connection to use to run the query.
:param query: The query to run.
:param session_token: A session token to share with the tech module when alerts need to be sent out for any
occurrence of exceptions. If this is passed, the tech module will be able to tell you which user faced the
issue.
:return: The response from the database.
"""
# Run the query:
rows_affected, db_response, exception = await db_conn.execute_one(query = query, return_exception = True)
# Send an alert out on exceptions:
if exception is not None:
# Created needed previews:
query_preview = query if len(query) <= self.PREVIEW_LENGTH else query[:self.PREVIEW_LENGTH] + "..."
# Format the message in Markdown format:
formatted_message = f"*Module:*\n`{self._debug_prefix}`\n\n"
formatted_message += f"*Query:*\n`{query_preview}`\n\n"
formatted_message += f"*Rows Affected:*\n`{rows_affected}`\n\n"
formatted_message += f"*DB Response:*\n`{db_response}`\n\n"
formatted_message += f"*Exception:*\n`{exception}`\n\n"
# Send the alert:
await self.send_alert(formatted_message, session_token = session_token)
# Return the response:
return rows_affected, db_response
async def execute_many(
self,
db_conn: AsyncMySQL,
query: str,
data: List[tuple],
session_token: str = None
):
"""
Runs many queries and sends an alert if that fails.
:param db_conn: The connection to use to run the query.
:param query: The query to run.
:param data: The data to feed into the query.
:param session_token: A session token to share with the tech module when alerts need to be sent out for any
occurrence of exceptions. If this is passed, the tech module will be able to tell you which user faced the
issue.
:return: The response from the database.
"""
# Run the query:
rows_affected, db_response, exception = await db_conn.execute_many(
query = query,
data = data,
return_exception = True
)
# Send an alert out on exceptions:
if exception is not None:
# Created needed previews:
query_preview = query if len(query) <= self.PREVIEW_LENGTH else query[:self.PREVIEW_LENGTH] + "..."
data_preview = str(data)
if len(data_preview) > self.PREVIEW_LENGTH: data_preview = data_preview[:self.PREVIEW_LENGTH] + "..."
# Format the message in Markdown format:
formatted_message = f"*Module:*\n`{self._debug_prefix}`\n\n"
formatted_message += f"*Query:*\n`{query_preview}`\n\n"
formatted_message += f"*Data:*\n`{data_preview}`\n\n"
formatted_message += f"*Rows Affected:*\n`{rows_affected}`\n\n"
formatted_message += f"*DB Response:*\n`{db_response}`\n\n"
formatted_message += f"*Exception:*\n`{exception}`\n\n"
# Send the alert:
await self.send_alert(formatted_message, session_token = session_token)
# Return the response:
return rows_affected, db_response
# *****************************************************************************************************************
# ***** ****
# *** MAIN PROGRAM ***
# ***** ****
# *****************************************************************************************************************
if __name__ == "__main__":
pass
View File
View File
+182
View File
@@ -0,0 +1,182 @@
"""
AUTHOR:
Khushal P Soonderji
DATE:
Thursday, 5th Dec., 2024
OBJECTIVE:
To create an interface between OpenAI and our internal system to perform LLM-based activities.
REFERENCES:
N/A
DOWNLOADS:
N/A
"""
# *****************************************************************************************************************
# ***** ****
# *** IMPORT ***
# ***** ****
# *****************************************************************************************************************
# To make sibling directories accessible for imports:
import sys
sys.path.append(".")
sys.path.append("..")
# My async utils:
from utils_v2.database.async_mongo_v2 import AsyncMongo
# Base model:
from controllers.base import BaseModel
# Data Models:
from models.core.user import CoreUserInfoModel
from models.core.ai.llm import LLMInput, LLMOutput, LLMUsageTokens
# To work with LLMs:
from langchain_openai import ChatOpenAI
# *****************************************************************************************************************
# ***** ****
# *** MACROS / ONE-TIME INIT ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** VARIABLES ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** FUNCTIONS ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** CLASSES ***
# ***** ****
# *****************************************************************************************************************
class LLMController(BaseModel):
AI_USAGE_COLLECTION = "_aiUsage"
def __init__(
self,
llm_creds: dict,
cache = None,
alert_url = None,
http_client = None,
debug = True,
debug_prefix = "Model | ",
debug_only_errors = True
):
"""
This is the model that works with OpenAi's LLM to perform tasks like text completion.
:param llm_creds: The JSON that holds the credentials to access your OpenAI account. Should have the keys
'model', and 'openai_api_key'.
:param cache: The object to use for caching results from database calls.
:param alert_url: Which URL to call when something goes wrong.
:param http_client: The instance of an HTTP client to use when trying to send alerts and make other APIs.
:param debug: Whether, or not, you would like to print debugging messages:
:param debug_prefix: The prefix to print with the debugging messages.
:param debug_only_errors: Whether you would like to print only error messages or all messages.
:return: None.
"""
# Initialize the parent:
super().__init__(
cache = cache,
alert_url = alert_url,
http_client = http_client,
debug = debug,
debug_prefix = debug_prefix,
debug_only_errors = debug_only_errors
)
# Create the interface to the LLM:
self.__llm = ChatOpenAI(**llm_creds)
async def invoke(
self,
mongo_conn: AsyncMongo,
user_info: CoreUserInfoModel,
llm_input: LLMInput
) -> LLMOutput:
# Format the message as per the format of OpenAI:
prompt = [
{
"role": {"system": "system", "ai": "assistant", "human": "user"}[message.role],
"content": message.content
} for message in llm_input.messages
]
# Invoke the AI, and format the response:
llm_response = await self.__llm.ainvoke(prompt)
print("LLM RESPONSE:", llm_response)
print("INPUT MESSAGES:", llm_input.messages)
llm_response = LLMOutput(
messages = llm_input.messages,
output = llm_response.content,
client = "openai",
model = llm_response.response_metadata["model_name"],
tokens = LLMUsageTokens(
input = llm_response.usage_metadata["input_tokens"],
output = llm_response.usage_metadata["output_tokens"],
total = llm_response.usage_metadata["total_tokens"],
)
)
# Store this into MongoDB:
mongo_document = {"user": user_info.model_dump()}
for k, v in llm_response.model_dump().items(): mongo_document[k] = v
inserted_id = await mongo_conn.insert_one(
collection = self.AI_USAGE_COLLECTION,
document = mongo_document
)
if inserted_id: llm_response.invocationId = str(inserted_id)
# Done here:
return llm_response
# *****************************************************************************************************************
# ***** ****
# *** MAIN PROGRAM ***
# ***** ****
# *****************************************************************************************************************
if __name__ == "__main__":
pass
+314
View File
@@ -0,0 +1,314 @@
"""
AUTHOR:
Khushal P Soonderji
DATE:
Thursday, 12th Dec., 2024
OBJECTIVE:
To handle all auth-tokens from one place.
REFERENCES:
N/A
DOWNLOADS:
N/A
"""
# *****************************************************************************************************************
# ***** ****
# *** IMPORT ***
# ***** ****
# *****************************************************************************************************************
# To make sibling directories accessible for imports:
import sys
sys.path.append(".")
sys.path.append("..")
# For Quart:
from quart import current_app
# My async utils:
from utils_v2.string import json
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
# Base model:
from controllers.base import BaseModel
# Data models:
from models.core.auth_token import CoreAuthTokenModel
# To work with MongoDB:
from bson import ObjectId
# *****************************************************************************************************************
# ***** ****
# *** MACROS / ONE-TIME INIT ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** VARIABLES ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** FUNCTIONS ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** CLASSES ***
# ***** ****
# *****************************************************************************************************************
class AuthTokenController(BaseModel):
# ┏┓┓ ┓┏
# ┃ ┃┏┓┏┏ ┃┃┏┓┏┓┏
# ┗┛┗┗┻┛┛ ┗┛┗┻┛ ┛
# For MongoDB:
AUTH_COLLECTION = "_authTokens"
async def get_token_id(
self,
db_conn: AsyncMySQL,
mongo_conn: AsyncMongo,
auth_token: CoreAuthTokenModel,
token_notes: dict,
session_token: str = None,
) -> ObjectId:
"""
Stores params from the session info and gives an identifier to use in the authorization URL. Use this when the
user requests an authorization URL to link your service to another service (like GMail).
:param db_conn: The database connection (MariaDB) to use to perform the action.
:param mongo_conn: The database connection (MongoDB) to use to perform the action.
:param auth_token: An instance of the core auth-token model that holds data in the database.
:param token_notes: Any notes to feed into MariaDB with the token identifier.
:param session_token: The session token of the user who requested this service.
:return: An ObjectId to later store the granted tokens.
"""
# Note down the timestamp at which this event occurred:
request_ts = date_time.get_current_utc_date_time(as_string = False)
# Get the identifier from the database if it already exists, else create one.
# BE CAREFUL WITH THE KEYS HERE, THEY SHOULD MATCH THE FIELDS OF THE CORE AUTH-TOKEN MODEL:
mongo_json = await mongo_conn.find_one_and_update(
collection = self.AUTH_COLLECTION,
filter = mongo_conn.dict_to_dot_notation({
"serviceType": auth_token.serviceType,
"user": {
"entityId": auth_token.user.entityId,
"billingAccountId": auth_token.user.billingAccountId
},
"clientUserId": auth_token.clientUserId
}),
update = {
"$set": {
"lastRequestTs": auth_token.lastRequestTs,
"status": auth_token.status,
"syncFreq": auth_token.syncFreq
},
"$setOnInsert": {
"serviceType": auth_token.serviceType,
"client": auth_token.client,
"authType": auth_token.authType,
"user": auth_token.user.model_dump(),
"clientUserId": auth_token.clientUserId,
"auth": auth_token.auth,
"token": auth_token.token,
"firstRefreshTs": auth_token.firstRefreshTs,
"lastRefreshTs": auth_token.lastRefreshTs,
"firstRequestTs": auth_token.firstRequestTs or request_ts,
}
},
projection = {
"_id": True
},
upsert = True,
return_updated = True
)
# Tell MariaDB that an authorization request was initiated:
db_json = {}
if mongo_json is not None:
db_json = await self.call_procedure(
db_conn = db_conn,
proc_name = "entity_integration_save",
proc_args = (
auth_token.user.entityId, # ..................................... 'p_entity_id'
auth_token.client, # ............................................ 'p_provider'
auth_token.status, # ............................................ 'p_current_status'
"Auth Requested", # ............................................. 'p_last_action'
None, # ......................................................... 'p_display_name'
None, # ......................................................... 'p_display_picture'
str(mongo_json["_id"]), # ....................................... 'p_token_id'
json.to_string(python_data = token_notes, no_space = True), # ... 'p_notes'
auth_token.user.userId # ........................................ 'p_created_by'
),
session_token = session_token
)
# Done here:
return mongo_json["_id"] if mongo_json and db_json.get("status") == 1 else None
async def set_token(
self,
db_conn: AsyncMySQL,
mongo_conn: AsyncMongo,
token_id: ObjectId | str,
auth_token: CoreAuthTokenModel,
token_notes: dict,
session_token: str = None
) -> bool:
"""
This method is to be called when the end user authorizes your service to connect to his third-party account. For
example, when the end user allows you to access his GMail account. USE THIS FOR UPDATING (REFRESHING) TOKENS
ALSO.
:param db_conn: The database connection (MariaDB) to use to perform the action.
:param mongo_conn: The database connection (MongoDB) to use to perform the action.
:param token_id: The identifier granted by the 'get_token_id' method.
:param auth_token: The actual auth/token data to be saved to the database.
:param token_notes: Any notes to feed into MariaDB with the token identifier.
:param session_token: The session token of the user who requested this service.
:return: True if saved, False if failed.
"""
# Start by assuming failure:
token_saved = False
# Note down the timestamp at which this event occurred:
request_ts = date_time.get_current_utc_date_time(as_string = False)
# Save the token to MongoDB.
# BE CAREFUL WITH THE KEYS HERE, THEY SHOULD MATCH THE FIELDS OF THE CORE AUTH-TOKEN MODEL:
mongo_json = await mongo_conn.find_one_and_update(
collection = self.AUTH_COLLECTION,
filter = mongo_conn.dict_to_dot_notation({
"_id": ObjectId(token_id),
"clientUserId": auth_token.clientUserId
}),
update = [{
"$set": {
"auth": auth_token.auth,
"token": auth_token.token,
"status": auth_token.status,
"lastRefreshTs": request_ts,
"firstRefreshTs": {
"$cond": {
"if": {
"$or": [
{"$eq": ["$firstRefreshTs", None]},
{"$eq": [{"$type": "$firstRefreshTs"}, "missing"]}
]
},
"then": request_ts,
"else": "$firstRefreshTs"
}
}
}
}],
projection = {"token": False},
return_updated = True,
upsert = False
)
# Tell MariaDB that the token was saved:
if mongo_json is not None:
db_json = await self.call_procedure(
db_conn = db_conn,
proc_name = "entity_integration_save",
proc_args = (
mongo_json["user"]["entityId"], # ............................... 'p_entity_id'
mongo_json["client"], # ......................................... 'p_provider'
auth_token.status, # ............................................ 'p_current_status'
"Auth Granted", # ............................................... 'p_last_action'
auth_token.token.get("displayName"), # .......................... 'p_display_name'
auth_token.token.get("displayPictureUrl"), # .................... 'p_display_picture'
token_id, # ..................................................... 'p_token_id'
json.to_string(python_data = token_notes, no_space = True), # ... 'p_notes'
auth_token.user.userId # ........................................ 'p_created_by'
),
session_token = session_token
)
if db_json["status"] == 1: token_saved = True
# Done here:
return token_saved
async def get_token(
self,
mongo_conn: AsyncMongo,
token_id: ObjectId | str = None,
**kwargs
) -> CoreAuthTokenModel | None:
"""
To retrieve stored tokens from the database.
:param mongo_conn: The database connection (MongoDB) to use to perform the action.
:param token_id: The identifier granted by the 'get_token_id' method.
:param kwargs: Any set of key-value pairs to build custom search criteria. This could be things like the user
info, the client, the type of authentication used, or even the kind of service.
:return: The retrieved record that has the token, and information about the service and client if found, else
None when there is no matching record.
"""
# Build the filter:
filter_json = {k: v for k, v in kwargs.items()}
if token_id: filter_json["_id"] = ObjectId(token_id)
# If there is no search criteria, we exit with failure:
if not filter_json: return None
# If there is some filtering possible, we fetch the token:
token = await mongo_conn.find_one(
collection = self.AUTH_COLLECTION,
filter = filter_json,
)
# Done here:
return CoreAuthTokenModel(**token) if token else None
# *****************************************************************************************************************
# ***** ****
# *** MAIN PROGRAM ***
# ***** ****
# *****************************************************************************************************************
if __name__ == "__main__":
pass
+389
View File
@@ -0,0 +1,389 @@
"""
AUTHOR:
Khushal P Soonderji
DATE:
Thursday, 12th Dec., 2024
OBJECTIVE:
To handle all messages from one place.
REFERENCES:
N/A
DOWNLOADS:
N/A
"""
# *****************************************************************************************************************
# ***** ****
# *** IMPORT ***
# ***** ****
# *****************************************************************************************************************
# To make sibling directories accessible for imports:
import sys
from pyexpat.errors import messages
sys.path.append(".")
sys.path.append("..")
# For Quart:
from quart import current_app
# My async utils:
from utils_v2.string import json
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, AsyncMongoStorage
# Base model:
from controllers.base import BaseModel
# Data models:
from models.core.auth_token import CoreAuthTokenModel
from models.core.message import CoreMessageModel
from models.core.user import CoreUserInfoModel
# To work with MongoDB:
from bson import ObjectId
from pymongo import InsertOne, UpdateOne, ReplaceOne
# To work with datatypes:
from typing import Literal, List, Dict, Any
# To make deep-copies:
import copy
# To work with base-64 encoding:
import base64
# To work with date and time:
import datetime
# For asynchronous activities:
import asyncio
# *****************************************************************************************************************
# ***** ****
# *** MACROS / ONE-TIME INIT ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** VARIABLES ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** FUNCTIONS ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** CLASSES ***
# ***** ****
# *****************************************************************************************************************
class MessageController(BaseModel):
# ┏┓┓ ┓┏
# ┃ ┃┏┓┏┏ ┃┃┏┓┏┓┏
# ┗┛┗┗┻┛┛ ┗┛┗┻┛ ┛
# For MongoDB:
MESSAGES_COLLECTION = "_messages"
# ┏┓┳┓┳┳┳┓ ┏┓
# ┃ ┣┫┃┃┃┃ ━━ ┃ ┏┓┏┓┏┓╋┏┓
# ┗┛┛┗┗┛┻┛ ┗┛┛ ┗ ┗┻┗┗
async def insert(
self,
mongo_conn: AsyncMongo,
message: CoreMessageModel
) -> ObjectId:
# Simply insert the document:
return await mongo_conn.insert_one(
collection = self.MESSAGES_COLLECTION,
document = message,
raise_exception = True
)
async def bulk_write(
self,
mongo_conn: AsyncMongo,
mongo_operations
) -> int:
return await mongo_conn.bulk_write(
collection = self.MESSAGES_COLLECTION,
requests = mongo_operations
)
# ┏┓┳┓┳┳┳┓ ┳┓ •
# ┃ ┣┫┃┃┃┃ ━━ ┣┫┏┓╋┏┓┓┏┓┓┏┏┓
# ┗┛┛┗┗┛┻┛ ┛┗┗ ┗┛ ┗┗ ┗┛┗
async def count_messages(
self,
mongo_conn: AsyncMongo,
token_ids: List[ObjectId | str],
additional_filter: dict = None
) -> int:
"""
Just counts the no. of messages that match a given set of conditions.
:param mongo_conn: The instance of the database connector to use for the operation.
:param token_ids: The token ids of the accounts from which these messages must be fetched.
:param additional_filter: Any addition filters to use.
:return: The no. of messages that match the given conditions.
"""
# Prepare the filter:
if not isinstance(token_ids, list): token_ids = [token_ids]
token_ids = [ObjectId(t) for t in token_ids]
filter_json = {"tokenId": {"$in": token_ids}}
if additional_filter:
for k, v in additional_filter.items():
filter_json[k] = v
# Get the count of the documents that match the criteria:
count = await mongo_conn.count(
collection = self.MESSAGES_COLLECTION,
filter = filter_json,
raise_exception = True
)
# Done here:
return count
async def get_previews(
self,
mongo_conn: AsyncMongo,
token_ids: List[ObjectId | str],
limit: int = 100,
skip: int = 0,
additional_filter: dict = None
) -> List[CoreMessageModel] | None:
"""
Fetches many messages in one call, but leaves out the full payloads. This does not mark messages as read.
:param mongo_conn: The instance of the database connector to use for the operation.
:param token_ids: The token ids of the accounts from which these messages must be fetched.
:param limit: The max. no. of messages to retrieve in this call.
:param skip: The no. of initial messages to skip. Useful for pagination.
:param additional_filter: Any addition filters to use.
:return: The list of messages (as the message model). This list can be empty.
"""
# Prepare the filter:
if not isinstance(token_ids, list): token_ids = [token_ids]
token_ids = [ObjectId(t) for t in token_ids]
filter_json = {"tokenId": {"$in": token_ids}}
if additional_filter:
for k, v in additional_filter.items():
filter_json[k] = v
# We fetch the messages that are identified by a specific token id,
# with the specified fetching limits, while enforcing the sorting condition:
records = await mongo_conn.find_many(
collection = self.MESSAGES_COLLECTION,
filter = filter_json,
limit = limit,
skip = skip,
sort = {"ts": -1},
projection = {
"_id": True,
"ts": True,
"markedAsUnread": True,
"serviceType": True,
"client": True,
"clientMessageId": True,
"clientThreadId": True,
"isSent": True,
"isBroadcast": True,
"sentSuccessfully": True,
"aiSnippet": True,
"preview": True,
"message": {},
"tags": True,
"usedAi": True
},
raise_exception = True
)
# Convert the fetched records to instances of the data model and return:
return [CoreMessageModel(**record) for record in records]
async def get_messages(
self,
mongo_conn: AsyncMongo,
token_ids: List[ObjectId | str],
limit: int = 100,
skip: int = 0,
additional_filter: dict = None
) -> List[CoreMessageModel] | None:
"""
Fetches many full messages in one call.
:param mongo_conn: The instance of the database connector to use for the operation.
:param token_ids: The token ids of the accounts from which these messages must be fetched.
:param limit: The max. no. of messages to retrieve in this call.
:param skip: The no. of initial messages to skip. Useful for pagination.
:param additional_filter: Any addition filters to use.
:return: The list of messages (as the message model). This list can be empty.
"""
# Note down the timestamp at which this event occurred:
request_ts = date_time.get_current_utc_date_time(as_string = False)
# Prepare the filter:
if not isinstance(token_ids, list): token_ids = [token_ids]
token_ids = [ObjectId(t) for t in token_ids]
filter_json = {"tokenId": {"$in": token_ids}}
if additional_filter:
for k, v in additional_filter.items():
filter_json[k] = v
# We fetch the messages that are identified by a specific token id,
# with the specified fetching limits, while enforcing the sorting condition:
records = await mongo_conn.find_many(
collection = self.MESSAGES_COLLECTION,
filter = filter_json,
limit = limit,
skip = skip,
sort = {"ts": -1},
raise_exception = True
)
# We now mark these fetched messages as read through a bulk-write operation:
operations = [
UpdateOne(
filter = {"_id": record["_id"]},
update = [{
"$set": {
"readTs": {
"$cond": {
"if": {
"$or": [
{"$eq": ["$readTs", None]},
{"$eq": [{"$type": "$readTs"}, "missing"]}
]
},
"then": request_ts,
"else": "$readTs"
}
}
}
}],
upsert = False
) for record in records
]
updated_count = await mongo_conn.bulk_write(
collection = self.MESSAGES_COLLECTION,
requests = operations,
raise_exception = True
)
# Convert the fetched records to instances of the data model and return:
return [CoreMessageModel(**record) for record in records]
async def get_message(
self,
mongo_conn: AsyncMongo,
message_id: ObjectId | str,
) -> CoreMessageModel | None:
"""
Gets one message if you know its message id. Marks that message as read.
:param mongo_conn:
:param message_id:
:return:
"""
# Note down the timestamp at which this event occurred:
request_ts = date_time.get_current_utc_date_time(as_string = False)
# We fetch the whole payload of that one message
# while also marking it as read if not already marked:
record = await mongo_conn.find_one_and_update(
collection = self.MESSAGES_COLLECTION,
filter = {"_id": ObjectId(message_id)},
update = [{
"$set": {
"readTs": {
"$cond": {
"if": {
"$or": [
{"$eq": ["$readTs", None]},
{"$eq": [{"$type": "$readTs"}, "missing"]}
]
},
"then": request_ts,
"else": "$readTs"
}
}
}
}],
raise_exception = True
)
# If no such message was found:
if record is None: return None
# If a record was found,
# we return it as our data model:
return CoreMessageModel(**record)
# ┏┓┳┓┳┳┳┓ ┳┳ ┓
# ┃ ┣┫┃┃┃┃ ━━ ┃┃┏┓┏┫┏┓╋┏┓
# ┗┛┛┗┗┛┻┛ ┗┛┣┛┗┻┗┻┗┗
# ┛
# We don't support updating messages themselves,
# but we will allow updating fields like tags, marking as read or unread, etc.
# ┏┓┳┓┳┳┳┓ ┳┓ ┓
# ┃ ┣┫┃┃┃┃ ━━ ┃┃┏┓┃┏┓╋┏┓
# ┗┛┛┗┗┛┻┛ ┻┛┗ ┗┗ ┗┗
# No support whatsoever for deleting messages.
# *****************************************************************************************************************
# ***** ****
# *** MAIN PROGRAM ***
# ***** ****
# *****************************************************************************************************************
if __name__ == "__main__":
pass