From 28e06fa684854c535046334afe52ecc8cdde2fb0 Mon Sep 17 00:00:00 2001 From: Siddhant Rai Date: Tue, 3 Sep 2024 16:11:24 +0530 Subject: [PATCH 1/6] fix: minor ui inconsistencies --- application/retriever/classic_rag.py | 9 +- frontend/src/components/Input.tsx | 8 +- frontend/src/components/types/index.ts | 1 + .../src/conversation/ConversationBubble.tsx | 2 +- frontend/src/index.css | 20 +++ frontend/src/upload/Upload.tsx | 135 ++++++++++-------- 6 files changed, 109 insertions(+), 66 deletions(-) diff --git a/application/retriever/classic_rag.py b/application/retriever/classic_rag.py index aef6e503e..32f512349 100644 --- a/application/retriever/classic_rag.py +++ b/application/retriever/classic_rag.py @@ -61,13 +61,12 @@ def _get_data(self): settings.VECTOR_STORE, self.vectorstore, settings.EMBEDDINGS_KEY ) docs_temp = docsearch.search(self.question, k=self.chunks) + print(docs_temp) docs = [ { - "title": ( - i.metadata["title"].split("/")[-1] - if i.metadata - else i.page_content - ), + "title": i.metadata.get( + "title", i.metadata.get("post_title", i.page_content) + ).split("/")[-1], "text": i.page_content, "source": ( i.metadata.get("source") diff --git a/frontend/src/components/Input.tsx b/frontend/src/components/Input.tsx index 56ca1d526..17e601900 100644 --- a/frontend/src/components/Input.tsx +++ b/frontend/src/components/Input.tsx @@ -10,6 +10,7 @@ const Input = ({ maxLength, className, colorVariant = 'silver', + borderVariant = 'thick', children, onChange, onPaste, @@ -20,10 +21,13 @@ const Input = ({ jet: 'border-jet', gray: 'border-gray-5000 dark:text-silver', }; - + const borderStyles = { + thin: 'border', + thick: 'border-2', + }; return ( 3 && (
setIsSidebarOpen(true)} >

{`View ${ diff --git a/frontend/src/index.css b/frontend/src/index.css index 6ae4b762c..025059ac0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -424,6 +424,26 @@ template { width: 0; } +input:-webkit-autofill { + -webkit-box-shadow: 0 0 0 50px white inset; +} + +input:-webkit-autofill:focus { + -webkit-box-shadow: 0 0 0 50px white inset; +} + +@media (prefers-color-scheme: dark) { + input:-webkit-autofill { + -webkit-box-shadow: 0 0 0 50px rgb(68, 70, 84) inset; + -webkit-text-fill-color: white; + } + + input:-webkit-autofill:focus { + -webkit-box-shadow: 0 0 0 50px rgb(68, 70, 84) inset; + -webkit-text-fill-color: white; + } +} + .inputbox-style { resize: none; padding-left: 36px; diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx index c5eed6d80..2641a8f87 100644 --- a/frontend/src/upload/Upload.tsx +++ b/frontend/src/upload/Upload.tsx @@ -275,7 +275,7 @@ function Upload({ } else setRedditData({ ...redditData, - [name]: value, + [name]: name === 'number_posts' ? parseInt(value) : value, }); }; @@ -321,6 +321,7 @@ function Upload({ colorVariant="gray" value={docName} onChange={(e) => setDocName(e.target.value)} + borderVariant="thin" >

@@ -356,6 +357,7 @@ function Upload({ {activeTab === 'remote' && ( <> @@ -371,6 +373,7 @@ function Upload({ type="text" value={urlName} onChange={(e) => setUrlName(e.target.value)} + borderVariant="thin" >
@@ -382,6 +385,7 @@ function Upload({ type="text" value={url} onChange={(e) => setUrl(e.target.value)} + borderVariant="thin" >
@@ -390,68 +394,83 @@ function Upload({
) : ( - <> - -
- - {t('modals.uploadDoc.reddit.id')} - +
+
+ +
+ + {t('modals.uploadDoc.reddit.id')} + +
- -
- - {t('modals.uploadDoc.reddit.secret')} - +
+ +
+ + {t('modals.uploadDoc.reddit.secret')} + +
- -
- - {t('modals.uploadDoc.reddit.agent')} - +
+ +
+ + {t('modals.uploadDoc.reddit.agent')} + +
- -
- - {t('modals.uploadDoc.reddit.searchQueries')} - +
+ +
+ + {t('modals.uploadDoc.reddit.searchQueries')} + +
- -
- - {t('modals.uploadDoc.reddit.numberOfPosts')} - +
+ +
+ + {t('modals.uploadDoc.reddit.numberOfPosts')} + +
- +
)} )} From ac930d5504d38e9c351d494cc7d194fffb290ca9 Mon Sep 17 00:00:00 2001 From: Siddhant Rai Date: Sat, 7 Sep 2024 14:44:35 +0530 Subject: [PATCH 2/6] feat: analytics dashboard with respective endpoints --- application/api/user/routes.py | 440 ++++++++++++++++++++++- frontend/package-lock.json | 28 ++ frontend/package.json | 1 + frontend/src/api/endpoints.ts | 3 + frontend/src/api/services/userService.ts | 6 + frontend/src/components/Dropdown.tsx | 8 +- frontend/src/locale/en.json | 3 + frontend/src/locale/es.json | 3 + frontend/src/locale/jp.json | 3 + frontend/src/locale/zh.json | 3 + frontend/src/settings/APIKeys.tsx | 5 +- frontend/src/settings/Analytics.tsx | 390 ++++++++++++++++++++ frontend/src/settings/General.tsx | 2 +- frontend/src/settings/index.tsx | 4 + frontend/src/settings/types/index.ts | 8 + frontend/src/utils/chartUtils.ts | 77 ++++ frontend/src/utils/dateTimeUtils.ts | 20 ++ 17 files changed, 992 insertions(+), 12 deletions(-) create mode 100644 frontend/src/settings/Analytics.tsx create mode 100644 frontend/src/settings/types/index.ts create mode 100644 frontend/src/utils/chartUtils.ts create mode 100644 frontend/src/utils/dateTimeUtils.ts diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 91b90d6ad..dbe8a0b26 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -1,14 +1,17 @@ +import datetime import os -import uuid import shutil -from flask import Blueprint, request, jsonify +import uuid from urllib.parse import urlparse + import requests -from pymongo import MongoClient -from bson.objectid import ObjectId from bson.binary import Binary, UuidRepresentation -from werkzeug.utils import secure_filename from bson.dbref import DBRef +from bson.objectid import ObjectId +from flask import Blueprint, jsonify, request +from pymongo import MongoClient +from werkzeug.utils import secure_filename + from application.api.user.tasks import ingest, ingest_remote from application.core.settings import settings @@ -21,6 +24,7 @@ prompts_collection = db["prompts"] feedback_collection = db["feedback"] api_key_collection = db["api_keys"] +token_usage_collection = db["token_usage"] shared_conversations_collections = db["shared_conversations"] user = Blueprint("user", __name__) @@ -30,6 +34,27 @@ ) +def generate_minute_range(start_date, end_date): + return { + (start_date + datetime.timedelta(minutes=i)).strftime("%Y-%m-%d %H:%M:00"): 0 + for i in range(int((end_date - start_date).total_seconds() // 60) + 1) + } + + +def generate_hourly_range(start_date, end_date): + return { + (start_date + datetime.timedelta(hours=i)).strftime("%Y-%m-%d %H:00"): 0 + for i in range(int((end_date - start_date).total_seconds() // 3600) + 1) + } + + +def generate_date_range(start_date, end_date): + return { + (start_date + datetime.timedelta(days=i)).strftime("%Y-%m-%d"): 0 + for i in range((end_date - start_date).days + 1) + } + + @user.route("/api/delete_conversation", methods=["POST"]) def delete_conversation(): # deletes a conversation from the database @@ -96,6 +121,7 @@ def api_feedback(): "question": question, "answer": answer, "feedback": feedback, + "timestamp": datetime.datetime.now(datetime.timezone.utc), } ) return {"status": "ok"} @@ -697,3 +723,407 @@ def get_publicly_shared_conversations(identifier: str): except Exception as err: print(err) return jsonify({"success": False, "error": str(err)}), 400 + + +@user.route("/api/get_message_analytics", methods=["POST"]) +def get_message_analytics(): + data = request.get_json() + api_key_id = data.get("api_key_id") + filter_option = data.get("filter_option", "last_30_days") + + try: + api_key = ( + api_key_collection.find_one({"_id": ObjectId(api_key_id)})["key"] + if api_key_id + else None + ) + except Exception as err: + print(err) + return jsonify({"success": False, "error": str(err)}), 400 + end_date = datetime.datetime.now(datetime.timezone.utc) + + if filter_option == "last_hour": + start_date = end_date - datetime.timedelta(hours=1) + group_format = "%Y-%m-%d %H:%M:00" + group_stage = { + "$group": { + "_id": { + "minute": { + "$dateToString": {"format": group_format, "date": "$date"} + } + }, + "total_messages": {"$sum": 1}, + } + } + + elif filter_option == "last_24_hour": + start_date = end_date - datetime.timedelta(hours=24) + group_format = "%Y-%m-%d %H:00" + group_stage = { + "$group": { + "_id": { + "hour": {"$dateToString": {"format": group_format, "date": "$date"}} + }, + "total_messages": {"$sum": 1}, + } + } + + else: + if filter_option in ["last_7_days", "last_15_days", "last_30_days"]: + filter_days = ( + 6 + if filter_option == "last_7_days" + else (14 if filter_option == "last_15_days" else 29) + ) + else: + return jsonify({"success": False, "error": "Invalid option"}), 400 + start_date = end_date - datetime.timedelta(days=filter_days) + start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0) + end_date = end_date.replace(hour=23, minute=59, second=59, microsecond=999999) + group_format = "%Y-%m-%d" + group_stage = { + "$group": { + "_id": { + "day": {"$dateToString": {"format": group_format, "date": "$date"}} + }, + "total_messages": {"$sum": 1}, + } + } + + try: + match_stage = { + "$match": { + "date": {"$gte": start_date, "$lte": end_date}, + } + } + if api_key: + match_stage["$match"]["api_key"] = api_key + message_data = conversations_collection.aggregate( + [ + match_stage, + group_stage, + {"$sort": {"_id": 1}}, + ] + ) + + if filter_option == "last_hour": + intervals = generate_minute_range(start_date, end_date) + elif filter_option == "last_24_hour": + intervals = generate_hourly_range(start_date, end_date) + else: + intervals = generate_date_range(start_date, end_date) + + daily_messages = {interval: 0 for interval in intervals} + + for entry in message_data: + if filter_option == "last_hour": + daily_messages[entry["_id"]["minute"]] = entry["total_messages"] + elif filter_option == "last_24_hour": + daily_messages[entry["_id"]["hour"]] = entry["total_messages"] + else: + daily_messages[entry["_id"]["day"]] = entry["total_messages"] + + except Exception as err: + print(err) + return jsonify({"success": False, "error": str(err)}), 400 + + return jsonify({"success": True, "messages": daily_messages}), 200 + + +@user.route("/api/get_token_analytics", methods=["POST"]) +def get_token_analytics(): + data = request.get_json() + api_key_id = data.get("api_key_id") + filter_option = data.get("filter_option", "last_30_days") + + try: + api_key = ( + api_key_collection.find_one({"_id": ObjectId(api_key_id)})["key"] + if api_key_id + else None + ) + except Exception as err: + print(err) + return jsonify({"success": False, "error": str(err)}), 400 + end_date = datetime.datetime.now(datetime.timezone.utc) + + if filter_option == "last_hour": + start_date = end_date - datetime.timedelta(hours=1) + group_format = "%Y-%m-%d %H:%M:00" + group_stage = { + "$group": { + "_id": { + "minute": { + "$dateToString": {"format": group_format, "date": "$timestamp"} + } + }, + "total_tokens": { + "$sum": {"$add": ["$prompt_tokens", "$generated_tokens"]} + }, + } + } + + elif filter_option == "last_24_hour": + start_date = end_date - datetime.timedelta(hours=24) + group_format = "%Y-%m-%d %H:00" + group_stage = { + "$group": { + "_id": { + "hour": { + "$dateToString": {"format": group_format, "date": "$timestamp"} + } + }, + "total_tokens": { + "$sum": {"$add": ["$prompt_tokens", "$generated_tokens"]} + }, + } + } + + else: + if filter_option in ["last_7_days", "last_15_days", "last_30_days"]: + filter_days = ( + 6 + if filter_option == "last_7_days" + else (14 if filter_option == "last_15_days" else 29) + ) + else: + return jsonify({"success": False, "error": "Invalid option"}), 400 + start_date = end_date - datetime.timedelta(days=filter_days) + start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0) + end_date = end_date.replace(hour=23, minute=59, second=59, microsecond=999999) + group_format = "%Y-%m-%d" + group_stage = { + "$group": { + "_id": { + "day": { + "$dateToString": {"format": group_format, "date": "$timestamp"} + } + }, + "total_tokens": { + "$sum": {"$add": ["$prompt_tokens", "$generated_tokens"]} + }, + } + } + + try: + match_stage = { + "$match": { + "timestamp": {"$gte": start_date, "$lte": end_date}, + } + } + if api_key: + match_stage["$match"]["api_key"] = api_key + + token_usage_data = token_usage_collection.aggregate( + [ + match_stage, + group_stage, + {"$sort": {"_id": 1}}, + ] + ) + + if filter_option == "last_hour": + intervals = generate_minute_range(start_date, end_date) + elif filter_option == "last_24_hour": + intervals = generate_hourly_range(start_date, end_date) + else: + intervals = generate_date_range(start_date, end_date) + + daily_token_usage = {interval: 0 for interval in intervals} + + for entry in token_usage_data: + if filter_option == "last_hour": + daily_token_usage[entry["_id"]["minute"]] = entry["total_tokens"] + elif filter_option == "last_24_hour": + daily_token_usage[entry["_id"]["hour"]] = entry["total_tokens"] + else: + daily_token_usage[entry["_id"]["day"]] = entry["total_tokens"] + + except Exception as err: + print(err) + return jsonify({"success": False, "error": str(err)}), 400 + + return jsonify({"success": True, "token_usage": daily_token_usage}), 200 + + +@user.route("/api/get_feedback_analytics", methods=["POST"]) +def get_feedback_analytics(): + data = request.get_json() + api_key_id = data.get("api_key_id") + filter_option = data.get("filter_option", "last_30_days") + + try: + api_key = ( + api_key_collection.find_one({"_id": ObjectId(api_key_id)})["key"] + if api_key_id + else None + ) + except Exception as err: + print(err) + return jsonify({"success": False, "error": str(err)}), 400 + end_date = datetime.datetime.now(datetime.timezone.utc) + + if filter_option == "last_hour": + start_date = end_date - datetime.timedelta(hours=1) + group_format = "%Y-%m-%d %H:%M:00" + group_stage_1 = { + "$group": { + "_id": { + "minute": { + "$dateToString": {"format": group_format, "date": "$timestamp"} + }, + "feedback": "$feedback", + }, + "count": {"$sum": 1}, + } + } + group_stage_2 = { + "$group": { + "_id": "$_id.minute", + "likes": { + "$sum": { + "$cond": [ + {"$eq": ["$_id.feedback", "LIKE"]}, + "$count", + 0, + ] + } + }, + "dislikes": { + "$sum": { + "$cond": [ + {"$eq": ["$_id.feedback", "DISLIKE"]}, + "$count", + 0, + ] + } + }, + } + } + + elif filter_option == "last_24_hour": + start_date = end_date - datetime.timedelta(hours=24) + group_format = "%Y-%m-%d %H:00" + group_stage_1 = { + "$group": { + "_id": { + "hour": { + "$dateToString": {"format": group_format, "date": "$timestamp"} + }, + "feedback": "$feedback", + }, + "count": {"$sum": 1}, + } + } + group_stage_2 = { + "$group": { + "_id": "$_id.hour", + "likes": { + "$sum": { + "$cond": [ + {"$eq": ["$_id.feedback", "LIKE"]}, + "$count", + 0, + ] + } + }, + "dislikes": { + "$sum": { + "$cond": [ + {"$eq": ["$_id.feedback", "DISLIKE"]}, + "$count", + 0, + ] + } + }, + } + } + + else: + if filter_option in ["last_7_days", "last_15_days", "last_30_days"]: + filter_days = ( + 6 + if filter_option == "last_7_days" + else (14 if filter_option == "last_15_days" else 29) + ) + else: + return jsonify({"success": False, "error": "Invalid option"}), 400 + start_date = end_date - datetime.timedelta(days=filter_days) + start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0) + end_date = end_date.replace(hour=23, minute=59, second=59, microsecond=999999) + group_format = "%Y-%m-%d" + group_stage_1 = { + "$group": { + "_id": { + "day": { + "$dateToString": {"format": group_format, "date": "$timestamp"} + }, + "feedback": "$feedback", + }, + "count": {"$sum": 1}, + } + } + group_stage_2 = { + "$group": { + "_id": "$_id.day", + "likes": { + "$sum": { + "$cond": [ + {"$eq": ["$_id.feedback", "LIKE"]}, + "$count", + 0, + ] + } + }, + "dislikes": { + "$sum": { + "$cond": [ + {"$eq": ["$_id.feedback", "DISLIKE"]}, + "$count", + 0, + ] + } + }, + } + } + + try: + match_stage = { + "$match": { + "timestamp": {"$gte": start_date, "$lte": end_date}, + } + } + if api_key: + match_stage["$match"]["api_key"] = api_key + + feedback_data = feedback_collection.aggregate( + [ + match_stage, + group_stage_1, + group_stage_2, + {"$sort": {"_id": 1}}, + ] + ) + + if filter_option == "last_hour": + intervals = generate_minute_range(start_date, end_date) + elif filter_option == "last_24_hour": + intervals = generate_hourly_range(start_date, end_date) + else: + intervals = generate_date_range(start_date, end_date) + + daily_feedback = { + interval: {"positive": 0, "negative": 0} for interval in intervals + } + + for entry in feedback_data: + daily_feedback[entry["_id"]] = { + "positive": entry["likes"], + "negative": entry["dislikes"], + } + + except Exception as err: + print(err) + return jsonify({"success": False, "error": str(err)}), 400 + + return jsonify({"success": True, "feedback": daily_feedback}), 200 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0f7675fa1..d342ab5a7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "i18next-browser-languagedetector": "^8.0.0", "prop-types": "^15.8.1", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", @@ -858,6 +859,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==", + "peer": true + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2503,6 +2510,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chart.js": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz", + "integrity": "sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==", + "peer": true, + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -7253,6 +7272,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-copy-to-clipboard": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index e45fbd36a..7bf983aaa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "i18next-browser-languagedetector": "^8.0.0", "prop-types": "^15.8.1", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index af2fb920e..09ddd2f1d 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -12,6 +12,9 @@ const endpoints = { SINGLE_PROMPT: (id: string) => `/api/get_single_prompt?id=${id}`, DELETE_PATH: (docPath: string) => `/api/delete_old?path=${docPath}`, TASK_STATUS: (task_id: string) => `/api/task_status?task_id=${task_id}`, + MESSAGE_ANALYTICS: '/api/get_message_analytics', + TOKEN_ANALYTICS: '/api/get_token_analytics', + FEEDBACK_ANALYTICS: '/api/get_feedback_analytics', }, CONVERSATION: { ANSWER: '/api/answer', diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index 193fe6ad4..ba9d7bb8d 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -23,6 +23,12 @@ const userService = { apiClient.get(endpoints.USER.DELETE_PATH(docPath)), getTaskStatus: (task_id: string): Promise => apiClient.get(endpoints.USER.TASK_STATUS(task_id)), + getMessageAnalytics: (data: any): Promise => + apiClient.post(endpoints.USER.MESSAGE_ANALYTICS, data), + getTokenAnalytics: (data: any): Promise => + apiClient.post(endpoints.USER.TOKEN_ANALYTICS, data), + getFeedbackAnalytics: (data: any): Promise => + apiClient.post(endpoints.USER.FEEDBACK_ANALYTICS, data), }; export default userService; diff --git a/frontend/src/components/Dropdown.tsx b/frontend/src/components/Dropdown.tsx index adf17889c..c5961aaa8 100644 --- a/frontend/src/components/Dropdown.tsx +++ b/frontend/src/components/Dropdown.tsx @@ -16,6 +16,7 @@ function Dropdown({ showDelete, onDelete, placeholder, + contentSize = 'text-base', }: { options: | string[] @@ -41,6 +42,7 @@ function Dropdown({ showDelete?: boolean; onDelete?: (value: string) => void; placeholder?: string; + contentSize?: string; }) { const dropdownRef = React.useRef(null); const [isOpen, setIsOpen] = React.useState(false); @@ -84,9 +86,9 @@ function Dropdown({ ) : ( {selectedValue && 'label' in selectedValue ? selectedValue.label @@ -123,7 +125,7 @@ function Dropdown({ onSelect(option); setIsOpen(false); }} - className="ml-5 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap py-3 dark:text-light-gray" + className={`ml-5 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap py-3 dark:text-light-gray ${contentSize}`} > {typeof option === 'string' ? option diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 773768bd8..7361d2122 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -62,6 +62,9 @@ "key": "API Key", "sourceDoc": "Source Document", "createNew": "Create New" + }, + "analytics": { + "label": "Analytics" } }, "modals": { diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index cb455ab63..293c4117d 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -62,6 +62,9 @@ "key": "Clave de API", "sourceDoc": "Documento Fuente", "createNew": "Crear Nuevo" + }, + "analytics": { + "label": "Analítica" } }, "modals": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 6c8700695..99fa85dde 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -62,6 +62,9 @@ "key": "APIキー", "sourceDoc": "ソースドキュメント", "createNew": "新規作成" + }, + "analytics": { + "label": "分析" } }, "modals": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index cfe9d180f..cbd820562 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -62,6 +62,9 @@ "key": "API 密钥", "sourceDoc": "源文档", "createNew": "创建新的" + }, + "analytics": { + "label": "分析" } }, "modals": { diff --git a/frontend/src/settings/APIKeys.tsx b/frontend/src/settings/APIKeys.tsx index dc220013d..923326f36 100644 --- a/frontend/src/settings/APIKeys.tsx +++ b/frontend/src/settings/APIKeys.tsx @@ -5,15 +5,14 @@ import userService from '../api/services/userService'; import Trash from '../assets/trash.svg'; import CreateAPIKeyModal from '../modals/CreateAPIKeyModal'; import SaveAPIKeyModal from '../modals/SaveAPIKeyModal'; +import { APIKeyData } from './types'; export default function APIKeys() { const { t } = useTranslation(); const [isCreateModalOpen, setCreateModal] = React.useState(false); const [isSaveKeyModalOpen, setSaveKeyModal] = React.useState(false); const [newKey, setNewKey] = React.useState(''); - const [apiKeys, setApiKeys] = React.useState< - { name: string; key: string; source: string; id: string }[] - >([]); + const [apiKeys, setApiKeys] = React.useState([]); const handleFetchKeys = async () => { try { diff --git a/frontend/src/settings/Analytics.tsx b/frontend/src/settings/Analytics.tsx new file mode 100644 index 000000000..a385c4713 --- /dev/null +++ b/frontend/src/settings/Analytics.tsx @@ -0,0 +1,390 @@ +import { + BarElement, + CategoryScale, + Chart as ChartJS, + Legend, + LinearScale, + Title, + Tooltip, +} from 'chart.js'; +import React from 'react'; +import { Bar } from 'react-chartjs-2'; + +import userService from '../api/services/userService'; +import Dropdown from '../components/Dropdown'; +import { htmlLegendPlugin } from '../utils/chartUtils'; +import { formatDate } from '../utils/dateTimeUtils'; +import { APIKeyData } from './types'; + +import type { ChartData } from 'chart.js'; +ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, +); + +const filterOptions = [ + { label: 'Hour', value: 'last_hour' }, + { label: '24 Hours', value: 'last_24_hour' }, + { label: '7 Days', value: 'last_7_days' }, + { label: '15 Days', value: 'last_15_days' }, + { label: '30 Days', value: 'last_30_days' }, +]; + +export default function Analytics() { + const [messagesData, setMessagesData] = React.useState | null>(null); + const [tokenUsageData, setTokenUsageData] = React.useState | null>(null); + const [feedbackData, setFeedbackData] = React.useState | null>(null); + const [chatbots, setChatbots] = React.useState([]); + const [selectedChatbot, setSelectedChatbot] = + React.useState(); + const [messagesFilter, setMessagesFilter] = React.useState<{ + label: string; + value: string; + }>({ label: '30 Days', value: 'last_30_days' }); + const [tokenUsageFilter, setTokenUsageFilter] = React.useState<{ + label: string; + value: string; + }>({ label: '30 Days', value: 'last_30_days' }); + const [feedbackFilter, setFeedbackFilter] = React.useState<{ + label: string; + value: string; + }>({ label: '30 Days', value: 'last_30_days' }); + + const fetchChatbots = async () => { + try { + const response = await userService.getAPIKeys(); + if (!response.ok) { + throw new Error('Failed to fetch Chatbots'); + } + const chatbots = await response.json(); + setChatbots(chatbots); + } catch (error) { + console.error(error); + } + }; + + const fetchMessagesData = async (chatbot_id?: string, filter?: string) => { + try { + const response = await userService.getMessageAnalytics({ + api_key_id: chatbot_id, + filter_option: filter, + }); + if (!response.ok) { + throw new Error('Failed to fetch analytics data'); + } + const data = await response.json(); + setMessagesData(data.messages); + } catch (error) { + console.error(error); + } + }; + + const fetchTokenData = async (chatbot_id?: string, filter?: string) => { + try { + const response = await userService.getTokenAnalytics({ + api_key_id: chatbot_id, + filter_option: filter, + }); + if (!response.ok) { + throw new Error('Failed to fetch analytics data'); + } + const data = await response.json(); + setTokenUsageData(data.token_usage); + } catch (error) { + console.error(error); + } + }; + + const fetchFeedbackData = async (chatbot_id?: string, filter?: string) => { + try { + const response = await userService.getFeedbackAnalytics({ + api_key_id: chatbot_id, + filter_option: filter, + }); + if (!response.ok) { + throw new Error('Failed to fetch analytics data'); + } + const data = await response.json(); + setFeedbackData(data.feedback); + } catch (error) { + console.error(error); + } + }; + + React.useEffect(() => { + fetchChatbots(); + }, []); + + React.useEffect(() => { + const id = selectedChatbot?.id; + const filter = messagesFilter; + fetchMessagesData(id, filter?.value); + }, [selectedChatbot, messagesFilter]); + + React.useEffect(() => { + const id = selectedChatbot?.id; + const filter = tokenUsageFilter; + fetchTokenData(id, filter?.value); + }, [selectedChatbot, tokenUsageFilter]); + + React.useEffect(() => { + const id = selectedChatbot?.id; + const filter = feedbackFilter; + fetchFeedbackData(id, filter?.value); + }, [selectedChatbot, feedbackFilter]); + return ( +
+
+
+

+ Filter by chatbot +

+ ({ + label: chatbot.name, + value: chatbot.id, + })), + { label: 'None', value: '' }, + ]} + placeholder="Select chatbot" + onSelect={(chatbot: { label: string; value: string }) => { + setSelectedChatbot( + chatbots.find((item) => item.id === chatbot.value), + ); + }} + selectedValue={ + (selectedChatbot && { + label: selectedChatbot.name, + value: selectedChatbot.id, + }) || + null + } + rounded="3xl" + border="border" + /> +
+
+
+
+

+ Messages +

+ { + setMessagesFilter(selectedOption); + }} + selectedValue={messagesFilter ?? null} + rounded="3xl" + border="border" + contentSize="text-sm" + /> +
+
+
+ + formatDate(item), + ), + datasets: [ + { + label: 'Messages', + data: Object.values(messagesData || {}), + backgroundColor: '#7D54D1', + }, + ], + }} + legendID="legend-container-1" + maxTicksLimitInX={8} + isStacked={false} + /> +
+
+
+
+

+ Token Usage +

+ { + setTokenUsageFilter(selectedOption); + }} + selectedValue={tokenUsageFilter ?? null} + rounded="3xl" + border="border" + contentSize="text-sm" + /> +
+
+
+ + formatDate(item), + ), + datasets: [ + { + label: 'Tokens', + data: Object.values(tokenUsageData || {}), + backgroundColor: '#7D54D1', + }, + ], + }} + legendID="legend-container-2" + maxTicksLimitInX={8} + isStacked={false} + /> +
+
+
+
+
+
+

+ User Feedback +

+ { + setFeedbackFilter(selectedOption); + }} + selectedValue={feedbackFilter ?? null} + rounded="3xl" + border="border" + contentSize="text-sm" + /> +
+
+
+ + formatDate(item), + ), + datasets: [ + { + label: 'Positive', + data: Object.values(feedbackData || {}).map( + (item) => item.positive, + ), + backgroundColor: '#8BD154', + }, + { + label: 'Negative', + data: Object.values(feedbackData || {}).map( + (item) => item.negative, + ), + backgroundColor: '#D15454', + }, + ], + }} + legendID="legend-container-3" + maxTicksLimitInX={10} + isStacked={true} + /> +
+
+
+
+
+ ); +} + +type AnalyticsChartProps = { + data: ChartData<'bar'>; + legendID: string; + maxTicksLimitInX: number; + isStacked: boolean; +}; + +function AnalyticsChart({ + data, + legendID, + maxTicksLimitInX, + isStacked, +}: AnalyticsChartProps) { + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false, + }, + htmlLegend: { + containerID: legendID, + }, + }, + scales: { + x: { + grid: { + lineWidth: 0.2, + color: '#C4C4C4', + }, + border: { + width: 0.2, + color: '#C4C4C4', + }, + ticks: { + maxTicksLimit: maxTicksLimitInX, + }, + stacked: isStacked, + }, + y: { + grid: { + lineWidth: 0.2, + color: '#C4C4C4', + }, + border: { + width: 0.2, + color: '#C4C4C4', + }, + stacked: isStacked, + }, + }, + }; + return ; +} diff --git a/frontend/src/settings/General.tsx b/frontend/src/settings/General.tsx index 2d0c466d0..e0a24a75b 100644 --- a/frontend/src/settings/General.tsx +++ b/frontend/src/settings/General.tsx @@ -89,7 +89,7 @@ export default function General() { changeLanguage(selectedLanguage?.value); }, [selectedLanguage, changeLanguage]); return ( -
+

{t('settings.general.selectTheme')} diff --git a/frontend/src/settings/index.tsx b/frontend/src/settings/index.tsx index 226ebb3b9..bffe9f7a7 100644 --- a/frontend/src/settings/index.tsx +++ b/frontend/src/settings/index.tsx @@ -11,6 +11,7 @@ import { selectSourceDocs, setSourceDocs, } from '../preferences/preferenceSlice'; +import Analytics from './Analytics'; import APIKeys from './APIKeys'; import Documents from './Documents'; import General from './General'; @@ -23,6 +24,7 @@ export default function Settings() { t('settings.general.label'), t('settings.documents.label'), t('settings.apiKeys.label'), + t('settings.analytics.label'), ]; const [activeTab, setActiveTab] = React.useState(t('settings.general.label')); const [widgetScreenshot, setWidgetScreenshot] = React.useState( @@ -129,6 +131,8 @@ export default function Settings() { ); case t('settings.apiKeys.label'): return ; + case t('settings.analytics.label'): + return ; default: return null; } diff --git a/frontend/src/settings/types/index.ts b/frontend/src/settings/types/index.ts new file mode 100644 index 000000000..7c04dab91 --- /dev/null +++ b/frontend/src/settings/types/index.ts @@ -0,0 +1,8 @@ +export type APIKeyData = { + id: string; + name: string; + key: string; + source: string; + prompt_id: string; + chunks: string; +}; diff --git a/frontend/src/utils/chartUtils.ts b/frontend/src/utils/chartUtils.ts new file mode 100644 index 000000000..eedfe8926 --- /dev/null +++ b/frontend/src/utils/chartUtils.ts @@ -0,0 +1,77 @@ +import { Chart as ChartJS } from 'chart.js'; + +const getOrCreateLegendList = ( + chart: ChartJS, + id: string, +): HTMLUListElement => { + const legendContainer = document.getElementById(id); + let listContainer = legendContainer?.querySelector('ul') as HTMLUListElement; + + if (!listContainer) { + listContainer = document.createElement('ul'); + listContainer.style.display = 'flex'; + listContainer.style.flexDirection = 'row'; + listContainer.style.margin = '0'; + listContainer.style.padding = '0'; + + legendContainer?.appendChild(listContainer); + } + + return listContainer; +}; + +export const htmlLegendPlugin = { + id: 'htmlLegend', + afterUpdate(chart: ChartJS, args: any, options: { containerID: string }) { + const ul = getOrCreateLegendList(chart, options.containerID); + + while (ul.firstChild) { + ul.firstChild.remove(); + } + + const items = + chart.options.plugins?.legend?.labels?.generateLabels?.(chart) || []; + + items.forEach((item: any) => { + const li = document.createElement('li'); + li.style.alignItems = 'center'; + li.style.cursor = 'pointer'; + li.style.display = 'flex'; + li.style.flexDirection = 'row'; + li.style.marginLeft = '10px'; + + li.onclick = () => { + chart.setDatasetVisibility( + item.datasetIndex, + !chart.isDatasetVisible(item.datasetIndex), + ); + chart.update(); + }; + + const boxSpan = document.createElement('span'); + boxSpan.style.background = item.fillStyle; + boxSpan.style.borderColor = item.strokeStyle; + boxSpan.style.borderWidth = item.lineWidth + 'px'; + boxSpan.style.display = 'inline-block'; + boxSpan.style.flexShrink = '0'; + boxSpan.style.height = '10px'; + boxSpan.style.marginRight = '10px'; + boxSpan.style.width = '10px'; + boxSpan.style.borderRadius = '10px'; + + const textContainer = document.createElement('p'); + textContainer.style.fontSize = '12px'; + textContainer.style.color = item.fontColor; + textContainer.style.margin = '0'; + textContainer.style.padding = '0'; + textContainer.style.textDecoration = item.hidden ? 'line-through' : ''; + + const text = document.createTextNode(item.text); + textContainer.appendChild(text); + + li.appendChild(boxSpan); + li.appendChild(textContainer); + ul.appendChild(li); + }); + }, +}; diff --git a/frontend/src/utils/dateTimeUtils.ts b/frontend/src/utils/dateTimeUtils.ts new file mode 100644 index 000000000..7f89007ca --- /dev/null +++ b/frontend/src/utils/dateTimeUtils.ts @@ -0,0 +1,20 @@ +export function formatDate(dateString: string): string { + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(dateString)) { + const dateTime = new Date(dateString); + return dateTime.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + } else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(dateString)) { + const dateTime = new Date(dateString); + return dateTime.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + } else if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } else { + return dateString; + } +} From 90309d5552e8495a5ee7c06be404f3fafcf50fb2 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 10 Sep 2024 01:30:47 +0100 Subject: [PATCH 3/6] feat: user logging api operations level --- application/api/answer/routes.py | 38 ++++++++++++++++++++++++ application/retriever/base.py | 4 +++ application/retriever/brave_search.py | 12 ++++++++ application/retriever/classic_rag.py | 12 ++++++++ application/retriever/duckduck_search.py | 12 ++++++++ 5 files changed, 78 insertions(+) diff --git a/application/api/answer/routes.py b/application/api/answer/routes.py index a809b4efc..ea8f67413 100644 --- a/application/api/answer/routes.py +++ b/application/api/answer/routes.py @@ -23,6 +23,7 @@ vectors_collection = db["vectors"] prompts_collection = db["prompts"] api_key_collection = db["api_keys"] +user_logs_collection = db["user_logs"] answer = Blueprint("answer", __name__) gpt_model = "" @@ -202,6 +203,19 @@ def complete_stream( # send data.type = "end" to indicate that the stream has ended as json data = json.dumps({"type": "id", "id": str(conversation_id)}) yield f"data: {data}\n\n" + + retriever_params = retriever.get_params() + user_logs_collection.insert_one({ + "action": "stream_answer", + "level": "info", + "user": "local", + "api_key": user_api_key, + "date": datetime.datetime.utcnow(), + "question": question, + "response": response_full, + "sources": source_log_docs, + "retriever_params": retriever_params + }) data = json.dumps({"type": "end"}) yield f"data: {data}\n\n" @@ -405,6 +419,18 @@ def api_answer(): result["conversation_id"] = save_conversation( conversation_id, question, response_full, source_log_docs, llm ) + retriever_params = retriever.get_params() + user_logs_collection.insert_one({ + "action": "api_answer", + "level": "info", + "user": "local", + "api_key": user_api_key, + "date": datetime.datetime.utcnow(), + "question": question, + "response": response_full, + "sources": source_log_docs, + "retriever_params": retriever_params + }) return result except Exception as e: @@ -460,6 +486,18 @@ def api_search(): ) docs = retriever.search() + retriever_params = retriever.get_params() + user_logs_collection.insert_one({ + "action": "api_search", + "level": "info", + "user": "local", + "api_key": user_api_key, + "date": datetime.datetime.utcnow(), + "question": question, + "sources": docs, + "retriever_params": retriever_params + }) + if data.get("isNoneDoc"): for doc in docs: doc["source"] = "None" diff --git a/application/retriever/base.py b/application/retriever/base.py index 4a37e810e..fd99dbddf 100644 --- a/application/retriever/base.py +++ b/application/retriever/base.py @@ -12,3 +12,7 @@ def gen(self, *args, **kwargs): @abstractmethod def search(self, *args, **kwargs): pass + + @abstractmethod + def get_params(self): + pass diff --git a/application/retriever/brave_search.py b/application/retriever/brave_search.py index 5d1e1566f..29666a578 100644 --- a/application/retriever/brave_search.py +++ b/application/retriever/brave_search.py @@ -101,3 +101,15 @@ def gen(self): def search(self): return self._get_data() + + def get_params(self): + return { + "question": self.question, + "source": self.source, + "chat_history": self.chat_history, + "prompt": self.prompt, + "chunks": self.chunks, + "token_limit": self.token_limit, + "gpt_model": self.gpt_model, + "user_api_key": self.user_api_key + } diff --git a/application/retriever/classic_rag.py b/application/retriever/classic_rag.py index 32f512349..888271883 100644 --- a/application/retriever/classic_rag.py +++ b/application/retriever/classic_rag.py @@ -120,3 +120,15 @@ def gen(self): def search(self): return self._get_data() + + def get_params(self): + return { + "question": self.question, + "source": self.vectorstore, + "chat_history": self.chat_history, + "prompt": self.prompt, + "chunks": self.chunks, + "token_limit": self.token_limit, + "gpt_model": self.gpt_model, + "user_api_key": self.user_api_key + } diff --git a/application/retriever/duckduck_search.py b/application/retriever/duckduck_search.py index 6d2965f5a..d746ecaa9 100644 --- a/application/retriever/duckduck_search.py +++ b/application/retriever/duckduck_search.py @@ -118,3 +118,15 @@ def gen(self): def search(self): return self._get_data() + + def get_params(self): + return { + "question": self.question, + "source": self.source, + "chat_history": self.chat_history, + "prompt": self.prompt, + "chunks": self.chunks, + "token_limit": self.token_limit, + "gpt_model": self.gpt_model, + "user_api_key": self.user_api_key + } From bea0bbfcdb6a059818b1f52dfe0114e3f91f13ff Mon Sep 17 00:00:00 2001 From: Siddhant Rai Date: Wed, 11 Sep 2024 17:45:54 +0530 Subject: [PATCH 4/6] feat: user logs section in settings --- application/api/answer/routes.py | 99 ++++++----- application/api/user/routes.py | 60 +++++++ frontend/public/fonts/IBMPlexMono-Medium.ttf | Bin 0 -> 134880 bytes frontend/src/api/endpoints.ts | 1 + frontend/src/api/services/userService.ts | 2 + frontend/src/assets/chevron-right.svg | 3 + frontend/src/components/CopyButton.tsx | 22 ++- frontend/src/index.css | 10 ++ frontend/src/locale/en.json | 3 + frontend/src/locale/es.json | 3 + frontend/src/locale/jp.json | 3 + frontend/src/locale/zh.json | 3 + frontend/src/settings/Logs.tsx | 175 +++++++++++++++++++ frontend/src/settings/index.tsx | 4 + frontend/src/settings/types/index.ts | 12 ++ 15 files changed, 349 insertions(+), 51 deletions(-) create mode 100644 frontend/public/fonts/IBMPlexMono-Medium.ttf create mode 100644 frontend/src/assets/chevron-right.svg create mode 100644 frontend/src/settings/Logs.tsx diff --git a/application/api/answer/routes.py b/application/api/answer/routes.py index ea8f67413..873a0ad7e 100644 --- a/application/api/answer/routes.py +++ b/application/api/answer/routes.py @@ -203,19 +203,21 @@ def complete_stream( # send data.type = "end" to indicate that the stream has ended as json data = json.dumps({"type": "id", "id": str(conversation_id)}) yield f"data: {data}\n\n" - + retriever_params = retriever.get_params() - user_logs_collection.insert_one({ - "action": "stream_answer", - "level": "info", - "user": "local", - "api_key": user_api_key, - "date": datetime.datetime.utcnow(), - "question": question, - "response": response_full, - "sources": source_log_docs, - "retriever_params": retriever_params - }) + user_logs_collection.insert_one( + { + "action": "stream_answer", + "level": "info", + "user": "local", + "api_key": user_api_key, + "question": question, + "response": response_full, + "sources": source_log_docs, + "retriever_params": retriever_params, + "timestamp": datetime.datetime.now(datetime.timezone.utc), + } + ) data = json.dumps({"type": "end"}) yield f"data: {data}\n\n" @@ -281,8 +283,9 @@ def stream(): else: retriever_name = source["active_docs"] - current_app.logger.info(f"/stream - request_data: {data}, source: {source}", - extra={"data": json.dumps({"request_data": data, "source": source})} + current_app.logger.info( + f"/stream - request_data: {data}, source: {source}", + extra={"data": json.dumps({"request_data": data, "source": source})}, ) prompt = get_prompt(prompt_id) @@ -319,8 +322,9 @@ def stream(): mimetype="text/event-stream", ) except Exception as e: - current_app.logger.error(f"/stream - error: {str(e)} - traceback: {traceback.format_exc()}", - extra={"error": str(e), "traceback": traceback.format_exc()} + current_app.logger.error( + f"/stream - error: {str(e)} - traceback: {traceback.format_exc()}", + extra={"error": str(e), "traceback": traceback.format_exc()}, ) message = e.args[0] status_code = 400 @@ -384,8 +388,9 @@ def api_answer(): prompt = get_prompt(prompt_id) - current_app.logger.info(f"/api/answer - request_data: {data}, source: {source}", - extra={"data": json.dumps({"request_data": data, "source": source})} + current_app.logger.info( + f"/api/answer - request_data: {data}, source: {source}", + extra={"data": json.dumps({"request_data": data, "source": source})}, ) retriever = RetrieverCreator.create_retriever( @@ -420,22 +425,25 @@ def api_answer(): conversation_id, question, response_full, source_log_docs, llm ) retriever_params = retriever.get_params() - user_logs_collection.insert_one({ - "action": "api_answer", - "level": "info", - "user": "local", - "api_key": user_api_key, - "date": datetime.datetime.utcnow(), - "question": question, - "response": response_full, - "sources": source_log_docs, - "retriever_params": retriever_params - }) + user_logs_collection.insert_one( + { + "action": "api_answer", + "level": "info", + "user": "local", + "api_key": user_api_key, + "question": question, + "response": response_full, + "sources": source_log_docs, + "retriever_params": retriever_params, + "timestamp": datetime.datetime.now(datetime.timezone.utc), + } + ) return result except Exception as e: - current_app.logger.error(f"/api/answer - error: {str(e)} - traceback: {traceback.format_exc()}", - extra={"error": str(e), "traceback": traceback.format_exc()} + current_app.logger.error( + f"/api/answer - error: {str(e)} - traceback: {traceback.format_exc()}", + extra={"error": str(e), "traceback": traceback.format_exc()}, ) return bad_request(500, str(e)) @@ -468,9 +476,10 @@ def api_search(): token_limit = data["token_limit"] else: token_limit = settings.DEFAULT_MAX_HISTORY - - current_app.logger.info(f"/api/answer - request_data: {data}, source: {source}", - extra={"data": json.dumps({"request_data": data, "source": source})} + + current_app.logger.info( + f"/api/answer - request_data: {data}, source: {source}", + extra={"data": json.dumps({"request_data": data, "source": source})}, ) retriever = RetrieverCreator.create_retriever( @@ -487,16 +496,18 @@ def api_search(): docs = retriever.search() retriever_params = retriever.get_params() - user_logs_collection.insert_one({ - "action": "api_search", - "level": "info", - "user": "local", - "api_key": user_api_key, - "date": datetime.datetime.utcnow(), - "question": question, - "sources": docs, - "retriever_params": retriever_params - }) + user_logs_collection.insert_one( + { + "action": "api_search", + "level": "info", + "user": "local", + "api_key": user_api_key, + "question": question, + "sources": docs, + "retriever_params": retriever_params, + "timestamp": datetime.datetime.now(datetime.timezone.utc), + } + ) if data.get("isNoneDoc"): for doc in docs: diff --git a/application/api/user/routes.py b/application/api/user/routes.py index dbe8a0b26..1b86135cc 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -26,6 +26,7 @@ api_key_collection = db["api_keys"] token_usage_collection = db["token_usage"] shared_conversations_collections = db["shared_conversations"] +user_logs_collection = db["user_logs"] user = Blueprint("user", __name__) @@ -1127,3 +1128,62 @@ def get_feedback_analytics(): return jsonify({"success": False, "error": str(err)}), 400 return jsonify({"success": True, "feedback": daily_feedback}), 200 + + +@user.route("/api/get_user_logs", methods=["POST"]) +def get_user_logs(): + data = request.get_json() + page = int(data.get("page", 1)) + api_key_id = data.get("api_key_id") + page_size = int(data.get("page_size", 10)) + skip = (page - 1) * page_size + + try: + api_key = ( + api_key_collection.find_one({"_id": ObjectId(api_key_id)})["key"] + if api_key_id + else None + ) + except Exception as err: + print(err) + return jsonify({"success": False, "error": str(err)}), 400 + + query = {} + if api_key: + query = {"api_key": api_key} + items_cursor = ( + user_logs_collection.find(query) + .sort("timestamp", -1) + .skip(skip) + .limit(page_size + 1) + ) + items = list(items_cursor) + + results = [] + for item in items[:page_size]: + results.append( + { + "id": str(item.get("_id")), + "action": item.get("action"), + "level": item.get("level"), + "user": item.get("user"), + "question": item.get("question"), + "sources": item.get("sources"), + "retriever_params": item.get("retriever_params"), + "timestamp": item.get("timestamp"), + } + ) + has_more = len(items) > page_size + + return ( + jsonify( + { + "success": True, + "logs": results, + "page": page, + "page_size": page_size, + "has_more": has_more, + } + ), + 200, + ) diff --git a/frontend/public/fonts/IBMPlexMono-Medium.ttf b/frontend/public/fonts/IBMPlexMono-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..39f178db700c34e524d0b9dc52bd020b44d70d01 GIT binary patch literal 134880 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%0DXk@Y++ZC(#=pPW-Uv!|o5~ewg=R?1wQQ z4tej{_dj`mH$CCI@}h~Fqh|HN66K|Kz!#wJ>$ zZ9M*IiP~swj5b$Wq;+Ylv`e+iwVSm&w7uGsxJPgp+lpm|Rfdc7@`JC2`GfQD%v8g4 zT-yLE2HrPx4NTiphUtdc6vsk)2Ih@FVc#~Nl|duVf=*b$E@I;_qf|o=Wub|c%$wnINJ(hfjtEWxpA`+J0#Kb>b=Uw02N~{7U{LKg^%z&+()DdA1(=r*HV@{7e3o5d1SY5SWA-g} zuwSvSxhHqwZrq)H&ck^eR)f)eB#&ViV_!C&SMeG?me1v*`8>Xi9pasAA79RT`9^k% zZ)TtHKeJEyNw4C&dAo%SOY%RGTU!_E99K7zl?v-#h64u7AI;_vZX=)gRFk{9w1c>(`` z=kvewO8yBi=O6Pj{v)5nzvFfMG#|r%;1l_`d;coILwi}h`)&a z;(&Nm92B>UJ4LU!MQj&)#3N$5m?16`4PvHPCoUG3h)YF>=oA}7yJ!_F#CmbJ=oVK% z=U*wNimSvlu}#btH;B37Rxw}PCKiYtVxhQ0ED}3Km$+B-i2KD#@qkz*9u$8P4~aEm zpSVaoCN_#2#a7WQT3{ufC^m_D@nUJabEPv*@?DP-yRK z(Epg-_F}x<%)bFAPiPt1Z-z@;ie0w5oH14!|6+W_wa#_7YoA-U+iCX__d_1;9CL+=V68ay`mn~+(zG6!ji*s!pg&@hqZ-W8nz>BPuRh*ABSfS-yY7wQ^T9W zFAd)n5gBn!#C;LZM~X;~$mg*dc-HiV`FiuQsFrA3^bIjlF)L% zmB+Qm9f~^@_e*?6d`tYJ@t=%XGvc)oUnRIFWGA#GY)ROk@I#_^;<&`ilH8J3B>gaQ z%gDau)ydzcT$J)gYDwznmJ-XUv>U7@>;Ckn^q(^3W_)j}v%R1BK~_(;Df^!6&vRDf z8gti;@*H*Xs4qwToG0>p@=bg%Wuk`pT8=9 zL;kh-x8~oMe<1(a{MYi|%l|C@hy1>R9R+&|4i-FLaJ=AD!Pf=96dDTs3QdJ03$qK$ zio%N$ifl#2MXwh9t?2K??IpbAx{@6wdrA(LJYRCWc1_tWWxL8ADSM{u)$+>n`tr@?+sf}Kf1td#{BZdj`&ZOf?)miHb>c-SfuA5!AsBTr=hPrF( zZmqko?m*qMb+6UESNB=n5A|*JJ@xDAx76QIe{cQ%`lsuU*1ubSy8g^WHqmQh&%|{T zw@kcYQu3snN#&C!Ogb^?(@Eb=`fak?8qz-KK;7sJErfMesKCr)8CwaYWmkRDreNsm^ov?jO8;fX&TX#(NxrQU(-)B`AqMb zOJ+Vd^VrPyXMR5O$C>A6dCm%+6+J6;)~H#%vkuRCqdB#CRC8r>ee=xb1A-5w)xoPT zS^ekLe_#Fm>T_${*95JZwr1Z&VHd?;wB@4T*1E0rTRUNG zSQoi2eqH*ymUWM=k6CY7pSOPW`d#ZES^vz%%Pzk7;&Yd{T;hL8_$3LK*e-eO(t=AL zx-9;(8#efDXx*@6!}}XP*;ux*cH{JoZ5w+wuHJb3@^P0>z5K76@;6m&ny~4vP4{m) zwCS16Nt>_Q{L7!&pWFYuXNzIW>McjE$hu<36>nVGbL9tDRa|xXRVS~$`0C@=jJamt zHJ@C&>DurAGW9RF{N?BCI^xt{i8Re-mvC|k8do!vFE0Q zo36O&mYeRs>4}?Oz3GFSzPah#cJJ-s+mpBFZy&pT#`ccwYqnpp{g&-7Z~yw{pqs02 zUU&1ZoBw+Axm)6IslDaWTMpjx>8*aZ=H1$M>&>?wyY+)xzq&2vw)We0-S)+fgdOcW z-n_l=_WIjfZ(n=+w%cF7!|RR-cWk}mqdSxDTzlv7JO8oMYiGjFik)+JUc7V1&L?-C z*m?G@;JY&Js=KS}t}E}_bJuHkeRp^0-6eO=z5B|$58VCk-DmF^eoy&5i|)Dlp2zO_ z=H4Oqj=Hz|-h1vne(%3_S$4JTx^mYOyMDMY;l76ZHs1I6eLwDw+TFPOn%zhCh&?%b zy7t_&=kNE&-rs)z{r7+RK-dHI4_xuU6Ayg;p#Ote4^Dq@?Ss1>eCxsUdo%aW-n(V* zb9=viXxKxw53PRa?uS0u=e@6J--dk$_I>%V_2Jfsw?6#g{($|Z`>)*p)+1q$T=K~7 zN8Wto+<~|Q#RsMxxaPop2cA9f{(&DK^?Ee=(Y#0NA6@q7_D7$5^pi(_KInch>|pZ2 zqJ#AZ=N(*maLd7+2OmH9=E1KIX@^1&r5zgc#QtYyym0h|lP~=9uu+KD_zx zp2M#l{`^Ic7e~H$#fx9PH1wsym#%&3$(KHS>Dao{`z1I5LL$CdKtl-%CV{adiI==PzkFQs~-t+q1umAi;!W-k?So+4UH{ScJ?_Z1m zy6sKZH}l`T=*_3!@_Xybw?2A1`|Ur!ee9jscjmwI^t=A=?zjJ8B|tlS54KH&s57&< z^2{vnf-^H;SOZ+-iHZ-72?KF{6^cVn6N`qmH33!_Ivb#~HS$$3VCUh1J+vQ=FNd;W zI8%s(9X1x{3rSApu(84(#T_%8vfIeeb;b?y4;(T3VN#kWp z241Z^7dg(?bZ$Y8^EHFVA;RKKMS69C;D0*mWF*%f~V87)~qR!dZnuo>jRx)j1mSAb@!; zdx^b_bIv0;8+w(!#*X2v^L6a||H|HEZ^0h*4o-{y#@=J^<3#iW*2m7{zzHWjB0x+M zlVuIa0H$vEZ^~f%rnL5#C(ltw~&iC9XIoS3S{> zE2y;R30UkrV2682yewW3N5oO_s(4Kt6UUuO;7nUQA)X{FfOtkcE1nb2ixuE8nrS608cB~8cDym%GdF5VDY>L8=%x4H2;wI7hy|df^2;K&qK%1U3n_`J9m%b zp2It%Uq;cF8eC{nP;0+`hxRu~`@6WlqP>Ib%NnFydmf`c20REsyI$Hyvdl?bU)4_G z`ik};t}o*}o=Q6EI)U_2?8>Rc`?AExxV`{vG+1bkXs;@&Nq3pRgDY|JO{d;;);$ll z!KttikY%s{_q{<~wo?xmp^r;gDQrKFqqcu&PiPn^+QZtX+TWQF?_u-`j8I^Nk&0F^ zVu3YAF7U+&2JRTiz#1bO_+ez90Nyz)7o!}QLf_G;KXI65fNqSk|4O&nwWVECYoBSK zYp1m@v@f-qwC^yN{LxI}iI%B-C!HvDd9c*wVNyR1m-;bW+pj&wB4G!6lEuq8 zXM~iO1ku1ufIL7tU_4-qic?-0V3bNzI1$hSm=0(FR0Bo=C_f88&r>}$04hHMKzSBG zARrkK4;ZV`^$2qTb%4nVQW1^@)B>oEae#$@Yyjok+p~vM-!MSA3Mn1{@B&b~RL&06 zekLFe5CI?>Z2)SA+O+}*&kVp;1ylx}rLyc>2hp8}^%eEa5CD~-dm#YI4+Bu2P)OwoFX|U6M{-5HA^Hi3um781bgGZ={NDmM zr@CAbCm=pu2-HW^CXK28DP2JqJUa?$XSk52VD!}qlxg`dfyU%?1q}!r0o6`GGEV7< z7XZmER{*$sKoAz=fb2U!j%maIz#wOw72O!yUhVe-M-vq+d8AzY~I~a?5 zc5r^?e>?7hb_i(PT?nMB{T0}C_J14F7`Hz|wkYoP2VlWHI}l!p z02py5>50hjThp5ivnuPjuY{N@FczxcmP%^ zpt@=R_Hj%h@s4;6IR6Vmd;2to?YyUua3>s6092ONG=vY89|0h|>_Bx=UpRvo(gega z>K9taQeF1;hzHar0ntZ1BNz*ydba{@1>6j{7H~D-CY9ca@J7J(fd91IohW+`;9dYd zPw72?U4REwcsIiTw45i({=bD#)L{nr0%%+j3cB}xP6McIK!2L*2iVgT+T&E-{=7Z>Z|}=Csa*y3dj9_!+Sf>S z9uS`Fu;K|qiaX;j%l#Mm&d>h4IMr#tC+nv^RzUfbX9r3XZ)G{vU-o{JJa>B5UdMkE za*WIKRPPDED>@#6AK>nXlW(#0D;A6mLfLj4)0J{NeoM4s8qw)DA;9kHH zKs&7XwE%c1oqrTC6|e;GGGHB`&Iu@e3*ag2z+p2!e;Z&2;0eGkKmnlo0+@t!J-{jK zJMEN5`s#l~2kIbP{wM9pegh49NY-!sJAWB`taRIvOxrJblM-)~!p>d}i%TVZv8&h^ z_!Jr7msNu&#=$SE7QS2Lxj}v#li|669RX}K)8K(M1Jr8-UD+wnj$*?S? zN{>w|d`C0zMnooTXxU_CQFfdF_(&OX<`M+!WiSiDiApHWN`}Mw63zU$E9}7!!^Y#! zcH>0G7uF{aSfLKU<7z*91m5y5z=HP_+r{oB><^#{Izrfq>AS_Y$u&ZDrH{*?> zt?W5?q8(!JO~Kn4*TSpIi+i&h;O})Q_knfyGMor)gU6L0-ntngy{m%YRTYd=gkkWg z3WG;gIQ*$1xrv+E)9e}64)3ODc>ONqF>F5kYvOo3Tf|4O4tQH7!n0{4yrELyOJjjw zjTL@38SuNwgwIVjysvWMeU%4qs{(jl6~S|=gqOnKs+?EA`)V}X!K+vwOh%pXyQ;=n z2FIHxUGOp+2kX@m_?*?U#qh+cgD2KRcxX+Ak4^)8bfz-$(3uWDt0wqa&En1QCz^v( zhaPyRkuUvnK94Qst-Ou5^Z9%MJhnREFW1St;4{|^&!bP^KerSoJ8##Z62isNr zYJLsd1V7uq@aylIS;`f?qT@g?Po8;`|SYEL}v3x+5PZ;JH&h09R3)4 z06uR|z{l??`1m~o@3ZG{7W4wUnjgmL#(6du9#AjA7w#4I1#5xt*-@NuyaLIA;W_s`&VO3@5A3h-q&vfZV%PAW*@N(>`USpJXWXUzFGIe{(mE0 zM%*rv#7L2hH-uA#MWhL7>l=u$BSAqLDUJHN6}f9m;&#+MtIjvlfHOO@Wh)Xn#F7}N6Zy1@P=y@ zZScmM4_~{5q640Go$$07 zBRuRj!SC+R@B+L7etcI+pTKM35qKSZ^0vYA?gn`0-2{KYo8c9BE4=!4z(?>7cnR)= zC*Ixg#Jd-sc=y2zZx6it9)M@xUU>HHgE!uO_z&tng@@o#_!xZoe&@95_s*!CPn6jA z><9Lv{aoqyPNU$jN}h42#TNs-}UHX4ay#Xe~yI)#9{xc)BORyEsW3sU>SETB>Hz(lo1<4u3nFmZ@cF*;ZldH8FZLBs<8?V)B6SO+5UYn>*(k5$Dv<9tF zo2pIIrfV~_CT*rROKa9_v734EY0(>7=u;R(G-+pPUr+oD~eU8!BAU9DZC zU90^?yH4AxZPTvTZqRPjZql~HU-}m9R_!)zhju$Wr|;BuYIkXOYxijPYP+=iwB6bs z?SAb6?LqidKZKKrhhd9|l}^hv zlrQb>q)bbBdWHT_aheJn{13W*X`Ux`AlMAcWK z>MK$8m8g13DvZsuyIYpEQ1zCyQdw!%T%UTB9N>?dGk3FSRrKdb4ZF=0# zL8qSK)!fs*aPGi5D^x8y;aLMx3Kbo(5_{`OjY^nji-#Q8Bj z%g^z!o?&R4-Q4Zj=4i{ZxB@3IEw8=(XI@>zC z7wMFiDN4&#hm@&$%2dads}3u(hpOYt?B&%kEz2^(4-2E**sW90?L>ibiM-{x#EDZ& zobF3jre#(5wYGG3fL*f}s$pvAL2ns*WQ{t%(r8*I1#m412 zvCAFAx-YkDALDYVX*^dtZtDF|VKc6jw^UM%mx>(YDp{S!s{T=6EOsK?-tWaqa*M65 z#fsEoom7wF3pKD(+9i?%Xk7KEJ|`fpcv)-@C27#Os(i5|8R=rz;&PqVQU^I6r4B^P zWXBoHFd;Pe7|SKv#&VhIQQlunr&ynP(0Z|Jxgwx^fJRYRlql>=6*i^zA!{E95^GCZ zc7~GsJn$*E)M?qtiGjr|QOU{BAWZAA&3b!hy4ytr@P&LMLjH42- zYNytWHIhfh8hu>UINdVT01@Lj{r))p{y3-mS~+BNyc0t9QCO;&QCjXjet?*d*U|GF z?=;%SJEXfz5mK(0rAuvEd6{BrxnibW59-p5`}XomB+4?4bvo8{I@WbgSR3ngp{RFa zNWIg2y}xtH4HIc_Pm~oTZTD*COeeXDBU zs@k`z_N|J3tD@hk+PA9q^&NpF!>ZcX=UPifS(+{tWlpO@%5|0zCAt|lg zINB6VHg#W#dWKEWqOSui8KsKW97T)1F0f?eC|XJtj{3xF$*?IJ^z{VNinbg@YmU0F z?-+1j)q|ablz5xsjlTT1WR$CRv($Zkezs&3tNPS@oT1OxmW&E}J*p4&?S>^o-)10G z_bU`1^lb?86&@8TwAuNj=(DLlwJCgTs!wf-KQ@IA7Q~W{GF6YRInhgs-(`xw7KKNS z%FnU$*?wQ;W3Q>#r`osJ`$zFrUr?hz)O}r(Bdze$r$_M7Ua!4>RC^YC|0urasI5{bMh0?;llPxoS5{-Pctt`bU*hDm6n_x#%BzJ@)=lp}q(~KdAc^s(yWuf_z0! zg$gaIe=MqhEO!2={;}B0+1s=CkHSlz*{qf<$!CPJJ%qAegt9(_vOa{eK7^7VR!g>e zE?f2w(y|?dvR@IZ`m$Ah*{Z&5RbRHMFFVIw+79S#1vhM%Q)HxPd8dqc^t53MA*1f| zI+u2NZ9RIyMeV9Wy$yP6Lbf3z)CNUV8x+;s&|}nw zEJtm~a(Wvws<$Bv$~GuQatb4dPJf!NN08R(Pt(T_(mMTV`uIUw=VzKe?vd8{nx>B@ zq@_F}lyZwu$_qlB?^()flBLff)--)QA=K}u>3R=oy?@g5ae}nML)T+S>-~|Y%b_(* z8AwX>ImcR}%bB%ApNo)IcqoHPnyy!@Y5KAhq2B*#IrdQTL6>LLuiDqm8Cdy9xkSD_ zl=5n|S#*BbbUlEyPKQm`A4u!<+w}2(v@T~hi(bD?9|y?S`E0Z3_1i4=`gQ)=EINN} ziY}X?*QV&TDL&Zrxz}n_e7EUyFVZ@FHhu0zTD7mwy-2I}^|=>m)xJLWBCXoj=U$}c zxVGB#xfh|{pEiB&MOv3Ln?CmNb1%}WePxZa>2t5u zmah1puG&}DJDalJ+4Qv+%BlACwHMN=eSPhPv}#{pdm*jb*VkT1tM>J^7t)G;eeGqn z>1!{9s{i%17t*T#^|cq$s{i%17t*?1+w`>;(yIUUwHMN=|Mj&O(z;yR^tG4OrpBX9 zjW?S<-yvV|UyXB{8s|1O&TVR(+tfI>sc~*oO2651 z6#tcewJH5-Q+m~=^sFsMwXd%SVfRz*>+3?KRr@&=o^t!9uWdc#ZZL&jQ*<9-&9GS2 zg3DSWE3%}kg;ly*D5dL)5cyIN5K6)k$__;+1q`7SEQI!Yq#z+J5k@E(Xh~O_k94)! zNLQPPbhX$|SDS)#wFyYKX6wl78$C&f+zPtOXj^CJ0{y$fDidQ|m0n)Ovth`>xjL&||H^w~VTa)B z;#bgkkns~pwLM?=_;nad`(v>n?^(F`5;Och>?k4VMdf|}b%0gs)-l>OB zJ4c=Y_hNJ^QV+5al=Vkh!uk-o%AB#JnWN(3qkVXER2(M8QQVdh9c6TlFW~9cp+WxX zL9y1%eB2D@fkXM%si`h5zT@-9H+8}^Z7m2Y9@}LWmZdkYDRrk_4pC( z@g`ehep6%Lxyfagxa!Kl=x9)%4(i=O{SctGz85b+!{tX}7C;>Ar{0%{^>w1&gEBmv zG7OY~@8kFLX9#5s(K9?iScGH;y|oshla9Oq#Uc+DfILfDY(LE)G&tRs?jId*EfD!U zBSnA%lHQw*!8JM47A~BYQxk0L=vW$(XbK20C59A~mNul?F5j@>a$9P{x!=Zmg+zz! z0eICEPAtS{P6ZneT4V7R+3(Wo)lVxY#PRl8KP5)WFgnrdMHyaBwE9zqzn197|`k~rMk2im_;%Z8nus>L7nG3e5kJ+;us>u(IqNH*fI(P^@?i> zBu{n)ri!d!AI=MNrxlvbh0}6sn?ok~%7MC*vElM@|I}g^IPv+z z9S*O!EMq%(jd;o;9keC@Q*e zYF1`Lfw}L>no?`!(6Z144dH3gfm|y`ug*cQR>sAaHRUAu=Z{T_$xANaF9*h2BV0Z5 zt4RJxdDB=6WZi@?+yQ}yUH;suPu-pRRCS?4xdu{+KpIe_)=4)~r)e_gj+mFqBMptO z>w}9rzb7Bad3Vg`|M2;L)9C=)rQvQQ=_jXdUkrdY#dSHd`oC8Jn{ zq(2O?v528pNr%uF&CIf;V7gENjo4s2A0l{Apbyk&ybmHnM{(;=*NnI%ZqyT*nqB$G zu`O~pY$~0WGb}YJG%_P0H6tQxtR*ukD!OS!R1C$VB8J4IMD?^ypO={Ra9mo>go5Hd z*yCcGWzDXw@i(P}C#HIOc*KOq#e296t<6p-O%3(;j~FpIB)BpaiD5>Ukf0E+@s**G zLq>!r#`1~Q#IQuGyy)6tq@0(+;cvRJ7n6sDrAdZFpf|MsUKmb19`3~SV9E$~%5YZA za+-E@3&|jfRxsF@Ao=c%&|fy=k34T3 z_t;cF?*X#7VT%&MkNSo3&EFSAuWp-y60R9nLjJ0!60m=a59vWXB!cWAO;wt#+H z0xr!U->%69trgrz{@k48ZzyHpXD<5ngk$m0i&+rg`$u(a4l1 z0nYLGqJ%fFov#nk zllVY{XMg%7pHf_Ql!qyFyTtH@bC2d$X^9SOX;vdt%ODHh#8m?v%zd3?L5(WMy3vKY z-o;54)V$}A1#c>Wj}H|zk}S9|Z%MKM$)4aU$v`1bcMXV+cMXbe=x~{E&HC3*U$TER z^?PysmLq*H{#uIB&*9I{gb(m?XO;A$3LebU2`|Mn2VM>|2J?(miP1iV%08^#y_<*Z z?#piw84c%p@HQLX1HdoQU^Np%n#e=#7%mtQ4w+K>Bu9o15$)r|7UxxtRMASSJNOhp zj|Av^qOWbZf;@~FEj5G?7|wk3v#lvCP-ndE2!BZKtCcCcD=`(spLp>4#d zvPVYFaPdk`Oh`5we5|Q?sWU>uL!)O53lEDHhEj7%a(r~!#0h;{`Hakj=;*%9_7MEg zHH4q8A^b1U5Q^Cj@|DVPWDFu-F-S@UmOS`;iJe;fMvxkNw527QE>IB{Y6xLy_&xoQ zh24P}Js5yDBCtPTSQ|#85gN?)e*A;AD6PzB-lUyEnB=eXqLLOF@dMTpd}PkF!l)>0 z5bR-IWn~_qe|uoxCQPovryHx}1><4-`E0Bq^}Xqu3-_ju{hH#RgQ0`=Yf8__`A!O$ zLkHXUpyGG-YWQV`{=J$KA52nmujU^eU?=fE*sB@(H>Q23c+dr#HQEVf|KHt%OTry{ zaE2Alg*zdif438&J@|!78~&f|!83SJ|DIIegmbJO7~V24VBW*NAOJ&#RvdPj8>HP; zKRLSAF`Hhfx_aeDfM{zC#b6{wKpSyiEJXD6QGi{k$x%Za1KPx5j;h(T>1U&B=!o3- zf_Zs0kM)hGy}_n1Yiw{^W!^KS??z#?_(2Xw(h7iKX`bSQ^Ptt1^KhnBk!}X^MM&eA zkf9lsR{8XMKcvaX=%gEoXltmOhG_K^7oQaYi%+Hv){j)4qG2K5zX8XhJ(5RgkVl?8 zab;|2)TCTnvDeUWzq*Kmw8)inMz=@j2ad__NHx_(<|D%^GdV9dJ|@m@sE?cQHViRm zj2K^+6ju}+Uv3^|@OJS-#<)hBOGcoF`~-E*T-v$eRW2uLJxLLJvWWiG%%HL6Og@=C z@S!pEfDzLQ?bKWl+V`lmgpigl8|~I;IH7CgUX53m72UQtJY?R96DO*2c|t>Q#QFpJ zPK`?|4&2)zI_>nUXam<{gS3{~NIE1)A?%o29BUrxTn}ZjVOZS~`n2Mab}5Z)aWD%6 zremE?k!XJX1*@?RrE7Uj-}6|DnS*)Lx!=H13ph%@2<6VosM80|&V#0dK`TgSj=DgK z)3x+SP74F=i$jYmOO2(K9fySBy<^ATldT1!wKBBk%T|%~(NAt@>?3TDt{gPv1~sot ze-4Hz8IT#Yo)qX;LF__ER#|VkbO}h;|L}$h{sL!M-N(+aaZhg?y`kK_VuLXBjpO?W z&i%%(?xSB_N(SEads_Z%IrZxhX%e*KElBQ!)0$xrn$BFhK*#q&2=S(*9BDpSoRgBE zWs-|rH|R!U4`OUUyy7gDT&X*;*BwqxO!Sx4wSe-UGBUdgn1pq)oyGd zF5wGgl3(&y$`AveY}{-!2f=tWmgn}p&Uf`4=4G>ZO~b6d{rExvR0hNQVZG34o}@#- zaJ8^?13fPND-5+fcIXm?m_yPWeM-wbeBTkxW7z?dh>&`nv-$4+zSWLRxZ%!8l*P9W@YP5;z4R9 zfmr?bGu!jh^T!O&s2bUK=F9B7yzJEraUyG5D0*j4${+3P;xm49(r8=wd~G zTrIjBlnnWd%!C#3Iy>J7QC5Dm5xY5ly4C4b#m|>>H9F!^ZQD$LX#v z{O9R?uZrHjcX-0N3KYgS4)7K*(SY%CAq|7HsAFu?DHgHaMM^8h+@Rv=F%W<@^>NAl zrPaBAF^k0>|0Xi0Hf?%`-+XsCOlAEDT6yxAjN#KK3{A=%mCvtj0tVwi^;lpaZ7ptC z5jfjg2KAhp!yG6L>N#gy3w_I3?>SnSavTtISXihNXjv5y&4*0v>T;R1d;MqMuH(h+ z?*hSnJ@4U1hZd4d;ez+9X;opPa{~N$jgzkZgE32|0lHQoy|A#07hrPQNwj98MiXlE zV~eRqe>TLa#zCYhN*uHfP>pmPt`5zl*wP|`mS{BjYB&-ngPIG{bgV&ECY}}T$)kfw z3TefLarcDgwq=W(HfPfU*M;ln@NsYS-N;+t=-US_e8qzxlEBz8u9`_O{};!VzKCSN z&=VNC0>c0~M93QEAN{cV?5ke|h+*eG2lqZl2z&$^35_-q8twmV(QvTMd87nNsnsnS z`V444@r)3^XVEBWE{upMY%Z~f)ddCB0I#&^8zxWMI6W9nKbhYa4sJ58w2@GBFnNXeQE`**=J;B^>E4njATH%Hp zon%W-O7rygZyjT)&8F{@4}-uJqGw!LKFwpgCy$-RLGzgEAlV=K3?|oBq_Lo@aKI46 z=XSl1;WYi+PBFbnuBOVt#Q?N0j8)Log-_2oad8-}T825>S_^Hw^oZ zU(RDt3;vI|gjf<2Eg>#l{?=(#Rnx5gP>uCT6=|WNX%$KQ{=U}g#no|fxZoQn4!Zwn z|L^EqWiNMRI651u5#(e49}t|L6_Bod_`=HR_??~^JujgB9p8MzXUX+~ozHS7P3BW_ zuCeoZkZu`d(s6E2?xY>|Xx)@%fK1`50!qq^*!t79pVk&MoBH*mNYe?SNfv-=>+E5k zZsR-0yLpB-S+`ZXRRZPt5Az*;vmWN#&QNv zV^r(*HX6jF6!4qzGDe$IeI-EiBRLP4^{sz&K);zqRID98##mFg{Oy<5R*rTVJ!bhU zn=id|Gk)o9;+pmA*YxcHFT8+v4e<74Q)yu^a6TNQYX+?|9Xxj!E1*KbTH2M;K35tj z{QGh8KXZCb<7i`5uEeTxOK7Z&YSnbXZt4V9;bfeetXnvlfTL{b7Y=Y*zRi!vlu~ z4wsh!bcbUug$;}K&!u*{BmNV*b&B=%2-y!Jq*f0_N0Q%MFL+>&#gL^%=8+YGHqnFG zpvk>^Fup*gAFjtkvGYO1k1Nr*n_pfk!oFYp@&*wuRxF;qG&q2p;}g$)jOft7zLW8Z z^cyOww}p{T1tEQ8#k0wAFm3Q&f-Q4WZp5(U zg4kWWeZh;C$rjJ7Q3aM*bG~JGWM*}8Vr6k&#FfOkILuu`fpZqE!~E!@YEC$-1-)aO z#L{FIt8c;mfFrE}$ZyC`ZMw;CC`(CB7R!`;j77N=f@qyKL}Jz&S%uEbVpX z!*L?!FSBR&-TRI4@>%?izPN_VPvI+QT>1q^1FI2a9SVcFBhjfp;)(t7PHPG09alW~ zOw!y7Yn-1lFh&6@>^dRg)LUdbL@$_Kp8IK7}7zwRR+jR#djWu8AOBo{x- zp{Winl$GwIxduBV?1Ghk%Bbl@Q7L1lj@*_vtJ31_>KQR2!<<}`9T{CbJ*TLmqNuQ< z!Yi(FZeh)at|{S3wG(ILh@jZvei1pf){H3y(NoI{JqpX^g+|aQ$kA|6=tf=;SY1lj zPW!0;1L@J{JTfVh@Sze(hXHh?!^TZ3O>Gy~`o3%CAN?j~tp9NKxqG4S$%dAI@fwab zdk(ve^gTX;C?Abb^W4aOeLrZS;*ec^f0Qm|L)Z7jW12g44J4hhP9o!5fFQHdQ;5K< zq_K4rA(K+A%sB)dOwYO@Bp7UluGA~VN~teEKU7EqTrVfO3^d36D9{u3)kj>(K{%(VsC za^;0r*(}}`yqJXxxe1b}`y`P= zTfF3h7joj|T`%+{hh}q)F3_^#ePGdc?CxFr#~Znqi|BtNkJ-61ilTmjKo&wU`2k2| z!|=fp31QvtB$PM{fs|B&$_#wF@&IkcideBkiMC?d{ZtOp@6Pfe7vUDtI2K*ulbF~f ziKJlkwH2wAVKOg=Hd?a#-JpPI7NG-Xy<9JZ)lUuHB(O|(dra$pA_XPM&$1Wuj~;{D zlyWR3zfygD%V5@M5AYYY*d#2_PF6BO_d2bZL#rV65|R}h6S1u zf)XR+Q)69yhnwQVn(AX`B~D7LOx!j%J-;z5{Hd_wSoic?2ouXV53>+>ZNV2q*U?hE zkQGT@lOZXOcwK}AwdAJrrl$yZ`3c%`c&hULC>DJD5Po=;hEo7SvLMhBAXs`<9mH?C zl6i^_cSxm^`6X4x!V$|x3 zK?8HZ?wE|P1H9MptSgMJL8yn$x1zBG2_Ar0`X8j)V0mG z+Bv8eSUrOL4gan_#c^35tn<;_Ix%xQJRskuC*Q!6ciNwHrpM{6FvpW_+t*B#PXd<$ z-YZ-rE_Is-m%6n;1^LsEA0_kaWqv*7|JR(WKi?@r@k>K-Z1Ps(++=*Im}=S0Quz)1 z8?*=hyWj?GaiEzr(oEogY`iTi*fp3hKYG->WlQRwd3&n1ZNqa<t&17F&fROTT+2>J1>@%(CFW@L?R zq(k35KZdWvH)dU+_N-*)(NBJK(++-vPxY}nf`D5MEo(jCB&@;F7P4)F(I-e7Ak*I^ z#3kgV#Kolm4jkd4ukVP6iwh44*DrdjKciJ*NC2H*4{X)3HXUR~8F=nQXyBI&fkv!m zOApdq2cW@+C9FsYPqeQLxqpAORpvWll#C*($& zGD@S{Cp3*p%P8`i=^mRk)~v98c6fT#$m9yE$=I-68&&9IwTw!RGv$s96ILfe(2Eu4 ze}-%ymXu7Onaxg#KXi~z4N;=p860b?a3U>SUu4i55V}Q(Cbbwz)^N$)EZgtyeqDnG zGW=sF5^Ah=!mNsxlMV_ZM~w_~Q1*orDUkc~%lJ9*3s$liFJ72`mA@dF#)d%d1F$5a zU51=VgQHWlr_DJvi5s`s?DC-!2*_K9e=gMNMZ=Q$FpI=;P{zx(13=SVf|aDAYky zVU$`b`l!`)FE)+-%4>>*o8I`a&$Q@=0;~|pO$lFPh>K6rX!@0JV8MnSOy>#8{l(}? z{gocX%WP>L{(f?66g_RF(szE8eP*9O_m^!z>x63q5-t#5Ul}f zf5r~79MEb4KF33_(jqgG0Z`yS4d>MPUncE zzyVYSCXLW(tp9&Vdk^@ysw;nd-V}|xG?GST)MhkIy^VSsHA}K(*|IFj!bL8)gRo41 z5C{Q6C`kyAvLR(zSOUpz3J^jF*db)|+YquTkZjr#hq58Pk|o(}g2w;v_ul);Xl%0k z`5Q*>&3$j)yZ794&OQB}y#loUXSt#~%oSZ?888Yp=eKLy_9q?$I=%K5;8M~r3<(hU zC@=u?iC<{*s!VT*enezW)C4^I zGF4`56)GMd2A9i##KmKtI##M<7a3;_8=!kp2esjf z412KwbCl%?m?LwWYw@5O(WzQMw?d_+SK(NLdzyh=kJ)N*Ms*I{Oyqk@?R=e5px^_k zR)ztCnv(DM*oN)fH}De(g#!3!D?9JH;K2+2ey8@)^;dT9Sz6k|&v;9HYkiCOqxl59 zv(IsfFt-8bO9srlMV91GPXX|HmSjI*O_=jYnDbb!j>FIHP zs{H;cd^)wS6eW5;J~}-;>hJWVc{=>&DX;VMD|OWFn4O-Ugl;KGTqGQ6wn+Y3Mehk~-)H zH~BX09D1s|;SLweS5)39lW6HCFB#IbSE_nuT3Yu-`^w^xSbux##QaomS8X`8wWqr_ zJlxW|AtOKCSE(Nzk)g(tir|KlvW9RVnp0G=t*v>gIJ0a+U1X@C^jsId7i!Hfu%|hS z+Nz8*N(sTU7umDHr9HL|{xQ6#lxGA)P3tamBZTMyRz#_g#__Oj77NhB4(qtHW!q3N zIJB)rOW!@a`%g6k+uPc<57fN&Y5V`I(bs3=UZD1W0@2k$(?CQm-eQ{&v#I;M>N%sC zd_M@W$mRrLG0LV|oE724_2@t~j;&|tvCwe69@mFNp^Of+42j2e0J}zvZzEVSjLs_O zi=|M6>GHFtOeEDTXpFCHYiZfG64#Hh9q~{oz9XiNeOoTrwYh85Gny6f1#6?>%3$`0 zXG1~r;#D)7uU>3!Uc7qq%vFoc-I1}Kv4Pmmv50Z9^U%V=q4wHfX+={w9%`y82}L?c zVwY<>wI2iT39D|fqE`ql-5RJvtLs^64OYvbb;hb~S>SCA03MxvZw3=&*I~xNjM)T+NC4zq^k~EjazwzxBOn7xX=$;1sh;pK7}c2P*$)aW&yXNVHh9# z$;WXUU*D)+|Cp^2Gdd*N;yB8pDRH!Qi?$fX*e&L+0R!p;dTBa(oqxud2qNO7X-d?? zXSBx1SbIf9JAvF*S=lxgscZ{*y&;~9qrG{_?=SQ;#6rGmpRZcmS1{7T%jr9 zqy!~c!}AzqX9I#WwI4zh*QjUZ3^QDUGtF!mhU69F$Q-4@PQZXw3p_Cdc~FG!4!Wbk z67TZX7q_l%9&`>(_Bowfw>q8L-9PK-_?cFI_0@@=T)N}VJ9n^}&5hUx=s93n6?E7S zF%n$Y{7Uf-Va2M_d4bAY!bpG%&*B#XDbDIA*!^6#42u)FT2_kH(t*`jI-?`%47eC+ zDWw~dt8Wh8TPdbK2QQQLZMdT5D8eTyoVUIWHG`-gaSpqv_9=gj-I>+h)YU&bJvLR- znA4g+)i*v9Jr#;pl?U6k<*M?EaCd80XUWD2Z(T+G*zo$u|F0GNDN z$u7&JIvE#)3^5^(=D86#+elY`#tbL3{4IH<&KpPI^~9+T^` zljOQSoSlmfkzyv2VPSl3jEuCFm$!~Y!Xu=gk#IvtM*}|E0XFIvd7SBMXz1gIE6*A1 zA$7IT4~oF=#ag56faqmwTRG(WGyMnqao6U=BarE@!rAlKTPI?>LRbQ?P?o?5b}ntl zZbi_ysDS3#=n2LXsf~9>@m~q3gBCJ(^wF6sPgk7UcdFv_l`~hgcHSFbj6eM}{@mMn zFMBkKyqscK21P`U4Ku~$YQ1mDiDc$PtAJuNPfUSSq)sv&Mg}+1BkcyI(yh#h9E8n6 z=aYGBYo|{ypFaJx{i?YaUz}UJ$=ZiPR(;Vh5!PHf#W>79sPKq$^+8Hqy1JzIhQSgt zzmCC6v{f@wC>KZelTtYQS-^ght62I<4uk%R9;44M&S_KZb>gwP7fCVj1YlPux=>1N zUp+;;h^TtNQW=!;bttC9bEHJnnc?yZDXiIcFc~U$ii1nd-4G}q#76|lui}K}G@H=B zY4Bi5JEcrPoK6y_%XSCW)qC)<<4z_|nS=LW@MOLPIFikJ0fcVAKzyiY#@v8`_yljh z>g`2W-W=X_^)9V@S4e$%X8yAIXOKE{N}OQw!dez~#^U+_n>W*}``54dI<}bGl6bgQyE*YD{ICf-aW!Zk!}79`DzS=DJPm~fvAj@isK%hr za7blP@4`!v(3-)7Rx%u^RTkq;2K9mM7PpjA7NXSwE?ZS24AvHDU(~eQ!hL0>p~A|V ztl9jTVe6OE(V9SXGM<0*m~ld!9m3x7K2TtmElTdA=;k!A0x|QVe}pTEfOQH;1l>d~ zNd(@%KJmnXC!QGk$^~D+|9qO}vir1U{Bpgw33=^!8W+5PcL=+yxh}W>?-&B+`Rph0 z_B@%HF7p=!uRe{ZWZ)huf(XGy*Q6o{d=F6OHy7R{`46>7{mxrIrX%Bq0|z#ef(U`v80X^kHq`TM^g`LOu=ihhEx==qSX9wY0; z8Ow3O%I!2bJE@fc?>r*=jJ`Mt9f`YHkc?q10d@;=#X2eGS5l(L)Z<@r#+26x@rB_?ToT#kP*zDohIYK4bBG8BZPU-qwElY zj29s$T<{$+t4+2y0_T5)e#dO@CDwNcuQffY@(fQ#Yzh4K#Q!M40ih}F95g@` zpW)Z%rv}>X9<%xI+aR7v5f1bdB>njC+a|$JTJa_ZSc86ANk0@v&P_jn?|H6hAE@rC z^0gF~2Xba}r~CeQU$p6>yc}=XU)?)poP5VPGfpHO_5xq(LGg%=$kW#jdN|1!NCP@G zh#5e9`W{J=wlkuE{8Ks|GXP6EAGtt~w{U?V4Re7Y4Re8rGv^A-F@XB8k}(F{S9D!+ z_t<}G`CZH6B=P&M-UAn0eZc|o2ROQ5%f?=Xi|CQ?WhstG4rv!wfbPz)*M5pEt{+9+Xl@X`3A9RO1!=3o)f;Np`K9HEZf## zTO)l%l2#SlG}b1ye#3qwS4$>51-u^i2M=eL9@i!lbJ_z-OWN^46?Q4gBCv zbHv+X?BM0+@>@S|^;>J$yYy4qXRr?@N~@7eD@~tk2E5wFQ)IXXZPQxgromi)M>yQ! z&mH{KXnR#zeRV-Wb$wY?`zR?tgg+fv-CP(qY~^}J55|mfzm5ma1hd2?ngF@pGP4Mo zRIx$_xMR>=l6da9-OoMOaMxXFcipA!(e@-hlK9Bp1pWagL$+~yCe{d-go(`DSpK)V@8T++2%|v(AQ(7rI{Q%MN6}GTxw^MsgUik>mUbY z*)x3o*H`cPr}nY5$s_x}p?%_lhtHnXj>;S;KP1d5NSHayk;|a83YMbxKxq@Ebf?rp zk?2jSh6pRA7Kmptk93uI=tb~rGV;Fy^8k4VZOn0(k#kUFRgFbv)&y`BB(6(UN%3ReKo}+WD0_MD z^-eWS@AWqoG!JZU89Fo-jZPgJ;xTbhD=L|2YMLmyuq-swUl!UtmsV7ZLL`6%5aoQ17*ib z(bkBEQaM+CJA`eau57nQR`+E1uNwwWro2e^G^}RQ+pA4%Th6M?YA$UrDsAi!&1|da z+J;g{h1KQ0-1(ev+i<5Z*gaUCy=O~qdVza7H;}6pcFnXF?_OwIT9|LD?{3ca)s{C0 zy6Z~p|IpZbrt8E|ZX?(Z#ULAYj{Ooavjp={$Lfm(MidUM!gin~k3n^e7;rG*q8D5MY#52w@#uE|}zlw9ycdrNVegi!{xc6d!b@U-QJE z>caBPoJx-wW=+a)LqyhIT*r8GyJ2y!Iz)!!)M#~v~78DMjaG}EJy6BumDH0GH{3j%1zAQ3yMXK{%|v{t+)haPp^1#-9CFD7{8#q zZ+E<=s(W+uz)V@1|GtwStKT`@_D5CsN%L&HXtE|ezJH)^c{J2j@7Q^3Z`su0TM{o! zY#7-vA^u>Daa$qc`9209H`z{tujUKUJYR4nOa%JOphp?&X~()JV?8O+xYR^}4}z+5 zVchf!B9b9pL@&LCXypaBt1^Di0+Z!fy}~t7NcPev0Qnw)WR&rx8ynV#b>26vF;+ji zWztt(?wi~)S|3aP*4Ee8hR>y*)ut<}x5r}JtFLTY^(23r9~>AM7!-dn_DyGRL;c<> zz=LaSZ(#RiMF(2ppavNDZz>`Rnax~=WZ5qh5YdZLgPGtPgBDrglJcL_EXvf;hXv53 zml2JjVg=(pqaJVxRQq*%9dLMi;QrQfmk%Fao{w+y&Sf?FgY9`U#S5dKXlZI{x%7D6 zyDqupU3tfkrVqC^R@S-`ce?BSEv+NzN41Tysch|F_Ee105NmHUhB{<>m{N?aJ-u!P zO|fzTcTa;;xF7*YZCSc9AoRANNf_;()KB*6C%5Y-NR$zRaS?`J2q%O1S&97FUOZO) zUY*=7T4agPQsJB+{mUBNx+_)`$q);Gu-me>r3&uRrrvhTEpKa^>I~=ZC>rP<-IVpx zPk;JS)~3Im;%wi)o zl~NF^wLVr#jM4g)suXeelC9Q@2z=V#nQ8%iMPDgg1L@IMYA^vxm54s-?1(}>(*u$% zj=B;|fy`WWLlB>;uXP;WoLy8CU+Ug+ad3-oEY>?-koAT8G%c^BGFLlxg=eJ6<5~2N zM4M-Ns`_Iiq56T5V1MPJJKJ5^94b$D9|Y_g&facsaWqh8gvj+Vswtf+rG}lNCz;1~ zgDPKV%#`>zUarxNV!VvjmuocLaD~v-c%(z95l6bv<-^J3i)`%zGL+TAxWZ!unr;Ku zbVgR3!d#%X4bDKppjk*PC7p=G!Sck&GeyZ>#H)y4oFGZLGYvUDQRwVi_Ut9WshZ8L zCG8W9MaTEekM0X@ERENfw@)+`U374MUrW~^tqdCjb!DXuwQi_ua{a`a5$cGC77k?| zIpXeU8m*4FzJsRNaA@(8>|^%J>RYm(&Q2U_YRURudJi#0w<{L1?npX@W3`8x_IC`7 zYq{zAQVr%xts4eeeSwBBqOy+I^sLa5l8vLT<`Pgu^zImKRY;)4e-25mfw4y(x#lx( z{HXh*mwo1%M;=Ku@A%LT?b5_;{NNOPW((#&iuo_cN<{gj#rEk_Sa0=;#PxVKr-{c3 zb@Z#FDXFAXtgsfSV0YPV4x&zA1x+e45x*K#Af-9ljcV0ki(?B0?m_^%M;Ph!w0lV=4Vt4f=(P?t!;E(CD(*iKWU?zQ!aK~STvMHhMOG-&5_Gi6+$4oV}n0!FZoDtM47H;J}v zNHw(V@IL4@xW53ady#6D1rA7Ul$@G`l*247{W&zBhj4!f-oU4v0--mFcMyUQ2!c`| zK$Q?T#DN&Zff9El>X1ugo)#>u*W8XOu+c&!T!Od?xlXn_DoQg)vPXxP_h*h|go-jo zvg&&ZcjTA0pceOzvW1C^krJoN)tXh7o7SG?{b+tceQhW!{gPXv_h0pQkCd()avpng z(L_^iqc7pf>I~NQ&u1O8zo@PK(&dK!=}3I0Wu`*=R@p(;iovofcyXKUt+cMS+hQ1o zzGkFIgQm1_8VKzIb_;u69=_0cL`OV)#Xzq#%oFk3jU%I~NLx%Npd;J8CFXP`F>a^h8=rA!JMQ{OW?eB@cTQ8~ zbWx;WbAC;sg?Z;I)Grv;@txNMKUi`M^2Z%%-?^ehfoshckC2!Uv6VmZoGgi*@=_!_MUCjkRS{S$0 zB17ZiKuEc?F)Vp?F%BC#%38nHVf< zFRsZc&u{XU=H-_cW^G*4P7*!L1&(B4~Dk0QeVPTVU1U}>R}xCh=>X5d~Kph}Akk2MDV{cV4m ziEax1raz4qc^77q_-6-bQF;m(N39OE8gNDpfH*}QB(6~hkk((xf{BYd4)RDGNo5|%2uMx#Ul{6KkfW^u-dZLa!{ zlXc_F38|1}omnzbR%xaloT+LRO08F}k)>Gge85pzT+zqKs&%Pqg3)-vV7MEPHB-$E zi@J83t7xoi>)yRvyT*!Kx7~JlaPaVvroO(WuAZK|wJ-cQ(t6~`k)y4VTV{KDW)po= z%{9AfntPgCntPs~M>!-7(!R|;ig_>O{zC=-XPMAk!W2cAvrWARxG#Wg>OJQY4=E-x z_$J1}GBn>P@sLG)zOovRueScnRZpFI_?y>%wN2~WsYMdc@v{?kFZ(b8eT@;maytfO z-66ml4J5EpC9#MVBVEueeL00VD;Bg%iiJB3bI2tXdMFqqtzn~4^NJYt!~Rk$PYO!Qg z2tX&3;n7@!Q|Q||!7_4?qe{n<<;Gv(I}vThyph%^*L=X;PX3t5G3akCui{m!3Zvp8F%c$7hXAmlWXM{2i7u7)ukgD z#w{&qV51We3Gl$l*BWsRZaju&3jWYxb3nx6@=A_O9giHDngFE(VkGTSb$p8GXPxY4 z9r{^^?%@K1`FJ8s7T1Xrx`v55e5?~X5O=D3V)|I zX%`2B-`lwHUBTc4M+wQl2wIaL9Iuu6@ik!aQhlvKFDOy+*kW+J6#dV-*wI%~BP4v( zTU8PDT+K4U#hLLfwWe&)JGCZqlN!P6n6Z+kxLz}a3t3l%thO2)62;C$>^>AjVd#tX zX}VMo?HP_x+B@rU7A%zc@YTYk8*~?cV|L41-s((qAC$d5STPSo&#sG|I z!QJAftvtb=QQR;=E+hmbYMOz(O3*^xTrLsBpj?2AiY3L@D2^0IsbEs|Y7xI_2ScI6 z231LAjDwdVm$)+^!^>oB3X5F`4>KL5EH$PFs8eOi+kWGF62H(6WOubqcMnAm z_cd>dRb(yQ_#Uk!@%Ns-uE@sP!N|$%%Gh|VH;_BE=$tC7Xl)%DZK(G(#lxX8wv>ir z-GTbC<`~v?>Yrg?VHkSc4P0OJsux@WP3=4d_xxOK{W2ecxEE2l=U3a~5%WxU1B-|^b>4*1#In> z9r$!5+LUdGB<$P&%BmTX@ z%AD6f_uR>2$4*{z%{7bDi`vAAefuw-Ui|OnHb%{D2mcCze^q0pirLds0ktVTn*&@aqKQf#0tf}D^mEvw{l zc+#RMEXziACmqPB!>()FP@;s&fIXK6MyB@dn;MxKne6L~PnFd7ho-KU<;}0@>F@8k zM%oomUYWmZ!@#7+l|M5T9c>S!U8W@(T7#_(Yrk!Yg<}E}g204Q>=spk9=?kq4FM$* z5ctIgEEK(%I#fznNQ$m#t>!>$tc=u&vJkN@c8lTwceGluV|}vfj}KT+9&01gxCz>lZkoj9$CbDj@Aj?F&{H>n=EW9 zOD*M~6XuRR$`@#kR^h!F1fx_V^A2U-5`0}KC+5D3x)FhcrsEK51kR|7bNCvLxn%j4 z;R*#2&hMZY;>lZXxn%{TT+zpdoxbcZuT>!STkN>&C_(JE2Kc5q9(xrBqdm=N|9nzA zYgx$qPbamT_n)%1+o7VU`yVyhC%MzFYIi%nqrvNIyw~i{{;n5TiWE%uFe6xxCD?C)5NZNxSzm}(cG6*jO^Rq`>XrJAxvwI%maWoDSuqLxPC z+e5`l&}JB{ii;;63_p0Qpi52=lP%u$10lrF z_*3>84%aYtK@hRMCDK&lM#{1;vpK7?z7+M=2YR(=qw}2G55ICndt>K#X^pQSy(m8~ zyQ!!wqo}$#(qHayG)GeQB)*;d5o23ob3WP)_GUv$hiMuYrEqvWB$Hx z%pdT;q2o=&K|E8~V8I9TcRw(GM|`-$c;0^`wclX0KWAz0a@=jSKX0@rzn^$(eSaEs zffmc}UNnBk_V*gkdnu{?T}JzlRlC?>SJ0VV(%GLRoym5NU$%e#Jng5f?Sh`<{f`>$ zliasTJ%=Ok3;3;n@8ib(*lSwQ6QARDJY&6I(35=rBfKAdi-Dekpr?9Z@88qOIjE8W z8$eHHjEhRwHtESrdh%K}i%F`;L(ih3CzHUGLInbY+ARo&3m}8gW$8qUPYq&GEW?v! zqzyHnkVvS}^&OZ<4MGxXHEc;%ic8r?>v2UxRY@C!TB+>wmd!%Hr4x{`of!7Hwi1|D zmAu~;c$=IH(vpPj7n0_Jv?SY~w6+UclI;&$+XXGL-R=0kF?YZLK}&20E#ZEJ2^L%+ zEy>^g!1x_;fwUys|0AiLv?SY~v$XdJnvm_!8|}&OC(c^mpAK5eu>9^t<9DyxO9d^- z=e=ZU&v1~IWc!cV4mr$?=S%_@+kk`<%-t-uP1>de17_4^=d(1|ai3sa3QgHtQj ziQ8G2P$th-%G)b(dnInin{ZQ&xG9VlS)YKiUAK_;Tkt-axk)*G3~h)|715tu9hfMl zr*f#UR;QSAMe|~v+J~mpEcKmo+(P&ov;9z}6SC=+%X>2U}?Kj@|_KsHW1i#eU zv7@#1f!5aXk%J@S_76S`Wm%1UGOG+qHZVpdoPhno@0fFvSqKIW$sQvd5Z zh=%NlC22;OsHFX*ts>v|zLkN2mCG$_nz5Aq)7;R|+}bB+j1_Epcs{-v@REC#zG90) zuD+R-9;9s)l!D%v85ycpgozvqeE(VMV|wB4S}hPL7GyzB}jdZ7i#a@9gepU~JFkmVqs$ zX+^{J`xPT&|3a64%km}hsf#9R!ea=M-6MizyZ6+_W4#rv z#hJyq{c9IYY=|>d7Jqb^xk7R~C`oDf5C1@o)U_(a`plLz$LDd?Y!}?_-1j?9rM6QB zbUb>Fc2c{1{%4KnPofqtWka@eMMh+b(I4ts^8Uw-``J(GTJrv9G}tQj`-N;M?|+2% z11qR&;rT+zg09tX8?}uItnCL@m=*vP@nRB;(fpRJYWb$DowSR| z5QPwK3y7DJwX}*XYp$3hV#PFs!d$F_X1Uo)loU2pFciWvzZ8nKm9;ChV?r*(uCNuJ z?p?#7Ake03X(*7mZSz?BWbt@@ceu31n^Bf0H8J0Y-paOMkrWTR%fPt96BovZ4o*hG zWFg)LoP`;MXLJh z<-yv+qT(K`UUc`Si2`i2)#`>|WcWfW;xSN5y`d$elaS{_7?z zROq(APHA7%_uHh*NY^L>Rnc#;|N5ba9?~lBPYiKe;>7$CFhsmf@_IjJP}3z?*$%1{ zEtqj!B}U11@OssLI;mYyaq4piLf+Js&(>v{T== z+)tja?tg^$gQp(?PalC*7lB{4+nL*?N7ynX&Wwyv%DV|qB^d#p=iuo=voZsRr680A z)Szoqm&%#kL+?BJc)8%?yjCOZx^=>?YjAT}R*^Gf<=x4N*V^}$UY~fEm^`aO|Jiz& zeDN`rc{#6EJ>C(kDf5=qh(G%LQ72dMdRV~AX`4cRKtABb=zhi(A+tIQ8j3XIb4Yd! ziAP3T@M!Koaz4q~1gcsng+*jt+zMk9^tq0NzT$cj>Fd-tg0(xc&A|Xr;H^_Hppv(=7|l zT6ih(5gdO=K0J?6Wk@~@V-Xf8K!VVb?LxLrZU@w0FBLe_ETD#8==Teoo5ZPyX?g-b z0*TD}4iYr`Qkuz;fatZQz=$lA4ml zz4n*?l~gegDk3B*n8(c8j;to7Q>)@UX7^L}a5q&u)qNEpBj{P?J~6#ZN_dQVWVtj9 zCzrJzm}?!Z$rXW}=6Fc%|MJ6&ytV$ql@(ne7#{!ERqd15%Dtu-U6;c-yqq{8IDybh zE%qfu`-<6=?cfkVb6|xb*U5HZg=#;&{(dFzB|S&3!hDYS6|toot8hSQR=ks%6(~@& zBO{dkQO_ssnC;Z8Rqc5W$Lgt|G&7 zL1xc2MlyYU9#hcJbS{z!ApHlkA|qGHcd%InzSF&_p8*ZUJrV^=1^rhWSN+h@`2-r} z^s{<(6|>xpY;~4DVA3Wk%F$95br(vb1THG|Jc=ls@OXPod{^&$@6dp8_O*dYhwE8? zr#R#{IOfM!X!ok!++>_-zuY*r$rH#`XSq3;2LUI}gH})Y3EpbVgP?MW7Z2kq zshoJxY`+}hYLphnEC!`vSaQYo8C0I))KD~Rl8Kcn=);gosuNn1R8pN#e5f~-!NN!d zh;}b1JB*N50MfNoBo!o^FYL|!mQez!cUQcI$EL{K{VJK)e)*wb!-lH~9_TtlJZ7I< z`|if6EmIrCABCIECannVPRC`89k8NynT*Tge$k%hu;Owvtpw_RD=s(Nu^FV}Gye^k zTb}=_eO&3Ua-9O~bsCcTE8eS9hM**Iox8RK#P41twMgny;|Mw43l9ELN_#-)uad@o zEUsd=Up$9igB-BbKHDjpp89PAVs;~#2?uC~N7G&rq=_?Vab2mXZ*!JYShpDiL@3c? z5X9sn$`xmf2QLt3B{-8_gl4qJFwADU1@1va+(Y=VgjTUI|08(U78HaP#$#lu0JW#O zGU|n;f?TPg_-!=E1PhUQp(@}exdT2Ej7Ed_Xa{N|5ybW&)2ViLW^QiFQpiy}Qaiov zm4-ThO~$mlEZiO+9Dm#Rp!oA^*zjS=Pkc2RnYrbrn{HWXs(sT*82#tkrO_XAu^EGH zl{i($8SIe$^h{0*>SK>Bn>q`iLxy6Om292A9uU6v_oZrJ-_hQNdk z02}gv72HCM#i6-fSF^wv;qa-32d5fS#T)gVw+hiB$g050q-mr`42psstz*c0P~~S3lZyorQ!c9btGF!`Aoa`-Z+Ooq zwfTQoK7QMkqeELZ&+aJvz_0E5`!+7VW9RoW_w71%$zso7=|F7pn~6ElNIqz43RO%7 zYR3vkoI4N>p3K9w8)l_Nkm~vW>G-INTsk-?}aOUK7 zixJDfCA~fp_?kp_u^J{*iOQaq1u{Lst?_tO?_5W0uBR&2-WT$hj}Pu$>|U%MDem+K zo5~^;Rc*n-?VGv=2fHpp=##T!aWD`VSnQath*ni(WtNWiZ=DZC1}ae4zbulInOPcW z@{esw%b1v)oLKtAnM#jT7yykc zzl`OgMS#(*jOeC!hq(fba-}lKL_}!}A$kPB;o;4d!Hf*&69q4s5#){8^S?YkeDL7i zCo4-2pE>i9kDS@qcWCyqrIM=aA6>d^_72jl7!iU^p+zEjp#mL&?U3z)X0e|jX!fL_ zS+o-Zg2SGI`R7J`+$j<8DTe(JNg*Ujp-wW|I$$@mwUH{3!e~PnBwda)`FjO5(0yNt z7K%uI97DK55h|5!a=oKf(SQWElf*WMx=&Vnifi5eOjFa zS>lsReTL^1(&1KUO5|S!7~3`LeWtR0Ekg&?ted$JE={J1htG0#E(I=_?bN5(0(}Zs&35Wjvi)?z+Adaz)aOWjYF+yzqAQ*1Ig~*m zHdy>L~yFuTZE1CEBt9V?-x8zOZgifD1xb zk}!MNfZ4046m>b)vTLb-Cg%?*-e|%sB{0ky3N1in(f}<>dYUmUODroWH=*VJ=CpUp zHIr4;Dv&FRl6uS)OodQq%syM)WS9^nM0PFP^TPEI2U37=QbA$X#C&a$2qM}iIa+8t zY)QsUc|UP7&2m4W<3JTniIeh*{(Pw&{bl|APRFc)$^V&z$&6c+82-cawEyTl?PpYb z5mvHsi)8In>5x0!?UnaFbWVHV_sr@md{WnSN=Ka4;YQM?2|L1x;EKVCl zt!}>8)Y8}Y=U4juqMx5p&v85cKy4V~mG`e2&;Kd!#~N1zPPi8m4=XfW%}rhP%x^!4 z&CE(t9M4SjPKlegCJYMtHU(x1J&c>K!5d}VG`?~iNw^HA4{_ZvXcjEnn#`D&a+PjVRvup>>z$-5Hna_jFa;sS%SLQ3C zhB9WAa*^WrCe9=Ugz*kDIf$`ONTrlY5|D#ic*_)-G?Dz5g+)PLrL#e9jmO-{9C06| zW~Pvbfh&c~mlo2oH;{#l2(A8Y&6k|gPTqUxo%bgGSiZ1&`n3Jjj_>VwVTb+V#7DKA zn13-I(l++$7PPJWN_~)u!h+`gvYloQ+%MZn+p_&P!u+A3y#?~(Q!oI)me{N^5OipC zQHbcWY;0v(WFND2VJJ2+6xz;FiyVWeLAOi_(@f_hq8kmldY!FptQNe zVsSWe{Q4VX(_P{0L+M3Tb@pQ?_g&FC(^XYaRbN^Y9BQsOci`e!{^#!g)aT1v;-Q+- z`laGAszsn(j31L?9NWFkG&4ui$2(|(0*I4 z6m9BPIR+*w?cT&hQ}&SN1(|qjgEk^F@oda%Cl-iA-e=`Wj_6J+z6|5TtNZX-#V74j z-2Ng?M9SmudBOzXhxC(t{p31vqVE_;g^&DCR-U0znAu&_j#Y(~22I)iP&kDsy)-%b zZ#P|k{Z0S&|7NDAXXdZFPMcWJsvrKuCm!B?WZz}`jyyH{YeGvw#h#|UOW-Rc7_5O@ zyU2mDb}?uH_sMq7f^7e7Qahn7+kcbPj{6 zCQfU;iLYoI&b6>5K5Rd={I%upE}v^nGojAaNkaW)3)H8aH>6)H;qkW~brDgBclN+wT}h^)89z~Qi<6-I_2B8(F%oHXI5_?g*|61!kV;Yk0Vj9t*^ zh1d&{If0MZ`{Nm$2>weqMwk)G*QO``MF}I#Lgf`K^5W`!{S6*L?v0! zyp;>`U?~i}(gwL7^Vv(pOrl5>bvh9()+zUN33^ZnL%ZkatHZ;q@{blve8a9S96WO5 z;KJHj?cHy917JA?nB|K(35z+=CF)tUrwgtZ5$(FfF5AiVWV+er~ zKDnN}UzgbB{g_+T{xgLOv!K6p!S#5*Dap(G$(`kMbV**elk3U*bxB^flk3U*f6C`$ zMrOhFxQABIc085qrL2Ij%k{|D*4y6Qm@Y0>Ea78vITEt){rdH0n-I05Mc{m%nd&Y14j!!*j)Y{JH2>inDfnT4sy)*G~;035c+@E86HwC40R?0U) zVpja2RR(5jLEp5>u>OGjmWgkS^kLG1%)*vcZDpYo`UDvB!!3FkJgT2%8NVRavlbGI z6nYr-6Z0_UCt&v_c5L3f`t;LUujVY3u%1=s`qjDAq zse*Xw1Rc>IAotZ|PB&|$it1Oi`3WyjCrV&>rtbJg#)0ASm(JFIBLh0_&+KJu8hk&O z;`lR;s1elANIs_^!_k`U%3|= z`I*@taEAzdd0sae4Ca)O%e-i!w#X0ULpOf#Ed=5g`*{?zS{2i2`Ac9~J3uA+> z#o9O0bwC~!GFHlz3SicWVofeT@Q~PS6UJF$t~?{NqfdiZ!5Wk|3w1TrStYLt46mYE zAkJ8CNuD*TtMp&!mCW^k7+L#O6ewjnpH~_3QHw@v+=0qF@d_VAlTq{ZYYx|sYWk*! z-ab9Vv+TOLhNI2bZCki`cN1_HI%kfh!EiDkqT1Im|5$6$x0CEV_Dv z0+>!X2c;a%m>4>Ke9&iUOyuPL%lDlGc=pluD@STjj`j_}M`G$EApnT6B^k53 z1*Hkhw%kvMVWn30>npW<{*A!wpZo=oPr83xNZP`3W1$34l$6l%UO6x7?V?|djA>L7 zx|lQ0oK8VXLQid{gw9sCL$}}fup;zh1<8lTd4!f1U2A6XYt!bZR!4J&t+YcKybu$H znkz^Fz>a82X3v3glquRtKh$Vtd?x~q8Q@?VXTly8&=S)sZn*Z^8?6i^I_=d{Y_ z;IG-v=g4-!7qK!#Ayl_5;4PS|4AvGV;PX}uYO^=1e!A3BFC zl%ej}P!2$1$p~4Lyb?!Q8mJwQmeoeMV)SBtc%3K`ZlatNL}GEPTa7_T5VYEKwq6pN zs@>T;I@uB5+&*4kc-`vu%R>{DTlxR~*fu5$etnBn~ zEFQe@NN4Lru%R>7(3X|sIkIWbrS2BfooYayE0TopuXV^{rU+q5MiI`5xUPwPHYhO# zY^zKctPy-ehrB{Y6FXC^iz;PRDE71!(uu_EGVDSiR*OpTAFjd-WniF&Wck&Y4H#h% zjN#Su3?U85A{-qN#AbA(v`mloi}%+~c2rk%&33f!@DDT(x3+b(?fPKiPml`_f8M2) zwNJ-dw)Rx}BU2sy&7tP5 z!Lgy^jIl->I%yDR(h$MQLNtdmI7U+{m8>C&4NwlQ-g6HE^?rDdFgx@vd~myx)}XlJBQ{ z$CfmQl;=<)eXjs6r0H4dG{l-zEg_20<~D>5zo3 z#2UKd%cRN9qwMFx>yBDlC?Id7iy9s&1YjUm*YjCdX={CbYpH8Bt+)}^rgf!x?1iNp zceZ(;C2nt+X!H5nCK|L)C607%AMdRQwfPEVRi4bkYV3&Tb2_7&S2t-NG-d-nD#z~$ z6#*L)x|G%=w4}5~XuWD*5;e+pr)B~wC!NVpQlMq#v6GdM`Y7e)o*{s;mF=kzWr+(d zpn-XY4tSv`CqZIqPeq-leb>f~yV}`_C-0Zimw7)SV9_i1I|a!lhbeto?=N)x#lX`9Ihzi7VK$5UTR2{th*?lFWW({ivE(@Nq=lfbLjMEK1b4-et$Oj)n>sDgj8dAqGdbzfovz0 zb6nY=1v9-p}{S`}Oy(e?H$Xo+I8%`j*ep>091U%#_a&n2Gmdjkni|es<#l zrrsgj$)9Aq&Yxtvu!-`1ag}Qg^HmTd1R?dURL_!Q@)TD#<4#gy@>0YC#kb91ENj9t zcXa0B>=g|B&8${b%7O$gQIG(s4VHKuBLsyz#LurB_10F2{1vaizP!3I-{pDQ{`JJu z*UEsKwbz!4LKXSNzGF9F*{=s|Sc{YUxMC+_CK|KIqg=Yp9i~#&9BU8_O$Ic|v5p|u zegwn9iS7-+Ssti=^aGEgo?TkTJGH9)_6ycNw% z4NsA&R9QRIHdd+k2FZ4h(k#oKahySPaH?`%() z8M^XNIs;WS>BwWKJ_98wtWocf{!!D4+&8UTSNNSk#w>_=avv|AklDH2#6R}WSBT`VV7w=F~brJa;+ z3KaELq8Qy2BBf2`!HBxM^x$<4Ik4PQ?H4h7CEj4eJ8!5OTpk|YGgNiMJD)EOmF5gg zObq0d21}lw&z$_l#>-|K8fGus_>0NR`5QB9yW;+%3(Lz3NB!}x+RPgRP}WK;~fOYTiqh$F-9C`w|nLRwN>^P=>FTa-_d1qRktm}%<1 zAYS92Sed!&!!7YzuQRQ1s+Y2SP5gqM-pTQS!I2Hwkwn&uA$D?jj#a=*X)9d_qr|Gs>eD;}%vswqHN@9hQ!RDdVy7Fm@4o4G|2~>j_@3U7h&D3bOrxNbv5Ty$t(} zI)z9MPhtTyWtQJ(fi5xw{SE4YCd5p`lTM!GlTxse{wVTHNv<>(iv8(<5AY-s!>M4v zIsqZa?dh+q(e`aWkht@JcH;JO-%|4pJO42;|MIQ3zO2p9EM42Gu$|RbM12lG198;I zq<2DVeT1rOveufiJf$SmD?Q2_O%lsx)QnZH6)edN*{5$0^`!-ngl~_2y=(Ddh7q^! zra?YODJLEnZb|@E=6}knqSes2x`Wtg2|EXM!k(aZxIw*aY_M9TRcnaG@&*%|`VX{c zRF^m9x9#nl+&$U1w=KV^ygH-(K>w!QFTC(XAmno8^gjIW`1f#cj>{DaJn_N{#FJjg zst$WTIPhTxoHdGVp7dE%vZ^_5liKy%ihQhEGJY&y;ysEgV_a}hg731>rc6@|gBj9( zV$~kWbq^x3v&O@`GW|BDgHBr(mno1K{sp3w>^e&%3 z!?pMj16kRdIVs3;7C6UBAuP=%1{4FDaU&|?nOJAAagaB-du7j?FMor_?=AIvMtX(= zEk%oaz#yJ|_VbmcE|;sU>hh)P;D^p&On56wo;$H3&Qsu!TD(4{l)XBw%eWYVwU7l@ zbD7G_>bvyNa5d-JEy@ZLX}DcO=~#|8$E~fbd?*qq83=@yJWa#%J@>MZ@a7xdS5r#Q zc^r@~L$C5}D->#y*XO?tX_NUYNLwW`8cDKXJZGs1de#|$LViZuPa$ZR&RI`4H>N7_ z1moAPx>qmT>vX^Koo?q|yYs*P3;hi4O8mRlv@7u=^mHCQrJS8bt3LuZg?mGX-{0=3 z;%p}Fnmy%|XDOV>kT{V6oB$puoRIU6uqOm{dxP^U-&?`|oaD9F&YYn{A@a8ck1Y`R zyMVqr(AU?KVoh{4d!m4t#8NTCl={&JF}a=~_NQO@4qnfBOw8l$G7p3t9wIcpiz1jysCu|lTU0aBv#l^xrJStsS*PH8m}^pS#zExbc)Hb!%u zzS8;>I7xy)Mvys8u0FNbF^$}Vf(g_>3>p+npapGRqOBcAI`Tklw2CSJK#3^pDrGlO z7?9y1Ooq{`LnWufK(PLDZ?8WPSaRhzRaP}s=jT^9RaG|SyOvWL-jCb-y&E^axUpx; z;VnIR(Q04(=*-N~xUV|;+H2NtiG=16_uyV&Koe%X5!8a+O)yG&k1w+i3)-2ZQkP6S zDHxEFAEfj=(2ic(g;5q%@UoEZ04F(%xZpo;PtatD-dRLiQS5!Af$&|8f0U|&K49aOFL-z#^POi zMM5T~san;bW9^+T-(=T6ue9{UhE`Ty_6I)k=z~pl)m2Zgy+(L70)7nwUeF7f?m<`T z|DM;RBxjn;N8dhzb)2}TnEEf!xaTtbm(T#WS?wWyWKZfbb4%z+p6w#aM#=X0zddtm z)i8m1?x`eqCRuVy^OB}iPODu@)a|(h|Ml-MJDAw^62L?{LhT19fK+U|m}FIAD;0#p zazCaNoYGfQ3MM;{3mR8vyNYoYtFsLa`D%5RK^*kNQc@J`2$ZHQ)!icFjzZM?ZhrNx zv-|eV{%++&q^T)#0_xw#cW&Rlb0HM035Lj@IJ=+@EFwi%l@TGHt0W9q7SZPn0D{L- zgFmILE-3{UtgCa1#8HShNqwl0tBVbUkdKePy3lway+1?=b3}#`gedV~39u2UVA@P7!@wGnc5F68VQ+vmxR;ah_`YpU)Fb(=+6vc>gnY14VpoSoxD;erhDOYy znc1o2ToYExequH#%r&JRQ-n7~0a&f1)RP8?p=LCs9$5s9V`U9knHY}Ze;75*6x6c7 z?M1yG-9%xOc2)VF+Re3t4JD4by?eJi%IXKA^}`L`;`-tGf^ttrhNrwh9kWZicaFbk zcYbGhv7^{Gbacy>qeH&!*;SqO2kSelvW*i=1FB@mb)*y%fXL}Wg*mb|aKONAWl=SO zU`qc=zOw2E+!TSbG#j7HI?ZIHR zuR2KCjrKC;Q>DT5$1%^~52Gf@U14KL=B{RiTQ$!qo)kIp=sJVMB^uM6Pculd;z>>n zmiYxhXa`IG{%$ zvp#g1fdfjaFyCOB**V>Wh0f;lxgXS7JW%k2y*N36k6GcH+Ru^PN>pXlQndBQ{UmfO^1iFf@PJVobik1`VMQIiLYt4_WHE6v~c5J6DZoj z&tDtEXsB;T;5V)lQjC--fS%DxYetUZkvgQRILa!EC)Q+z(v?Uz3aCtoDtVv*4i9Az zF}sZTW;vcbk&#-BDN#9L3SzXv<7@ zHO9CB*g$Xd>sf5f$yan_HdvK8G1up}*m$JH1`4?&xZ#pq_u6ss2Oi?5zpSmTO=vw| zPB?(CqDPDZv-bmE(;QqlIU7^xX*+r;YK`f9B)#TTwqI`ff{wd^Iig>dE5 z{HIJf+Wq&S{6VpAps{MasH!kiyK2?G`^^XLI5JjT=3Oot*nQ*jH`XRF7d_~kU+@li z?C_m)kT1T6#dpdPjDby~%!{#X!u4Ft(d*eIUI%%SSP90G>|NWW{p;$>hIb9Up~WU5 zdINnF-$h=c=#5`|FT-_2>7aKqY6q?M0rbveTcU81TnSRW+w5J6E6iNgsq7Og7zFMS zSYbyjl`Ngx?T8h^^hCsPG-z)*QSpl_udcI4j-7ZuyS%2cYG!#74!ZBZ@l9HE?O}g? zDZG4$CVC0|{61F6N?Vv!Dl5SpqT;!g;D4--!lTF^Vos3A&fA9(U`262j8rGB`U$u;hJ0?VL;Ic#`8MloS#(&;$9o{W((8KX4n#l}hZj;L})i75gu~TU`ED(|z zq2#B&W2EpObVQ=Bp1Anp6RSOQUFGFnb3LnR@t&UeLH_BV@Jofxz&NQ2pbc3F2}5O3Y;3niadf@%c4wn9@~OBYbimS$qFf1uxVQm zpvjV8Jc}0ycvj03<^^Se3t%IZlk02{|B*Ph`kmG3qj2=zM@R3gzh$(9E(;V?^+ zZqDM(YQn_{Cp-@mp0{qorG-NK??mf`d-31cPRl^9c{d|Rz)L9lnOeS#_9Kgg#-UiGjF_fkdyf2G7vLN{ z+`s4W;XVKO0S=;F8#;_d^VO4w51-uG+&0kG{1<{AxMLUj&{05~W7+mMtq>-}|BwB% zYAJK8>r|9v0Bd4zl`2S!5!-h5NbVM(bASM)#lU%_#VoOyc<>{NxKM^6EuwBCa3P>@ zL8-+uDN9zvqGfMy>%|w}y3M~cCp6mm&CbzKj&TA=z>H_l1b#810;`UPer!24 zpb&HK2SvcRXKg`*KTbw6%q6 ztc*}$B@hi=N^_XIRC1R;_)As$6R+Yj?w1iPyq^&)xE~zFXcza(_9s-k8<>RJqgcbA z7x#$>309%87mMFNhDDyMk!ZKy0r6zSG#BYKqmah5vm_}Sm%!puAR3;;M|HgzU6*gSn{ZVVXz$@AQ1ls{8<$08rU5_o&4`VTWG)a!hWl+ zV8VO+Yu(cNN+Z7MMpxe9kKADBIZq%@ zdH=!-_;-##o}(`jmNJfqFh(32Vf<$TnejcBiTIvo#<^9)H{5EW;{Qc_j|YR4@jXKD zzYdg3oPj+I6m5;~@u*rr)ZZEbCmx$H_7bD0?%$60f$H|F_>PL#iTGJwI*{RRkGx*c z&+zt)8#wZ5@L28^kY$^uET61ZC?0FZFMrE=erLvg55tAR0)UdO?V9Tf-L2r^1nC}GUGrX6a7`q-=Hiu%FP~bLV z(v)i?Iao;ha=RaKLDpEIsRQo*51mPh6r!&j{wRAF##Ceb1U(YT95iJ$OmUVaS!hVj zW&(w@?aKYEQey99npQd%Y#JpPs6)%HAGAzum#dwus!%Lwnb&G)ml#GrwLM%xr9+6J z56L5kw@AR``|XDhx6jnQVdYqUu%xsmx4*YPzoM)-Yjb$!454uO<my&zIN$|-RRDSP{xp#+oybjObm(t)gh?2~it!(5|L zDD|A{EX+KunbLx%_Pc=4aK6Fbamyd)-f+?w>98E>3`1s~T|dk-7@|!(yJp`a#u=ek z!=tnlirc6HZ)kieIGr*;(*}lIXbh0#%MHQ7x@rmSti$_wcI8`ED>uaXEX;h znj;3x7(W@FEpcA>#lf5Dd1qlO#Mg=_uPG76vivm>=f#j(M0#PS7(2k3DgaL2PaR$2 zq}0vL?DN0fe^qnWG%|wCoWRCC@7UAM$-Dg>d$dbAZOwM)x4$js?D^+s)ns9|IBrG_=dk`n z#yTneZ;v)bBsS#`PCn*Kj5bS-HVdPLT~Cdc!GkiyRt<(rj+mpyBOlO;A3XAbBX;Mv zzO|T``_YfEipW?z>OzdgQxX;g5*4Edfu#a$^(f_dy_RGV_#M~iN`ul~M^@Dnb^Wky z$OEtDOpyiITi9`K$DC)Vh9e#4qFay@Q+_UIWe9#K7pOf?9Qk0 zZ|$|Gt$p#>-ar96*~^i_=28$bWrbHPUv!RHyi#Ig4z?RTESNUfbK5Atf^$bZk;YY(g$2TOi3KuwJ{Mj3uk^ zAcR>gP*OORjE&njvKALrhysC?6g#{^g@8zrrv(e63fk@xj70lU+e~L#$L2SF@v&>i zyV5$xuXy~e+Yg-By!nKdmN>9w%YnZ<{4#L_21-ODQYwYdjQoK%6pVBjBV~+a31iL? z2_mvo&O6eSE!?eY6Jo}dk33gKDMCe%>S-?Yj$DIH%MwPiZYYN-%SdyWC1RK;A&sDt zq{u}M^!a5d^j#laX+jt(LM<)Pi!$DF6vJV@i;q&13(Eq98+{}xVsOe{K`QtbD-#!@ zEG7Afg(wdVjD>)*&EQK0Md-;7z%V6akRVu4Ee+BnL%==iC@Y}S9UJWAo&k)z?rjT;k3JZwbKQ2EtGa~InV3NtzsDJDmNUo+ zM7!AkLs+$F#nNmGm{CSyXW172|3!@JL7S8nNK~fGK;tI_BdkUXh{^uwfse|#*R|Jf zy_G#WgjJKfVlsr({EnV$PT6z6>^XNR{EE1bk_&Qlz^}M}YRGmE`IXGb(q~YGe3<&0 zk^)UKG({m0KB+E5^LB{Q(vm-nxbd0_%vc51hYC^cfYPFjoXussSdMdH{ba1Alx`UB z!I(}jw1N`c%QPM^6w8)rw4*_c0#^|h;bO0-6QysRmb9O0^7d&-+p1ynt4FNJ6B2_jfTCexO^hsu% zrDIJfg!K@CUzDmKSG|MrR!=;!x<|WBYefAG6aejA030@-ods-ICHrzxlcAZ`(Bn-c zO9@4?is&_BWugaxvG}aa!$=VnG82ea+JcVWjfMyDc@iH784L^Ok-@ND4-t)wLRHd4 zPeq?)oV_%8p1rN0IH#&DR5MV%$}zy@+Bh)ZS-QM%*_>yzdv62$tXY|fpCFX%r&&4l zy)Iw1LJV01^sV)0S>4raCq~r>j7Y8S1yq{k0QL@xRrpF0P zcAZnn;&E~>|9|a$d4OF-mG`ZCU-#=Jy?5vJzNXWAI!UL~Njm9loqYikFoXnxV%Vaj zK_n<5VH`1{NDRWDFa&pCX`362bWiVWc7_4oUos=D{St-Axk zKfX`G?W%jV(%g4(47qc4i`g*$Ib(kGa})D3`a0%_cY!9_oc|`D z(x=ne78?;6v^Zsc(ny-%jle1}G&QbwlFNy~lxa6?!w}txE9{-*mL`1UH^&>5EBhVt8F`ngHs*hzSI*z()OCpV5A6nECvGjnnVJI9HN+px3X zGr-2Npi}NunvJnW`do(lGQp0HNwd6PKPOMu&;N@^^WPuwpa0H0Ux{*NiVRqQCy)Ve z@&^Uo@NC&nsLHvM=4!Q56T)&z%tgwyV}ej1s|@T1DriwN-;( znl#H8oT&9HY`)IWAsC#RUQLEuv5U634Vj&|7#{;e6FRe=tWAuGQ_nPXmmvNv922+U z%*fOZM874+#Jx&fK7mqNoZRga-d4!qf+ppg+4u#nwK%QkU#kTlw#ZeRM|;!!LOPT3H9Shk5oo!{K$jTbu7K89xd#SOT6d z3Xuu7(GQ*K#Zc?=6myNWA|qN=WV%Nb{ExnO5^ zUtf2wd+NJ(e`Ck|@>to)B{NU`!iT@f@Q^b`OyCN8)W)$DIXBTgndpy9=|H1{1zkL* zr1CI?^=yKt`;p5`Jn2WhEgB^eyb?wd73q;Zp+Kl6(b~ig5$~n?`NpS5B>c@#@H47| zA#k8rMi{X->ASp^iS=D_zk&ru5 zGr)uabec=CMRsjh(8rT5IsNoYPH$qzequK}wwm&jK_eKY$vxVR_O3>I)A}q^T4{K4 z(Cj1eS^BYv;8Z%yPWvonJkmxKpC$F{_8m8k>|08B ziXIS&iQyH$Wvkw?-bSBVzh$#O5>v=BkTf>ptIqdZ@&w2#Av(G%n+5&B)OV7*(!(zH zT`R)-95a>baZgjIEzIfxDY--nqTBbo)-VGKAAoI|ZdhvaDeq{ZkdoNg9>Wum4`f2CHV z>9EW{`;5?`A)R%rhlf|!b@Hf@{QAy$t!G9{?z%p7I>>q3F4%USL}oP%^fYFFhT23U z=xn}6Z34a}^xW^TXMXUU+O&RtDB(GFoHNf~On6Q$TfhIZc@E!!?2wf$J6LgHtK5Sk z;Kj~DcZS!~rpTU@(QE89@$aA{bCj6_Gmb_f6})^&b%Y9Rf0~H>zSB_bdrp$Pdg$WK zE3;Q^{_vqIt{AyOWh-^tx30eWTk&=3gl~VF79-c{Y{mGiaK6n!nAUd~3^!vB1rJiR zrm%~RD`L3}g?8_FzC+7aD>PeWq=zW@Cc}UcqcBx~Ei-zAVG72_=(igaL@nyozGGHr zuidcgXOCX8VrACK)w}-n`s2^M{O|w%a&@{o{^Cn7y*PeT{PkBs6ElI?e9=e|bI;KX z4oqtA08SV!UE_R_(L9N}g{3G6CTD1b!OamZz8hF#m@I@k?Bexzt&xSBR{ zYBUaNEDlVj0RR~s)Bp#VNfI2Cq>E-I9)7+tt7=ilyyN;1&|KHsGSr7~>mZ`J2bT6^ zL^Mm}HTA_Jw78Npn=vq)@Vbx`E}TLIVbOvaciBG*i??)(;bujPwWZ7elRUT@nNEUQ zh`UOMrGs3$2vb-+%H*`^=499U~)IE!`Vm$=>ymC;x5r zvYan|Ia163Z51ud{y0ar#Og}#uGhWG&gFB&)nTVt*d^F44!yk^z0LI&`OZf<-;_A- zkTOzJm|f7@+C`wdkSheJ-{$MCoKSpxpM+^@U*+rH!ZM9-+35J?+=nIEo|1c!MNuz$Y{d?W^P2Y}lzs!ed~4a_d_02^kq04C=7&ib|gjxWxLyAkxb& zcfc53Btug#G@Xm0?irOcN=H7YuKiqA z?X3D!Pgp$sF9TwOUut(-Vg9*O3t|!Y?Fa?G)o)dmOl;#|Lex!x6i-k=F+;8PQGi8}a;a zbgR&gb!Z3uburG|B(Hc#VJs=3;(-sDo*lc29KkSAXEz?~#dRYtIO?KkK?EceaZc3_ zJSfi*X_5|cba2PXeKZL^JbJC9H9Qk1N@ef<)Ws3EB`l7Jf787eRO+v0Qa6HbJm+e) zRDIul5TmOKBND4)9p65&Q(o1+W|T6xj3!Y|pZFx~ud4M4FjXj~)!?3bM!Fh05)U$i zVybrt7o9Y5N45dZX2|L2bVRD|^{ZB`=ha$U+sdnC(xehxYUycPx14s`maV5Pn%=dd zYr6b-cVS+BUZMO!pWUEVsfW;Kd0hSBqG4Q$X}vi{9tLA6+yZC?XaNKAJZS-X#XnK z8v`f|lM*~uOFb@#9Sg6F<;sY0eZFE930>$wHT7c;F9yLSE}|?_ed@mWiyvxgXsD{M z&skPDJhW(eaqZNKevOZQlbzo>lti8FR2<}Mb>;oO#33xjh*H?h; zyShVIOW{Dq^ai||qeKN}?hDf-n8O~VCe9!0xN@Op=&b{Kks>(DiJZriaU&oSOe~0p z7o?#L6p(Y9gy06db_FNfv9VaZ@nf_f;(HTezmDv1R&kO9z*f4HmRDH8!<2 zwYhCorIGT=?(W*={+^buEHArz>de8;+U{6gO=YBN@*|0PD-=dK*Augj^c zmp>dgUjZF`7cI(h7VuB5h+s=SDNBiicIZ7_u}=$lN(7Wq!=OjVUGF!Se|%U~ejLTl zT>5uQ;!lDLd>&YP0ArIJx}TmE2S&_*5MDKiiz->W&!bbuYEUOhNk<8N1SfLzRzm7L zh(}N0`ZX@^Ts+u|%#FBi z*Bd>+f(-4&m)Hj}JNmh4Dh^3mv9_zFu66n1#migkTDsP*AjWw$fXZ;Gr3~ml#CG0a z)zMcKrn}|qt}>+OPD>)ZW6lm!9whhZo9g>&*AYqXYBFq5Dh*Y@fK4a4p|5K4LTj|- z#wPtd+vBZE?(A^{H^$Np7*G$XbH|k2>059lk{i?ONs>E3ZUct89)>m9C{ZHn9JZ!S-`g5FBY?7Sbl*Ay|@_LjAT>@k%D=m?;%@1i|p=?Ljsk*p*1l_lv2frbE#JpewS zHH14Rq#>lM@m>r-Uqfg&JfI!Jyd6BCokP~cXlduFF{%pfU#)z-vAUh0k3u1!uS1lqF$v~_-67}pr8eYF#ro+C0O`4M-wnr zflpAE1Oy3s0+xr%CP}dGD_U+nr@pFsa#daKPWgLy#9sj=900_fTqRteWaDRwa}kK60$CxW{{&kI$&JE2TL`aJ zA*6eu<7wfK-odypk@j&Hs7m>63Z5`PSIhx(NWC%T{cWI9B)w{zy`Xq@{*=0!x|aHe zruy2NSa0pL@;TGy4tLZwb*)s}J8N1iYAdnj{p5kk#W^Jv9qly@(>og5vLab+EjO6(MY1y-PFwj5=?>10H*>LpKPI3VJr}EKL_uC)$q4$$Ws?OHobI6`nQNh? zP+IxEk>7%S45DiB4WHaHZQAn4>FU7ZpZubK(Fd=+7CnCxdgD8Qt&!*R6(&jc6g$Mo zwoq@>fS>9GNi{xPj0PvB>2gBAmOw}=#aJZGJo6ULL#&=KUL!aoLbkr@?4MUOYf?*1 zU427UMQwDb;1^%b&B||XnYM7b!FOY2BvxCRlk>pfNUXlUuWuN*_@-$IIVQYa!J3d@ zEl4JdUfM(G60LVCv)Hs0@LLHjp2Kk@bkD+Nu_-;V)0ZBXpPk#@+B0v({Mx#@I$Ub| zs`)Jo`#Xzz*3D3*Epw*NS~9z?GOw~u{%|zk0!ld`cKkw$tVBCL#S3Jb1L*a_8if|v zQx3$C@2l`y)O*ff_`esdb9XPkXejH;c!Po&6J_}^M|I?G;|CvtgfxC z=2cT)UxQ0+ALwju%ZcPnt)JT0hd;=+HFpj?8Y{~wi^-p>s>*Z9tK<(T>ubX4wbd>= zH(GxYW2ggvQ6`^<^ba)*`hwW4So%Tv7@6b$yAq>dWl|&e+?*WNYh%j zC9oXLivlGm}S%^%3tky56fS1-9!GiW|F^)eDO>9i@x{{U>i&R1`wt8MGg8y zkGqM=-$dLSSNcTduf}$&_@(?cEg4__rf-jxzkywxDu0Eeyk+^DJ*N1D^aY(9KwpT3 zoI&~qup8+MODmAR(7r_Tf)hO?dsT%mdx4`{F!HqQeR@K&H>fe(`&Qb*QVyhTmM>hf zvkFz?YN*E5P>n_Odg4Kc(M$(fHT`1w{`(ZhzsAKMvN)CP=r^+b0^A9B-RU*cOn-a> zI@#Agj?m6NQkgAs;RLat$CugZaIEzkE3+NrSx+akF-w);UOyt4J(k4AOgUCaxj2~^ zYtQ~|bO(B6s&gj(M&0L}Ya~u3fAr0?zyC7f_XqL)QwiVuzh@}_SN41Po!>v5@V)reeXvHZl}+5Vp<)Svo$B7UAvsGr~eEa7|q_YCd-kCg8d%l}Qn@2U7n z{5`e))ZhO-0Y3lt4DkIY>xcY+-i1iuCtxaca^_E;?ueN`?p~^F>`r(wjz_1$0G6~2 zl*Lp__36WlvsZob)c7BEso&Oq?}tBBpKUsP9LjAP9d!rX`{>r7;i}`E5O7uI4b-TZ zR~F4zO+Wd`#(VDCD1ZA@w`%K;zoznL#h;G!kgV!(1?jis$CC59u|^swpO4B+{d{L zj1~b~2pda^OMqsQc6?|JNc**|UqFX}mfd@YR(E(=hySc@$?`zl&k^!5>j@|>T|KBN zT+bJ_zNYI@_kuFKEOpD_KWBLzBo5S5=InA8xEG>57?<1+QO@$b4_Ti5E~h*5x$BKd#GB*Gq#AY*kTR6UwbYxyx+1*l^62%kd7|ffS-gAqW|iBI9tG;358T z)u8cU%8kFV^?+Y)nR6oG-K^ow3(5`PB}>DI<5E&R=E|g4?Em+?DR!2CtD=6f0`G}{ z*OZ$TlpDaS%LTMeW-Nb7&=7xKU1ZRp%RR6){)Pb;FAn|bMs9{j#E#VUV?g>Og)GhOQ^>uOu?I*A3sjx4LV5C z=#RmjUV;pL9pmSAXdi!p?t!Spgx~N?e#1Nb4OnoT%W>k-bZEPvvKT~_thKuj)R)Np z4IohsnWo~Wh89m(C%xw+HGT2WQwcZj^r6LI+>i+R7e9|0HOGD!H9|TdJ~?4^UrJ1N zuwKn_Y(tYVP5dF|os~ra^xSjk0;w8#&BqUFCjOi-b#ueEXF2We8uxP;(P(=ET#{2X zM<-u{r9-|Z6l*t>u~DHsb4AyiB^^=Sw{~XFn%VVRhi2Aw))W|8jtFV&?cFfc(lBRz zZ)xe=Ws}?b>pvY(U;*miXzO=_`X{4)EHw+&&)`kOyC5ia2%B6pqEpxZc_Fugn$EhJ zLtE=-uj!e&wy)aIysrP#_5E#=m(49L?Oi{op=D@8@7|#P9A}w(touRT{>GqwY*COP z`MH3jpG(2}&9<{$kVj;fza3fw0yuwiXJc7eW9Q`VV+LDV29N1J9~1%Pt5NBRNmXq% zHEmUsHdRhvIeq%d8I__ZQ2r&0j!TB5d?vwHg~u*YvjdA&ObnIIDO0~!EZ zajb)Ybi@n*%cTCw8LW1C)X(v2s2zryZbE4lT)@EZQv1F>uZud#{skRsCEdD{ow`kI-ovG$eQ7cAJm zs$<=(A=0ap#?(u(_+k{`sL+)g`s6n_E+Khjv~LJYCp45pC&`Rxwr_8UtFz6SDP& zsfw)<-JuW%2itbgMMPW5_7OE8Xcg+1P1%u#8U5A0OFP?_&ZylvciFm*RofQ~ZC}|I zYn@qxKHRQms860T&#mb`YyGSf<~KFW+c0b2#(6XMZCQTav0dF8&R;oq)3W|pWh4hW z$mlOsp8H|79+Xi4oy)^NttEJnCe7~khu^E#52N*?zjO}ay%GOC>^mWp6udXG2k$}m z{gPUb{XjjZf|GkVu9#z=?Zq($l!4qMC2x4xyYtOI5UYpq{>T{b^Z3<6d-lldp3?+q z2L#$EM)C?el(lxQ*Aly!0^*)6I8i8Q3nUL7aXS~z4n>jDOU2nD{i$q2Sknsz!hmhvt;<^&flocW^psxn)m32@^<=8{?i~i4@fAB2)iySxz zNYF<=Lm%ya2O;6Aq%b7tas8hK++~VTS-N0aHZpRJ{ojxW2!tm>9s*n(8@i_^3fIIaYIxY)_s}BzoBvSc>4zNDa0oDt{FPx0Q=5g+r?@?P5T!d4`|Zf#nJ&x){q4wv>)p2p&X$CFM=<)*!<<~Nd~54PcR~6=`pCB=cTPuAQ!(u| z-U2RshUghq-SNHNorkZbL{xc{h#9}-GAXh7lD@r{Qqnmzp?V?vQ178V7|7@7YVqZS zYEAkQl>*0TEhTd@p4{ZVMXk-MdpN#Vt#{8ne7y7zAkO@42*<&GFh{P+l|XnA`%rtJ_H}Y=XG5a0yheK`+xqB*2uZof=mY zXeTiT#*n$4xo9WpmdgysZ6_%rsV|cI)Aj|ns4?RwE+kBOH87w#1geeZA$dp*eraRR27y9EK%@1L0skSF~x*% zrJ?FhXlHm41!6!MJeUW=k%~PBP@2XHYtWPx!X84kLTybd6~mlvD>FSV)d1C~#;+o= z^+Ib2QcYSvg_>%>fkK-nNs%C9%pMwuK`t5vwySkti!Km#fP)MrVIGgWcF=fcusZ`N zX`D%SyVrxQQCNem`F$;#Nv^Xr6{}`_aLUY}gxYe)aquiuLu;Mb&=r}g)QIpaEZjeW z6siY-Y+NH;Sas8(A##tS%IH$L4yZE3LBmujBp>DhGR$=8Vfxo~z-I!@i30%I6M4b4 z!5we95?K&$TElTh1OSW9V_ z3<_OSTC53YGkT=a6^=%q_u-JSrpK3eAAO3ME@LfvRGN$3m{NPRMT z689fDg3*IhP_39vQ>X5%1UVvgX2xhr8$)AgJdEnDOOP?+)HW8MOJAGNp_co?@>2T` zZNtLNgni`S9<^vmm$|kK)bbYFHb!j)^bRMV!)Dr+(r-z$sWm*h!U{rTLN*o_<&3;U zv}eOjl6$d$=M!M0z>3b_+Ue3Ot5o5|2gI1IgW9xxY1#pbT6@zL6Mq(xIc%y2JF`Qvk9eHh{ zO=zviHZ9Po-vJ&h)aBPFJU^+vqtAnbIy3QL%xH%=hxKwmhJegz;nD-m8J;|(VeQ4* zx(Re@v`g1n1MRZF%qO4*9jx~3n=LTBg-(GXgHL4pIXNwT{fHTStp-ep& zkf}^<+jER-sP(*5YMFH9qfC`IdL>keM`6!LomT3P7EV124ZJ>}KfuRn73+0@pPfDz{Cim4 zeh7sbE1WX~t|@aL;1VvyBNp_uFeh3(?uwBGsIeWmnbosUbB`p=q&Yi7-A()Rb#16A zzIS+!>OM3K`00&d6NDqezi%)TvUnKT%b8Bj_ULt(*1m@q$ynX}*6OBdPo)sEg@=pX zDPrHS7cFK&_I;w)C>b*{7a*8vR<{E&@G%oqQBy!)Ql^iY#F^d*F~k1M2Ilk}6<4(o zYT}*vr|3Y$iI$-7d5O4aVNI~jx>4CaKz|dJ+9~4A2Q`BZg=Vn!%(u`4`|x*&FO@6I z#I7{hm-%c%HF|#FX%Nj`ctsBieS>C<1uNcP_#A?AwTDi7ek~^m(KbaKge|Ch_)vT; z{j8!@3C(ci0aimZ*uuup3_r)*H#iHRW-wlIe`?nd_es_Ls=b`}D>cELvUU$@dm6PJ zKkUAOMu_eDu=xH3Ga;JEo*2#O83B-s%qW0n+$o2P?Ysa-zF-EvJBu0mbpzjJGG;u% z3&npk$5MQ)Hs^txCZxG&??+lPP zrs-$UI`Cy<_7Y`^a0PHk>0k06a>_%8B=#b!Ts!VP&4HvAZWD$mgFJsuo#K=TVw$Vz ziuG|488rffws&%XH~75`2C1fzkJwps0|Cd04+ROrhlGPA;7@=>FdCBhjOgZQPtT+5 zD3?e@N-O$BgyIaFa;LzTJcaaOqz&ikfzgPMQHF%b$Vv9%Yh{2=Wy@!U0?`gevXBN3 zfg6ISYu~S1E*yC4(j6qOD#vNzm?;* z2)H#-G#m+rt5N-k?-dFKXJfF8`wEFvb2n+Xk=6P1q*=`Bo)sxW>~bc`atzH&X#fzr zR;#k|5g4Wvw{B%EBFRVDa*>hwWI>i^3$JE8y`7P;AqTpBc6P$bN#NDJo}(KAfi2Jy zL9l9CL9CuYVQ%AL*f}hWsf=^IRwZ3~$oXj6@I@^x3XoDZG3J9Vb1f-ag<5qC6vYMn z+9FK_r$Vp7Z;UTY@S4bP3_a1ch_Qj5rOY;XMU5OYmU4AZvS#f+Bx>Om+lM|BK?1xQ zIg;=61#}C%l3NCNwc}CC5#37emFRO#@Oj~zhpf*{3l-otBvS;p5g)gDZ0N!eQNucE zS|`h0TPI-%#tYBqu~aKiG-2ETiY&HO2zG=LC%)HjF#Fp`PfbnW2W8GG?#!wqR{%kY z88fN;fHjlz=*^#XJP-Lh;%?FFx`z#)ke;?iq~;fzZRoiYEvhtY^0gf4?K0Z;MTyX` z9~j%KXWY{F1NSQ<3>rEfiYjKcm1vBXG5>TQnIWL*Uk_$I6r6~U8A`em1Q>sa3kLOR zZ<=*PPz_lK5y7-n_Z;d!;@&F}+)a`~fP17BD6|M^%+i+Mt89T8IpN+Fh0BZ}GA7-} z)V(z4H#+-zpcOGIkSq2a)gmL^^op4##OV%V@do2nDL1E|c+r~X<^}IrHg$N-MJG-l zp1SNk3*uk08Yv@Lq`dGs2q=#;wAkr`(TF7ozm+Ap*eAY3{#1oQ(}ok z=AzNlP^4NP!06o>|B??QibT};XtsNzw*g~QqAyK6iBwiS`A6`^m6Xpv5`uu~t`S|_ELRUJs>xhNqQ z)#sx3ash+NivOieWvQ&T_+R7?Z@bm6R>Yg+PrPY``W60IO_6G_)wfHO&Vzf zBWT1gklcn~rk~uOZIDP>#t2O;G8Pp89?oUi@)KHM%MY~x6-aCaWE!ywT1nFa+Sai7 zr2(LRQ$BW;C1IGExWMjZe3*@$z#$93RzGDK+XGDulZ5n8`q92o#w;*UU1D-{Se=Kk z5w23w`yO)rUIcVQ-4*{BwbBe&$P3xtM0oreaUG>bn@Y!q&^u&hx?q=gbO)w33-$_8Xn=%hY&x(kQ^O6fia}IG4klg$cHb)`e90%N}Ajg4}SwMUcmYs zXh$cG4&yCGTQlr{uuh*r$s{i}c57krZSL(mi|9CgttIk!gt2a2T)VUB7Fm@z`QXy%*NvD`}wn>+mbfrm8 zGU-_+eV<7`YSPP1y4R#%GU>NXdb>#N^oLeLJ9VYEI z>5xfRoAg+dzQ?5JoAff1?lbANCjBRq-e%J8n)D|o{e?+?ZPGtNvs2IX-mQLv>t*mM zndE(*D^i|!WCI>~@Dd-q6ngLmAH2Z_6m&QtRoG@5SYHow>Pjp^xLTgA!LP?jZYO^I zp!~W5zpj&KSK-&s%d-}zKy}EoyYSxqk{iW)RNlCSW`WuvIV^%vw;`uhbt=Sk+EJ;b z>Rw~+n~=8Qz7J^+(z)h-p}Aj%bPb+wLV6t1lg<5U<{ot6?8NhnkbV&9rRM$<=Kcz# zSK;}7q@PFnRdav9+=EV>yYT$`Nbg7bsJVaK-2W8m^Dr%*m19X>z}=s4jTYanpmQr& zqKXGU^FC(o>yWnKz87f+(tdM4XzmvyU4iEtk*-6!+1zh4_uG-~!1G;5&qaDM(p^YD zX6`?U^h(@chxBTsUo`h$H}^Lqy$#PFKza|-hmbyi^f7b)Bm|s30swozbK7yd-*8es zE5q~f`{8$5L+=lS^081p(>q72oN!%XIK%aZ>&o;_Gz4?FE#Y#)b!B=dTvw)d{+i{e zCn}1HDtJATl2LOC^SJqAUg0At8Fk3M9v`tezl0I{dfd9CfuGigQTjZx3kJ9_A-{JD#Y4|gh z?{tnHjQ`y2^#1Tp=rnE(wD~5OGhAJJpK~&Dno-w zEIc1Y8pC}S(mJGrNL!FDM%sb21!*tR4y64^w;^4DbUV^@NRtKV$MAdyo_`YQxkz^) z-G%gAq!%OoI?^kV-i-8Wq{#yGF+9Hw&!0qk57OI^h6U)$xPJ!E|BUoGq|YGz4btb3 zzW6tRst+tkQXs;@^8Z_h5*7l5pMNdHL_{%?o-b|LwHWKhBow?82m8A^L;Zv z-#7j9VXjl-*opVV^oQx5?;HI2z7rtdH~8~?CqTY7_+c35`%Zv-_#*ULG{(>{q_haK zJ{PjT0;z|z#@sg{ZNq&Z(jKI9&HX}izYOUbJl}-$IHV_=`_s%l^_QJ^ei70SBE8hy zf5O~ff%Gao-;eb3NWW_C514!EGk4+n_mSR@^igyFxVir+(r5Af1*Fd-eaYPa$=ttz zbQH2bA38u5(h_qYGxv2!TkyOWX$R7Nb3bVA7b9JP=Npl(L%P}AZ!`DXk?z3rT}aPG zda=3xn7RKX(kt=&I;2-4{i3=5y1Bm@=`+RmD9k>;nySs*hA$q&8}}f6Qr-+}Gg`ZO z8PA`??|=TAcJu$QcJt-l=H^~rkECSO3M*cH@$P5T&+Y5+5u5W%xWs-Js1d=3CHD0r z{BV{1&aX!8wU6R2+Zuy&uX#^%z67*h&mmNPqGo}kz!8c`Vs0nKnoKo*h@yr>TqpyEl4QP%x+qk{5@AV zP0gqy&dVZa=4CBR?unS%W$5Y!Y1-xQM`CBa2qbVQWkmdT*)kA6cR6T)n4Iv;St=&# zf|+vN@GN2iC;RCLfu<7OdbXU=iazj3?N>pcaPrR1lj}ZFKI{bc@HYVP<$8|rB6q8L z4tph6IXj3jvmYSN?BvOE9wzP3K<;*3WHZ=aRzK;L4CcEJGORV*oY=rU7Z_8GlNO=9 z>6of)>|{0>PxLPAlev=_W(}s{w8d<*nSD%7I~j--Ds@5Cl#*CWaY=PiM^RsE_{QB@ zl#@THpkP+4+0USNKJA{Oe&jw2>q1(fqnn%~Z9$ITf*jL=Nq!4Tk>e4#lVp?1yPfh- z3&IT$_}@3Vv9PqRsHVE3sJA(kcTXwE$uxl?>wqvk~XXnEvZjbNrX?!yCwx{=?m*E^vPXj5m^yf|XS)Rd*x< zaia_oO*+oGqp7I_m%FLCYI0Sx{Nc#`xVud~uZ}Se5`rU&7rZt@ZV-xV~&scUyg|ELNZN zhfv<&E_JiK3g9NnxKbN`qDPg)uL_i!aD8x11W6kykWY26*&U&94_ zzKUIcZ}ws+7rf8suBOdNr2(ZVMGY+7a*DgO@%Cx#j|2Grz4rU$UhcJhS|l0ZVjvbh zaLSgY?u*lIZ^YLZyUW$HUN-1+4QT}Hdccl=aU4%hjDtaOeD+F4M^llVa#YI3sdyPb z587H*n_95vavRRYR;OJ!?}A+e7kb&(be?~H=QUE_dem2l`mi=KR9|}Bxl#{!yRK)M zt!I{GeB8#+s6SsgZ`ZEb7rN_BRZ`a~X9!@-^|GZVO}l6gPJ$G&;us|n zPji9vdYt+(8=FZNdaAo~u%jwxamA#vvRH9LORQpXPIddByQ-mgPGv(=XkOX zlDXTdI;c+^*Sr=y%HYH9Rm$BV+h*b%sg`D(*sHp0rZ<A=8%+h5kyTT?r& zxvZ>tT5U~lQ`!9fntuGxxY=*IGt?~}IaJJPbxx*0oa(fpm(aB!Glrlutt8Ask(v;J zrk$2FeHuN+&}ai-E|h~TorCf2~(tuu=;?{!YSruAaTn~H<-eFeU+!1rKbV3GyEZ4{^i$3dqChm!Qq!uF*Def}}o zg|3?0(?6**w;@(mRohinKDQ*2Uy)zeQQp+`WNl$@eo0}`q*#{s-VG5i($o@btU$1z za?S!g%OTaO9PB%jZzVsvG_>zbDR+zE;pEe2#H3?p51@|G~w|JhycCT9&~Sk$}2e|D0)4LxuW&+hb}eZXDn`~o~b>fPl( zdlb+94*WLi-R(cyjc0S^***TV4eoO1R*d_ocW>y~dOX9KY~FpLXRFM6-}RsEcKcDs zOuYBc`Wd1P|IwZ2obBZTnVT^ha>$==g^r73M98R-A*1HuDTZU4pL*?+C+y3o+KfB_ zDdYOE-TbzHaB zxeSyW_5Nk_B;pF3)DXa(~~u8+y;* zezIHC+BtkK>$-8Y*SQQ-9reEFHvt;xkjJYVGgzgo^s9bpIv8x6jAp}E!paygt^}cT)a7Wj;i<~>W ze+22qu-`7f63nwwWPEO_!5{;rvRY1K!-x_dz9*uDyuEXNS559U~Wy< z{CcVgi>h%dcvJnfrn@u{?HidkrJ=HFVAjqwa2?xPRW+}^p=P@e+s6P~ zkF#>3uyJczs*B}Fs{vz^?k@9D|w={iqI4a%~GWKNUNv;i|m^ULCwS4A?vaewcz{( zCvASw43Gm>6aI1i?cMQT1j8lB0j71M!`jj_=zFv&>lm60f8P!CD)I>H3hMgdC& zU@Amf3z$5>G=M`I5(a4^SUtOEUwGlH-B%|=_WWmicJA!??B@cAg4_|~%L7cw_`=8- z6R=A+pS0lQB&fU(bl+aTI{+leanx*d7pO|y4` zTyxJO(16`Ri{{mm(H$JsO81w*JYM(+5%&?7UHGBTc8`p7e-<#D0KRp=y8?Yy?f5!= zZ(7wJz6xEF*67Kmd7`V>gHp{xs}={m?4q8JUfg|AZ~MIVs+>9Hv97whu2}h;oT~PD z?!ou>o^V3%`(v>wUFD56jTII6Q{FWt2F%0{xeJ7nxK?-qn4#QA#Y~>qE$J`=4jaY) zG)Llxz}cSCsFB|4F{sh~ychjbta*B|=IJ=T#WHthxYaOMUzN(KE>bJh zGcy0x)YG8H=HNH(IPwwpbY&o7Q80S5kV8_t?M2jbzkR^hyUVb-3tR)_RTRAS4Yk|X zSV%25IM#53q<#bliQK?5vONur!>*5wG59`9o4+HO$dBE4Ye3jR4j4QZ{dg;=s|xb| zL%?7?F<1n#YcKM$ z)pOn_(8~8xM-NW8AP^6T;;}f(xdfRjak-?48|CR=(Us+8`yMPm1Lef|w3nlPLDO_*Qs^ywtul{cb&R~}q|pwR+tizoW7Mx~!nhOfb$Do@7o zW_c>fo8{%Ir@YHhM~$;Svo}kZ4O+PomE4NUJ-Uu8FE<#SK~DTwFTs!X0Pqxb=4bX} z?FUGL<83;V1=RzZt^f*VqLqq7r`Ai>sfGS~)qM&6hkrpW)9HQieUG9mk5zWq#q|PSlEM1v?((jpy9;Gq?7jk@(5+xJOA`D~A&u&- zySZ)+`<`A$Js9-d4KJgg>j9uffCp?KThx3I^q1bdMMs+Zdbox^NlPlUA2ywLi@Q}F zkqhfjX7}Cho8LV>*A;uRt#c!RqU{|3j+3DolUCk)cWt zp{)jmk4xt0_^f_4rqAkGbxY)Z=v4Zw&Kb{VMYtoN$A}gNkBhn`4G`aI7UV>P@wxhE z1knl|ybUMTj2Wk{{D|+oN^)ZTtmOmKQVu`2WKjCRWOpdbva$Gz%=FF_dq0In& zKhzsDY9&69v;9#>AULJl;jd|VFf z`>+uWU#mE+eeL{A--JFhjgcCeXN~B1JQ+>2UUV<@KU8G7pnCfer_qIneH+$!*N6+MSc8&E74(dgo^zN@+ax z?f-EOL{?$E)B0Y9){Cs*kd@xy&^FyMai-x`X( zVZO~ixluCol^=Zz-TTyd7W&)6>Cv*;BXwij>@TQF>G?e8UBD=8_AskU<%F8bkHk8q zVF4xn$V;owcCzipcS2$_{L88w(Av*thAmDZZ~E*sODe?jPw*8Bu< zc9bl7>B|I*{srgt$O5tGPY2du(Gy3-;Go4?*lx2{{eH>l1B9$Id}8)JiQ>p?`pAN` zHa*6J_om@7OEUGbortxp>;B&XpIG => apiClient.post(endpoints.USER.FEEDBACK_ANALYTICS, data), + getLogs: (data: any): Promise => + apiClient.post(endpoints.USER.LOGS, data), }; export default userService; diff --git a/frontend/src/assets/chevron-right.svg b/frontend/src/assets/chevron-right.svg new file mode 100644 index 000000000..1463b6f70 --- /dev/null +++ b/frontend/src/assets/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/CopyButton.tsx b/frontend/src/components/CopyButton.tsx index e28fdcaf5..e13f91335 100644 --- a/frontend/src/components/CopyButton.tsx +++ b/frontend/src/components/CopyButton.tsx @@ -1,16 +1,24 @@ -import { useState } from 'react'; -import Copy from './../assets/copy.svg?react'; -import CheckMark from './../assets/checkmark.svg?react'; import copy from 'copy-to-clipboard'; +import { useState } from 'react'; + +import CheckMark from '../assets/checkmark.svg?react'; +import Copy from '../assets/copy.svg?react'; -export default function CoppyButton({ text }: { text: string }) { +export default function CoppyButton({ + text, + colorLight, + colorDark, +}: { + text: string; + colorLight?: string; + colorDark?: string; +}) { const [copied, setCopied] = useState(false); const [isCopyHovered, setIsCopyHovered] = useState(false); const handleCopyClick = (text: string) => { copy(text); setCopied(true); - // Reset copied to false after a few seconds setTimeout(() => { setCopied(false); }, 3000); @@ -20,8 +28,8 @@ export default function CoppyButton({ text }: { text: string }) {

{copied ? ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 025059ac0..2009ec9c0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -420,6 +420,12 @@ template { src: url('/fonts/Inter-Variable.ttf'); } +@font-face { + font-family: 'IBMPlexMono-Medium'; + font-weight: 500; + src: url('/fonts/IBMPlexMono-Medium.ttf'); +} + ::-webkit-scrollbar { width: 0; } @@ -461,3 +467,7 @@ input:-webkit-autofill:focus { -webkit-box-orient: vertical; text-overflow: ellipsis; } + +.logs-table { + font-family: 'IBMPlexMono-Medium', system-ui; +} diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 7361d2122..645703a2b 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -65,6 +65,9 @@ }, "analytics": { "label": "Analytics" + }, + "logs": { + "label": "Logs" } }, "modals": { diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 293c4117d..49aa5d538 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -65,6 +65,9 @@ }, "analytics": { "label": "Analítica" + }, + "logs": { + "label": "Registros" } }, "modals": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 99fa85dde..9e3673304 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -65,6 +65,9 @@ }, "analytics": { "label": "分析" + }, + "logs": { + "label": "ログ" } }, "modals": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index cbd820562..81eff996d 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -65,6 +65,9 @@ }, "analytics": { "label": "分析" + }, + "logs": { + "label": "日志" } }, "modals": { diff --git a/frontend/src/settings/Logs.tsx b/frontend/src/settings/Logs.tsx new file mode 100644 index 000000000..58ab930d0 --- /dev/null +++ b/frontend/src/settings/Logs.tsx @@ -0,0 +1,175 @@ +import React from 'react'; + +import userService from '../api/services/userService'; +import ChevronRight from '../assets/chevron-right.svg'; +import Dropdown from '../components/Dropdown'; +import { APIKeyData, LogData } from './types'; +import CoppyButton from '../components/CopyButton'; + +export default function Logs() { + const [chatbots, setChatbots] = React.useState([]); + const [selectedChatbot, setSelectedChatbot] = + React.useState(); + const [logs, setLogs] = React.useState([]); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(true); + + const fetchChatbots = async () => { + try { + const response = await userService.getAPIKeys(); + if (!response.ok) { + throw new Error('Failed to fetch Chatbots'); + } + const chatbots = await response.json(); + setChatbots(chatbots); + } catch (error) { + console.error(error); + } + }; + + const fetchLogs = async () => { + try { + const response = await userService.getLogs({ + page: page, + api_key_id: selectedChatbot?.id, + page_size: 10, + }); + if (!response.ok) { + throw new Error('Failed to fetch logs'); + } + const olderLogs = await response.json(); + setLogs([...logs, ...olderLogs.logs]); + setHasMore(olderLogs.has_more); + } catch (error) { + console.error(error); + } + }; + + React.useEffect(() => { + fetchChatbots(); + }, []); + + React.useEffect(() => { + if (hasMore) fetchLogs(); + }, [page, selectedChatbot]); + return ( +
+
+
+

+ Filter by chatbot +

+ ({ + label: chatbot.name, + value: chatbot.id, + })), + { label: 'None', value: '' }, + ]} + placeholder="Select chatbot" + onSelect={(chatbot: { label: string; value: string }) => { + setSelectedChatbot( + chatbots.find((item) => item.id === chatbot.value), + ); + setLogs([]); + setPage(1); + setHasMore(true); + }} + selectedValue={ + (selectedChatbot && { + label: selectedChatbot.name, + value: selectedChatbot.id, + }) || + null + } + rounded="3xl" + border="border" + /> +
+
+
+ +
+
+ ); +} + +type LogsTableProps = { + logs: LogData[]; + setPage: React.Dispatch>; +}; + +function LogsTable({ logs, setPage }: LogsTableProps) { + const observerRef = React.useRef(); + const firstObserver = React.useCallback((node: HTMLDivElement) => { + if (observerRef.current) { + observerRef.current = new IntersectionObserver((enteries) => { + if (enteries[0].isIntersecting) setPage((prev) => prev + 1); + }); + } + if (node && observerRef.current) observerRef.current.observe(node); + }, []); + return ( +
+
+

+ API generated / chatbot conversations +

+
+
+ {logs.map((log, index) => { + if (index === logs.length - 1) { + return ( +
+ +
+ ); + } else return ; + })} +
+
+ ); +} + +function Log({ log }: { log: LogData }) { + const logLevelColor = { + info: 'text-green-500', + error: 'text-red-500', + warning: 'text-yellow-500', + }; + const { id, action, timestamp, ...filteredLog } = log; + return ( +
+ + chevron-right + +

{`${log.timestamp}`}

+

{`[${log.action}]`}

+

{`${log.question}`}

+
+
+
+

+ {JSON.stringify(filteredLog, null, 2)} +

+
+ +
+
+
+ ); +} diff --git a/frontend/src/settings/index.tsx b/frontend/src/settings/index.tsx index bffe9f7a7..47b5f1557 100644 --- a/frontend/src/settings/index.tsx +++ b/frontend/src/settings/index.tsx @@ -15,6 +15,7 @@ import Analytics from './Analytics'; import APIKeys from './APIKeys'; import Documents from './Documents'; import General from './General'; +import Logs from './Logs'; import Widgets from './Widgets'; export default function Settings() { @@ -25,6 +26,7 @@ export default function Settings() { t('settings.documents.label'), t('settings.apiKeys.label'), t('settings.analytics.label'), + t('settings.logs.label'), ]; const [activeTab, setActiveTab] = React.useState(t('settings.general.label')); const [widgetScreenshot, setWidgetScreenshot] = React.useState( @@ -133,6 +135,8 @@ export default function Settings() { return ; case t('settings.analytics.label'): return ; + case t('settings.logs.label'): + return ; default: return null; } diff --git a/frontend/src/settings/types/index.ts b/frontend/src/settings/types/index.ts index 7c04dab91..52a58f236 100644 --- a/frontend/src/settings/types/index.ts +++ b/frontend/src/settings/types/index.ts @@ -6,3 +6,15 @@ export type APIKeyData = { prompt_id: string; chunks: string; }; + +export type LogData = { + id: string; + action: string; + level: 'info' | 'error' | 'warning'; + user: string; + question: string; + response: string; + sources: Record[]; + retriever_params: Record; + timestamp: string; +}; From dbf2cabd383ac56254f17379d2e707d0233a9e44 Mon Sep 17 00:00:00 2001 From: Siddhant Rai Date: Wed, 11 Sep 2024 18:01:23 +0530 Subject: [PATCH 5/6] fix: linting issue --- application/api/answer/routes.py | 30 ++++-- application/api/user/routes.py | 167 ++++++++++++++++++++----------- 2 files changed, 128 insertions(+), 69 deletions(-) diff --git a/application/api/answer/routes.py b/application/api/answer/routes.py index e873a1cf9..da9f27755 100644 --- a/application/api/answer/routes.py +++ b/application/api/answer/routes.py @@ -38,7 +38,9 @@ gpt_model = settings.MODEL_NAME # load the prompts -current_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +current_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +) with open(os.path.join(current_dir, "prompts", "chat_combine_default.txt"), "r") as f: chat_combine_template = f.read() @@ -99,9 +101,12 @@ def get_retriever(source_id: str): return retriever_name - def is_azure_configured(): - return settings.OPENAI_API_BASE and settings.OPENAI_API_VERSION and settings.AZURE_DEPLOYMENT_NAME + return ( + settings.OPENAI_API_BASE + and settings.OPENAI_API_VERSION + and settings.AZURE_DEPLOYMENT_NAME + ) def save_conversation(conversation_id, question, response, source_log_docs, llm): @@ -274,7 +279,7 @@ def stream(): user_api_key = data["api_key"] elif "active_docs" in data: - source = {"active_docs" : data["active_docs"]} + source = {"active_docs": data["active_docs"]} retriever_name = get_retriever(data["active_docs"]) or retriever_name user_api_key = None @@ -282,12 +287,13 @@ def stream(): source = {} user_api_key = None - current_app.logger.info(f"/stream - request_data: {data}, source: {source}", - extra={"data": json.dumps({"request_data": data, "source": source})} + current_app.logger.info( + f"/stream - request_data: {data}, source: {source}", + extra={"data": json.dumps({"request_data": data, "source": source})}, ) prompt = get_prompt(prompt_id) - + retriever = RetrieverCreator.create_retriever( retriever_name, question=question, @@ -381,7 +387,7 @@ def api_answer(): retriever_name = data_key["retriever"] or retriever_name user_api_key = data["api_key"] elif "active_docs" in data: - source = {"active_docs":data["active_docs"]} + source = {"active_docs": data["active_docs"]} retriever_name = get_retriever(data["active_docs"]) or retriever_name user_api_key = None else: @@ -424,7 +430,9 @@ def api_answer(): result = {"answer": response_full, "sources": source_log_docs} result["conversation_id"] = str( - save_conversation(conversation_id, question, response_full, source_log_docs, llm) + save_conversation( + conversation_id, question, response_full, source_log_docs, llm + ) ) retriever_params = retriever.get_params() user_logs_collection.insert_one( @@ -461,10 +469,10 @@ def api_search(): if "api_key" in data: data_key = get_data_from_api_key(data["api_key"]) chunks = int(data_key["chunks"]) - source = {"active_docs":data_key["source"]} + source = {"active_docs": data_key["source"]} user_api_key = data_key["api_key"] elif "active_docs" in data: - source = {"active_docs":data["active_docs"]} + source = {"active_docs": data["active_docs"]} user_api_key = None else: source = {} diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 0f72be97f..5bdc52015 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -4,7 +4,6 @@ import uuid from urllib.parse import urlparse -import requests from bson.binary import Binary, UuidRepresentation from bson.dbref import DBRef from bson.objectid import ObjectId @@ -30,7 +29,9 @@ user = Blueprint("user", __name__) -current_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +current_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +) def generate_minute_range(start_date, end_date): @@ -81,7 +82,9 @@ def get_conversations(): conversations = conversations_collection.find().sort("date", -1).limit(30) list_conversations = [] for conversation in conversations: - list_conversations.append({"id": str(conversation["_id"]), "name": conversation["name"]}) + list_conversations.append( + {"id": str(conversation["_id"]), "name": conversation["name"]} + ) # list_conversations = [{"id": "default", "name": "default"}, {"id": "jeff", "name": "jeff"}] @@ -112,7 +115,12 @@ def api_feedback(): question = data["question"] answer = data["answer"] feedback = data["feedback"] - new_doc = {"question": question, "answer": answer, "feedback": feedback, "timestamp": datetime.datetime.now(datetime.timezone.utc)} + new_doc = { + "question": question, + "answer": answer, + "feedback": feedback, + "timestamp": datetime.datetime.now(datetime.timezone.utc), + } if "api_key" in data: new_doc["api_key"] = data["api_key"] feedback_collection.insert_one(new_doc) @@ -138,24 +146,31 @@ def delete_by_ids(): def delete_old(): """Delete old indexes.""" import shutil + source_id = request.args.get("source_id") - doc = sources_collection.find_one({ - "_id": ObjectId(source_id), - "user": "local", - }) - if(doc is None): - return {"status":"not found"},404 + doc = sources_collection.find_one( + { + "_id": ObjectId(source_id), + "user": "local", + } + ) + if doc is None: + return {"status": "not found"}, 404 if settings.VECTOR_STORE == "faiss": try: shutil.rmtree(os.path.join(current_dir, str(doc["_id"]))) except FileNotFoundError: pass else: - vetorstore = VectorCreator.create_vectorstore(settings.VECTOR_STORE, source_id=str(doc["_id"])) + vetorstore = VectorCreator.create_vectorstore( + settings.VECTOR_STORE, source_id=str(doc["_id"]) + ) vetorstore.delete_index() - sources_collection.delete_one({ - "_id": ObjectId(source_id), - }) + sources_collection.delete_one( + { + "_id": ObjectId(source_id), + } + ) return {"status": "ok"} @@ -189,7 +204,9 @@ def upload_file(): file.save(os.path.join(temp_dir, filename)) # Use shutil.make_archive to zip the temp directory - zip_path = shutil.make_archive(base_name=os.path.join(save_dir, job_name), format="zip", root_dir=temp_dir) + zip_path = shutil.make_archive( + base_name=os.path.join(save_dir, job_name), format="zip", root_dir=temp_dir + ) final_filename = os.path.basename(zip_path) # Clean up the temporary directory after zipping @@ -231,7 +248,9 @@ def upload_remote(): source_data = request.form["data"] if source_data: - task = ingest_remote.delay(source_data=source_data, job_name=job_name, user=user, loader=source) + task = ingest_remote.delay( + source_data=source_data, job_name=job_name, user=user, loader=source + ) task_id = task.id return {"status": "ok", "task_id": task_id} else: @@ -276,7 +295,9 @@ def combined_json(): "model": settings.EMBEDDINGS_NAME, "location": "local", "tokens": index["tokens"] if ("tokens" in index.keys()) else "", - "retriever": index["retriever"] if ("retriever" in index.keys()) else "classic", + "retriever": ( + index["retriever"] if ("retriever" in index.keys()) else "classic" + ), } ) if "duckduck_search" in settings.RETRIEVERS_ENABLED: @@ -345,7 +366,9 @@ def get_prompts(): list_prompts.append({"id": "creative", "name": "creative", "type": "public"}) list_prompts.append({"id": "strict", "name": "strict", "type": "public"}) for prompt in prompts: - list_prompts.append({"id": str(prompt["_id"]), "name": prompt["name"], "type": "private"}) + list_prompts.append( + {"id": str(prompt["_id"]), "name": prompt["name"], "type": "private"} + ) return jsonify(list_prompts) @@ -354,15 +377,21 @@ def get_prompts(): def get_single_prompt(): prompt_id = request.args.get("id") if prompt_id == "default": - with open(os.path.join(current_dir, "prompts", "chat_combine_default.txt"), "r") as f: + with open( + os.path.join(current_dir, "prompts", "chat_combine_default.txt"), "r" + ) as f: chat_combine_template = f.read() return jsonify({"content": chat_combine_template}) elif prompt_id == "creative": - with open(os.path.join(current_dir, "prompts", "chat_combine_creative.txt"), "r") as f: + with open( + os.path.join(current_dir, "prompts", "chat_combine_creative.txt"), "r" + ) as f: chat_reduce_creative = f.read() return jsonify({"content": chat_reduce_creative}) elif prompt_id == "strict": - with open(os.path.join(current_dir, "prompts", "chat_combine_strict.txt"), "r") as f: + with open( + os.path.join(current_dir, "prompts", "chat_combine_strict.txt"), "r" + ) as f: chat_reduce_strict = f.read() return jsonify({"content": chat_reduce_strict}) @@ -391,7 +420,9 @@ def update_prompt_name(): # check if name is null if name == "": return {"status": "error"} - prompts_collection.update_one({"_id": ObjectId(id)}, {"$set": {"name": name, "content": content}}) + prompts_collection.update_one( + {"_id": ObjectId(id)}, {"$set": {"name": name, "content": content}} + ) return {"status": "ok"} @@ -401,7 +432,7 @@ def get_api_keys(): keys = api_key_collection.find({"user": user}) list_keys = [] for key in keys: - if "source" in key and isinstance(key["source"],DBRef): + if "source" in key and isinstance(key["source"], DBRef): source = db.dereference(key["source"]) if source is None: continue @@ -411,7 +442,7 @@ def get_api_keys(): source_name = key["retriever"] else: continue - + list_keys.append( { "id": str(key["_id"]), @@ -471,8 +502,10 @@ def share_conversation(): conversation_id = data["conversation_id"] isPromptable = request.args.get("isPromptable").lower() == "true" - conversation = conversations_collection.find_one({"_id": ObjectId(conversation_id)}) - if(conversation is None): + conversation = conversations_collection.find_one( + {"_id": ObjectId(conversation_id)} + ) + if conversation is None: raise Exception("Conversation does not exist") current_n_queries = len(conversation["queries"]) @@ -484,24 +517,24 @@ def share_conversation(): chunks = "2" if "chunks" not in data else data["chunks"] name = conversation["name"] + "(shared)" - new_api_key_data = { - "prompt_id": prompt_id, - "chunks": chunks, - "user": user, - } + new_api_key_data = { + "prompt_id": prompt_id, + "chunks": chunks, + "user": user, + } if "source" in data and ObjectId.is_valid(data["source"]): - new_api_key_data["source"] = DBRef("sources",ObjectId(data["source"])) + new_api_key_data["source"] = DBRef("sources", ObjectId(data["source"])) elif "retriever" in data: new_api_key_data["retriever"] = data["retriever"] - - pre_existing_api_document = api_key_collection.find_one( - new_api_key_data - ) + + pre_existing_api_document = api_key_collection.find_one(new_api_key_data) if pre_existing_api_document: api_uuid = pre_existing_api_document["key"] pre_existing = shared_conversations_collections.find_one( { - "conversation_id": DBRef("conversations", ObjectId(conversation_id)), + "conversation_id": DBRef( + "conversations", ObjectId(conversation_id) + ), "isPromptable": isPromptable, "first_n_queries": current_n_queries, "user": user, @@ -532,33 +565,39 @@ def share_conversation(): "api_key": api_uuid, } ) - return jsonify({"success": True, "identifier": str(explicit_binary.as_uuid())}) + return jsonify( + {"success": True, "identifier": str(explicit_binary.as_uuid())} + ) else: - + api_uuid = str(uuid.uuid4()) new_api_key_data["key"] = api_uuid new_api_key_data["name"] = name if "source" in data and ObjectId.is_valid(data["source"]): - new_api_key_data["source"] = DBRef("sources", ObjectId(data["source"])) + new_api_key_data["source"] = DBRef( + "sources", ObjectId(data["source"]) + ) if "retriever" in data: new_api_key_data["retriever"] = data["retriever"] api_key_collection.insert_one(new_api_key_data) shared_conversations_collections.insert_one( - { - "uuid": explicit_binary, - "conversation_id": { - "$ref": "conversations", - "$id": ObjectId(conversation_id), - }, - "isPromptable": isPromptable, - "first_n_queries": current_n_queries, - "user": user, - "api_key": api_uuid, - } - ) + { + "uuid": explicit_binary, + "conversation_id": { + "$ref": "conversations", + "$id": ObjectId(conversation_id), + }, + "isPromptable": isPromptable, + "first_n_queries": current_n_queries, + "user": user, + "api_key": api_uuid, + } + ) ## Identifier as route parameter in frontend return ( - jsonify({"success": True, "identifier": str(explicit_binary.as_uuid())}), + jsonify( + {"success": True, "identifier": str(explicit_binary.as_uuid())} + ), 201, ) @@ -573,7 +612,9 @@ def share_conversation(): ) if pre_existing is not None: return ( - jsonify({"success": True, "identifier": str(pre_existing["uuid"].as_uuid())}), + jsonify( + {"success": True, "identifier": str(pre_existing["uuid"].as_uuid())} + ), 200, ) else: @@ -591,7 +632,9 @@ def share_conversation(): ) ## Identifier as route parameter in frontend return ( - jsonify({"success": True, "identifier": str(explicit_binary.as_uuid())}), + jsonify( + {"success": True, "identifier": str(explicit_binary.as_uuid())} + ), 201, ) except Exception as err: @@ -603,10 +646,16 @@ def share_conversation(): @user.route("/api/shared_conversation/", methods=["GET"]) def get_publicly_shared_conversations(identifier: str): try: - query_uuid = Binary.from_uuid(uuid.UUID(identifier), UuidRepresentation.STANDARD) + query_uuid = Binary.from_uuid( + uuid.UUID(identifier), UuidRepresentation.STANDARD + ) shared = shared_conversations_collections.find_one({"uuid": query_uuid}) conversation_queries = [] - if shared and "conversation_id" in shared and isinstance(shared["conversation_id"], DBRef): + if ( + shared + and "conversation_id" in shared + and isinstance(shared["conversation_id"], DBRef) + ): # Resolve the DBRef conversation_ref = shared["conversation_id"] conversation = db.dereference(conversation_ref) @@ -620,7 +669,9 @@ def get_publicly_shared_conversations(identifier: str): ), 404, ) - conversation_queries = conversation["queries"][: (shared["first_n_queries"])] + conversation_queries = conversation["queries"][ + : (shared["first_n_queries"]) + ] for query in conversation_queries: query.pop("sources") ## avoid exposing sources else: From da4f2ef6b33a758d0d36700681fa6928c1468d9a Mon Sep 17 00:00:00 2001 From: Siddhant Rai Date: Wed, 11 Sep 2024 18:02:47 +0530 Subject: [PATCH 6/6] fix: linting issue --- application/api/user/routes.py | 1 - 1 file changed, 1 deletion(-) diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 5bdc52015..a35275bf2 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -2,7 +2,6 @@ import os import shutil import uuid -from urllib.parse import urlparse from bson.binary import Binary, UuidRepresentation from bson.dbref import DBRef