Spaces:
Sleeping
Sleeping
Pygmales commited on
Commit ·
268baab
1
Parent(s): 593a090
updated project state
Browse files- .gitignore +64 -1
- config.py +30 -5
- requirements.txt +4 -0
- src/apps/chat/app.py +66 -20
- src/apps/dbapp/app.py +44 -0
- src/apps/dbapp/backup.py +191 -0
- src/apps/dbapp/collections.py +8 -0
- src/apps/dbapp/config.py +348 -0
- src/apps/dbapp/framebase.py +15 -0
- src/apps/dbapp/imports.py +158 -0
- src/apps/dbapp/mainframe.py +8 -0
- src/apps/dbapp/query.py +8 -0
- src/apps/dbapp/utilclasses.py +38 -0
- src/cache/__init__.py +0 -0
- src/cache/cache.py +72 -0
- src/cache/cache_base.py +19 -0
- src/cache/cache_metrics.py +28 -0
- src/cache/cache_strategies.py +89 -0
- src/const/agent_response_constants.py +66 -80
- src/database/docker-compose-cache.yml +27 -0
- src/database/redisservice.py +54 -0
- src/database/weavservice.py +185 -40
- src/pipeline/pipeline.py +74 -98
- src/pipeline/processors.py +280 -0
- src/pipeline/utilclasses.py +15 -0
- src/rag/agent_chain.py +112 -81
- src/rag/input_handler.py +1 -0
- src/rag/language_detection.py +75 -0
- src/rag/prompts.py +237 -83
- src/rag/response_formatter.py +57 -39
- src/rag/scope_guardian.py +12 -9
- src/rag/utilclasses.py +26 -10
- src/utils/lang.py +3 -3
- src/utils/stratutils/generator.py +8 -0
- src/utils/stratutils/templates.py +31 -0
.gitignore
CHANGED
|
@@ -1,2 +1,65 @@
|
|
|
|
|
| 1 |
__pycache__/
|
| 2 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# Virtual environment
|
| 7 |
+
.env
|
| 8 |
+
.venv/
|
| 9 |
+
env/
|
| 10 |
+
venv/
|
| 11 |
+
ENV/
|
| 12 |
+
env.bak/
|
| 13 |
+
venv.bak/
|
| 14 |
+
|
| 15 |
+
# Environment variables
|
| 16 |
+
.env
|
| 17 |
+
|
| 18 |
+
# VS Code settings
|
| 19 |
+
.vscode/
|
| 20 |
+
|
| 21 |
+
# MacOS system files
|
| 22 |
+
.DS_Store
|
| 23 |
+
|
| 24 |
+
# Jupyter Notebook checkpoints
|
| 25 |
+
.ipynb_checkpoints/
|
| 26 |
+
|
| 27 |
+
# Logs
|
| 28 |
+
*.log
|
| 29 |
+
|
| 30 |
+
# Cache and temp files
|
| 31 |
+
*.tmp
|
| 32 |
+
*.swp
|
| 33 |
+
*.bak
|
| 34 |
+
.cache/
|
| 35 |
+
*.sqlite3
|
| 36 |
+
*.db
|
| 37 |
+
|
| 38 |
+
# Data files
|
| 39 |
+
*.pdf
|
| 40 |
+
*.json
|
| 41 |
+
|
| 42 |
+
# Output folders
|
| 43 |
+
dist/
|
| 44 |
+
build/
|
| 45 |
+
*.egg-info/
|
| 46 |
+
|
| 47 |
+
# Output data
|
| 48 |
+
data/
|
| 49 |
+
|
| 50 |
+
# Pycharm
|
| 51 |
+
.idea/
|
| 52 |
+
|
| 53 |
+
# OS junk
|
| 54 |
+
.Trashes.env
|
| 55 |
+
.env
|
| 56 |
+
.env
|
| 57 |
+
|
| 58 |
+
#idk
|
| 59 |
+
--source-branch
|
| 60 |
+
--source-repo
|
| 61 |
+
/.gradio/certificate.pem
|
| 62 |
+
|
| 63 |
+
#feedback I just uploaded into the same file to check for accuracy
|
| 64 |
+
chatbot emba x.docx
|
| 65 |
+
IEBMA Test Cards 1_2.docx
|
config.py
CHANGED
|
@@ -128,7 +128,11 @@ class WeaviateConfiguration:
|
|
| 128 |
# Weaviate backup settings
|
| 129 |
AVAILABLE_BACKUP_METHODS = ['manual', 'filesystem', 's3']
|
| 130 |
BACKUP_METHOD = 'manual'
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
# Weaviate Cloud settings
|
| 134 |
CLUSTER_URL = os.getenv('WEAVIATE_CLUSTER_URL')
|
|
@@ -137,13 +141,31 @@ class WeaviateConfiguration:
|
|
| 137 |
|
| 138 |
# Custom timeouts for Cloud connection (in seconds)
|
| 139 |
INIT_TIMEOUT = 90
|
| 140 |
-
QUERY_TIMEOUT =
|
| 141 |
-
INSERT_TIMEOUT =
|
| 142 |
|
| 143 |
@classmethod
|
| 144 |
def is_local(cls) -> bool:
|
| 145 |
return cls.LOCAL_DATABASE
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
# Data paths
|
| 148 |
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
| 149 |
RAW_DATA_PATH = os.path.join(DATA_DIR, "raw_data.json")
|
|
@@ -153,8 +175,11 @@ VECTORDB_PATH = os.path.join(DATA_DIR, "vectordb")
|
|
| 153 |
# Determines when the text is considered German during the language detection
|
| 154 |
LANG_AMBIGUITY_THRESHOLD = 0.6
|
| 155 |
|
|
|
|
|
|
|
|
|
|
| 156 |
# Vector database settings
|
| 157 |
-
CHUNK_SIZE =
|
| 158 |
CHUNK_OVERLAP = 200
|
| 159 |
|
| 160 |
# Agent Chain settings
|
|
@@ -176,7 +201,7 @@ ENABLE_EVALUATE_RESPONSE_QUALITY = True
|
|
| 176 |
|
| 177 |
# Conversation state settings
|
| 178 |
TRACK_USER_PROFILE = True # Track user preferences and avoid repetition
|
| 179 |
-
|
| 180 |
MAX_CONVERSATION_TURNS = 15 # End conversation after max turns reached
|
| 181 |
|
| 182 |
# Data processing pipeline settings
|
|
|
|
| 128 |
# Weaviate backup settings
|
| 129 |
AVAILABLE_BACKUP_METHODS = ['manual', 'filesystem', 's3']
|
| 130 |
BACKUP_METHOD = 'manual'
|
| 131 |
+
|
| 132 |
+
# Weaviate generated data paths
|
| 133 |
+
BACKUP_PATH = 'data/database/backups'
|
| 134 |
+
PROPERTIES_PATH = 'data/database/properties'
|
| 135 |
+
STRATEGIES_PATH = 'data/database/strategies'
|
| 136 |
|
| 137 |
# Weaviate Cloud settings
|
| 138 |
CLUSTER_URL = os.getenv('WEAVIATE_CLUSTER_URL')
|
|
|
|
| 141 |
|
| 142 |
# Custom timeouts for Cloud connection (in seconds)
|
| 143 |
INIT_TIMEOUT = 90
|
| 144 |
+
QUERY_TIMEOUT = 60
|
| 145 |
+
INSERT_TIMEOUT = 600
|
| 146 |
|
| 147 |
@classmethod
|
| 148 |
def is_local(cls) -> bool:
|
| 149 |
return cls.LOCAL_DATABASE
|
| 150 |
|
| 151 |
+
# Cache settings
|
| 152 |
+
class CacheConfig:
|
| 153 |
+
LOCAL_HOST = "localhost"
|
| 154 |
+
LOCAL_PORT = 6379
|
| 155 |
+
LOCAL_PASS = os.getenv("REDIS_LOCAL_PASSWORD", "")
|
| 156 |
+
|
| 157 |
+
CLOUD_HOST = os.getenv("REDIS_CLOUD_HOST")
|
| 158 |
+
CLOUD_PORT = int(os.getenv("REDIS_CLOUD_PORT", 6379))
|
| 159 |
+
CLOUD_PASS = os.getenv("REDIS_CLOUD_PASSWORD")
|
| 160 |
+
|
| 161 |
+
CACHE_LOCAL = "local"
|
| 162 |
+
CACHE_CLOUD = "cloud"
|
| 163 |
+
CACHE_DICT = "dict"
|
| 164 |
+
CACHE_MODE = "cloud" # 'local' or 'cloud' or 'dict' set here the default cache mode
|
| 165 |
+
|
| 166 |
+
TTL_CACHE = 86400 # 86400 seconds = 24 hours
|
| 167 |
+
MAX_SIZE_CACHE = 1000
|
| 168 |
+
|
| 169 |
# Data paths
|
| 170 |
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
| 171 |
RAW_DATA_PATH = os.path.join(DATA_DIR, "raw_data.json")
|
|
|
|
| 175 |
# Determines when the text is considered German during the language detection
|
| 176 |
LANG_AMBIGUITY_THRESHOLD = 0.6
|
| 177 |
|
| 178 |
+
# Confidence Threshold to activate fall-back mechanism
|
| 179 |
+
CONFIDENCE_THRESHOLD = 0.6
|
| 180 |
+
|
| 181 |
# Vector database settings
|
| 182 |
+
CHUNK_SIZE = 512
|
| 183 |
CHUNK_OVERLAP = 200
|
| 184 |
|
| 185 |
# Agent Chain settings
|
|
|
|
| 201 |
|
| 202 |
# Conversation state settings
|
| 203 |
TRACK_USER_PROFILE = True # Track user preferences and avoid repetition
|
| 204 |
+
LOCK_LANGUAGE_AFTER_N_MESSAGES = 3 # Lock language after N user messages (0 = never lock)
|
| 205 |
MAX_CONVERSATION_TURNS = 15 # End conversation after max turns reached
|
| 206 |
|
| 207 |
# Data processing pipeline settings
|
requirements.txt
CHANGED
|
@@ -35,3 +35,7 @@ docling>=2.55.0
|
|
| 35 |
|
| 36 |
# Weaviate Vector DB
|
| 37 |
weaviate-client>=4.16.9
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
# Weaviate Vector DB
|
| 37 |
weaviate-client>=4.16.9
|
| 38 |
+
|
| 39 |
+
# Cache
|
| 40 |
+
cachetools>=5.0.0
|
| 41 |
+
redis>=4.5.5
|
src/apps/chat/app.py
CHANGED
|
@@ -1,17 +1,19 @@
|
|
| 1 |
import os
|
| 2 |
import gradio as gr
|
| 3 |
-
from src.apps.chat.js import JS_LISTENER, JS_CLEAR
|
| 4 |
from src.const.agent_response_constants import *
|
| 5 |
from src.rag.agent_chain import ExecutiveAgentChain
|
| 6 |
from src.rag.utilclasses import LeadAgentQueryResponse
|
| 7 |
from src.utils.logging import get_logger
|
|
|
|
| 8 |
|
| 9 |
logger = get_logger("chatbot_app")
|
|
|
|
| 10 |
|
| 11 |
class ChatbotApplication:
|
| 12 |
def __init__(self, language: str = 'de') -> None:
|
| 13 |
-
self._app = gr.Blocks(
|
| 14 |
self._language = language
|
|
|
|
| 15 |
|
| 16 |
with self._app:
|
| 17 |
agent_state = gr.State(None)
|
|
@@ -26,6 +28,11 @@ class ChatbotApplication:
|
|
| 26 |
)
|
| 27 |
reset_button = gr.Button("Reset Conversation")
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
chat = gr.ChatInterface(
|
| 30 |
fn=lambda msg, history, agent: self._chat(
|
| 31 |
message=msg,
|
|
@@ -35,12 +42,8 @@ class ChatbotApplication:
|
|
| 35 |
additional_inputs=[agent_state],
|
| 36 |
title="Executive Education Adviser",
|
| 37 |
type='messages',
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
iframe_container = gr.HTML(
|
| 41 |
-
value="",
|
| 42 |
-
elem_id="consultation-iframe-container",
|
| 43 |
-
visible=True
|
| 44 |
)
|
| 45 |
|
| 46 |
def clear_chat_immediate():
|
|
@@ -66,29 +69,27 @@ class ChatbotApplication:
|
|
| 66 |
|
| 67 |
lang_selector.change(
|
| 68 |
fn=clear_chat_immediate,
|
| 69 |
-
outputs=[chat.chatbot_value
|
| 70 |
queue=True,
|
| 71 |
-
js=JS_CLEAR
|
| 72 |
)
|
| 73 |
|
| 74 |
lang_selector.change(
|
| 75 |
fn=on_lang_change,
|
| 76 |
inputs=[lang_selector],
|
| 77 |
-
outputs=[agent_state, lang_state, chat.chatbot_value
|
| 78 |
queue=True,
|
| 79 |
)
|
| 80 |
|
| 81 |
reset_button.click(
|
| 82 |
fn=clear_chat_immediate,
|
| 83 |
-
outputs=[chat.chatbot_value
|
| 84 |
queue=True,
|
| 85 |
-
js=JS_CLEAR
|
| 86 |
)
|
| 87 |
|
| 88 |
reset_button.click(
|
| 89 |
fn=switch_language,
|
| 90 |
inputs=[lang_state],
|
| 91 |
-
outputs=[agent_state, lang_state, chat.chatbot_value
|
| 92 |
queue=True,
|
| 93 |
)
|
| 94 |
|
|
@@ -112,12 +113,57 @@ class ChatbotApplication:
|
|
| 112 |
try:
|
| 113 |
logger.info(f"Processing user query: {message[:100]}...")
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
except Exception as e:
|
| 123 |
logger.error(f"Error processing query: {e}", exc_info=True)
|
|
|
|
| 1 |
import os
|
| 2 |
import gradio as gr
|
|
|
|
| 3 |
from src.const.agent_response_constants import *
|
| 4 |
from src.rag.agent_chain import ExecutiveAgentChain
|
| 5 |
from src.rag.utilclasses import LeadAgentQueryResponse
|
| 6 |
from src.utils.logging import get_logger
|
| 7 |
+
from src.cache.cache import Cache
|
| 8 |
|
| 9 |
logger = get_logger("chatbot_app")
|
| 10 |
+
cache_logger = get_logger("cache_chatbot_app")
|
| 11 |
|
| 12 |
class ChatbotApplication:
|
| 13 |
def __init__(self, language: str = 'de') -> None:
|
| 14 |
+
self._app = gr.Blocks()
|
| 15 |
self._language = language
|
| 16 |
+
self._cache = Cache.get_cache()
|
| 17 |
|
| 18 |
with self._app:
|
| 19 |
agent_state = gr.State(None)
|
|
|
|
| 28 |
)
|
| 29 |
reset_button = gr.Button("Reset Conversation")
|
| 30 |
|
| 31 |
+
chatbot = gr.Chatbot(
|
| 32 |
+
height=600,
|
| 33 |
+
type='messages',
|
| 34 |
+
label="Executive Education Adviser"
|
| 35 |
+
)
|
| 36 |
chat = gr.ChatInterface(
|
| 37 |
fn=lambda msg, history, agent: self._chat(
|
| 38 |
message=msg,
|
|
|
|
| 42 |
additional_inputs=[agent_state],
|
| 43 |
title="Executive Education Adviser",
|
| 44 |
type='messages',
|
| 45 |
+
chatbot=chatbot,
|
| 46 |
+
fill_height=True
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
)
|
| 48 |
|
| 49 |
def clear_chat_immediate():
|
|
|
|
| 69 |
|
| 70 |
lang_selector.change(
|
| 71 |
fn=clear_chat_immediate,
|
| 72 |
+
outputs=[chat.chatbot_value],
|
| 73 |
queue=True,
|
|
|
|
| 74 |
)
|
| 75 |
|
| 76 |
lang_selector.change(
|
| 77 |
fn=on_lang_change,
|
| 78 |
inputs=[lang_selector],
|
| 79 |
+
outputs=[agent_state, lang_state, chat.chatbot_value],
|
| 80 |
queue=True,
|
| 81 |
)
|
| 82 |
|
| 83 |
reset_button.click(
|
| 84 |
fn=clear_chat_immediate,
|
| 85 |
+
outputs=[chat.chatbot_value],
|
| 86 |
queue=True,
|
|
|
|
| 87 |
)
|
| 88 |
|
| 89 |
reset_button.click(
|
| 90 |
fn=switch_language,
|
| 91 |
inputs=[lang_state],
|
| 92 |
+
outputs=[agent_state, lang_state, chat.chatbot_value],
|
| 93 |
queue=True,
|
| 94 |
)
|
| 95 |
|
|
|
|
| 113 |
try:
|
| 114 |
logger.info(f"Processing user query: {message[:100]}...")
|
| 115 |
|
| 116 |
+
preprocess_resp = agent.preprocess_query(message)
|
| 117 |
+
final_response: LeadAgentQueryResponse = None
|
| 118 |
+
|
| 119 |
+
current_lang = preprocess_resp.language
|
| 120 |
+
processed_q = preprocess_resp.processed_query
|
| 121 |
+
|
| 122 |
+
if preprocess_resp.response:
|
| 123 |
+
# Response comes from preprocessing step
|
| 124 |
+
final_response = preprocess_resp
|
| 125 |
+
|
| 126 |
+
elif Cache._settings["enabled"]:
|
| 127 |
+
cached_data = self._cache.get(processed_q, language=current_lang)
|
| 128 |
+
|
| 129 |
+
if cached_data:
|
| 130 |
+
# Cache Hit — restore response with metadata
|
| 131 |
+
if isinstance(cached_data, dict):
|
| 132 |
+
final_response = LeadAgentQueryResponse(
|
| 133 |
+
response=cached_data["response"],
|
| 134 |
+
language=current_lang,
|
| 135 |
+
appointment_requested=cached_data.get("appointment_requested", False),
|
| 136 |
+
relevant_programs=cached_data.get("relevant_programs", []),
|
| 137 |
+
)
|
| 138 |
+
else:
|
| 139 |
+
# Legacy: plain string cache entry
|
| 140 |
+
final_response = LeadAgentQueryResponse(
|
| 141 |
+
response=cached_data,
|
| 142 |
+
language=current_lang,
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
if not final_response:
|
| 146 |
+
# Response needs to be generated by the agent
|
| 147 |
+
final_response = agent.agent_query(processed_q)
|
| 148 |
+
|
| 149 |
+
answers.append(final_response.response)
|
| 150 |
+
self._language = final_response.language
|
| 151 |
+
|
| 152 |
+
if final_response.confidence_fallback or final_response.max_turns_reached or final_response.appointment_requested:
|
| 153 |
+
html_code = get_booking_widget(language=self._language, programs=final_response.relevant_programs)
|
| 154 |
+
answers.append(gr.HTML(value=html_code))
|
| 155 |
+
|
| 156 |
+
if final_response.should_cache and Cache._settings["enabled"]:
|
| 157 |
+
# Caching response with metadata
|
| 158 |
+
self._cache.set(
|
| 159 |
+
key=processed_q,
|
| 160 |
+
value={
|
| 161 |
+
"response": final_response.response,
|
| 162 |
+
"appointment_requested": final_response.appointment_requested,
|
| 163 |
+
"relevant_programs": final_response.relevant_programs,
|
| 164 |
+
},
|
| 165 |
+
language=current_lang
|
| 166 |
+
)
|
| 167 |
|
| 168 |
except Exception as e:
|
| 169 |
logger.error(f"Error processing query: {e}", exc_info=True)
|
src/apps/dbapp/app.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from tkinter import *
|
| 2 |
+
from tkinter import ttk
|
| 3 |
+
from src.database.weavservice import WeaviateService
|
| 4 |
+
|
| 5 |
+
from src.apps.dbapp.mainframe import MainFrame
|
| 6 |
+
from src.apps.dbapp.query import QueryFrame
|
| 7 |
+
from src.apps.dbapp.imports import ImportFrame
|
| 8 |
+
from src.apps.dbapp.backup import BackupsFrame
|
| 9 |
+
from src.apps.dbapp.collections import CollectionsFrame
|
| 10 |
+
from src.apps.dbapp.config import SchemaConfigurationFrame
|
| 11 |
+
|
| 12 |
+
from src.utils.logging import get_logger
|
| 13 |
+
|
| 14 |
+
logger = get_logger("db_inter ")
|
| 15 |
+
|
| 16 |
+
class DatabaseApplication:
|
| 17 |
+
def __init__(self) -> None:
|
| 18 |
+
self._root = Tk()
|
| 19 |
+
self._service = WeaviateService()
|
| 20 |
+
|
| 21 |
+
self._root.title("Database Interface")
|
| 22 |
+
self._root.geometry("810x500")
|
| 23 |
+
|
| 24 |
+
notebook = ttk.Notebook(self._root)
|
| 25 |
+
notebook.pack(fill=BOTH, expand=True)
|
| 26 |
+
|
| 27 |
+
main_frame = MainFrame(notebook, self._service).init()
|
| 28 |
+
import_frame = ImportFrame(notebook, self._service).init()
|
| 29 |
+
config_frame = SchemaConfigurationFrame(notebook, self._service).init()
|
| 30 |
+
collections_frame = CollectionsFrame(notebook, self._service).init()
|
| 31 |
+
query_frame = QueryFrame(notebook, self._service).init()
|
| 32 |
+
backups_frame = BackupsFrame(notebook, self._service).init()
|
| 33 |
+
|
| 34 |
+
notebook.add(main_frame, text='Main')
|
| 35 |
+
notebook.add(import_frame, text='Import')
|
| 36 |
+
notebook.add(config_frame, text='Schemas')
|
| 37 |
+
notebook.add(collections_frame, text='Collections')
|
| 38 |
+
notebook.add(query_frame, text='Query')
|
| 39 |
+
notebook.add(backups_frame, text='Backups')
|
| 40 |
+
|
| 41 |
+
logger.info("Application initialization finished")
|
| 42 |
+
|
| 43 |
+
def run(self):
|
| 44 |
+
self._root.mainloop()
|
src/apps/dbapp/backup.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, shutil
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
|
| 4 |
+
from tkinter import *
|
| 5 |
+
from tkinter import ttk
|
| 6 |
+
from src.database.weavservice import WeaviateService
|
| 7 |
+
from src.apps.dbapp.framebase import CustomFrameBase
|
| 8 |
+
from src.apps.dbapp.utilclasses import BackupData
|
| 9 |
+
from config import WeaviateConfiguration as wvtconf
|
| 10 |
+
|
| 11 |
+
def _load_backup_files():
|
| 12 |
+
backups = []
|
| 13 |
+
os.makedirs(wvtconf.BACKUP_PATH, exist_ok=True)
|
| 14 |
+
|
| 15 |
+
for backup_id in os.listdir(wvtconf.BACKUP_PATH):
|
| 16 |
+
backups.append(BackupData(backup_id))
|
| 17 |
+
|
| 18 |
+
return backups
|
| 19 |
+
|
| 20 |
+
class BackupsFrame(CustomFrameBase):
|
| 21 |
+
def __init__(self, parent, service: WeaviateService):
|
| 22 |
+
super().__init__(parent, service)
|
| 23 |
+
self._backups = _load_backup_files()
|
| 24 |
+
|
| 25 |
+
def init(self) -> ttk.Frame:
|
| 26 |
+
self._backups = _load_backup_files()
|
| 27 |
+
|
| 28 |
+
main_frame = ttk.Frame(self._parent)
|
| 29 |
+
main_frame.pack(fill=BOTH, expand=True)
|
| 30 |
+
|
| 31 |
+
tree_frame = ttk.Frame(main_frame)
|
| 32 |
+
tree_frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
|
| 33 |
+
|
| 34 |
+
label_frame = ttk.Frame(main_frame)
|
| 35 |
+
label_frame.pack(fill=X, expand=True, padx=10, pady=10)
|
| 36 |
+
|
| 37 |
+
button_frame = ttk.Frame(main_frame)
|
| 38 |
+
button_frame.pack(fill=X, padx=10, pady=10)
|
| 39 |
+
|
| 40 |
+
date_reverse_sort = True
|
| 41 |
+
columns = ('date', 'size')
|
| 42 |
+
|
| 43 |
+
info_label = ttk.Label(label_frame, text="", padding=8)
|
| 44 |
+
|
| 45 |
+
def _print_label(msg, backc, forc):
|
| 46 |
+
info_label.configure(text=msg, foreground=forc, background=backc)
|
| 47 |
+
info_label.update_idletasks()
|
| 48 |
+
|
| 49 |
+
def print_failure(msg: str):
|
| 50 |
+
_print_label(msg, "#FFCDD2", "#B71C1C")
|
| 51 |
+
|
| 52 |
+
def print_info(msg: str):
|
| 53 |
+
_print_label(msg, "#cdedff", "#1c31b7")
|
| 54 |
+
|
| 55 |
+
def print_success(msg: str):
|
| 56 |
+
_print_label(msg, "#d7ffcd", "#4db71c")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
tree = ttk.Treeview(
|
| 60 |
+
tree_frame,
|
| 61 |
+
columns=columns,
|
| 62 |
+
show='tree headings',
|
| 63 |
+
selectmode='browse',
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
def sort_by_date():
|
| 67 |
+
nonlocal date_reverse_sort
|
| 68 |
+
|
| 69 |
+
parents = tree.get_children("")
|
| 70 |
+
data = []
|
| 71 |
+
|
| 72 |
+
for p in parents:
|
| 73 |
+
value = tree.set(p, 'date')
|
| 74 |
+
try:
|
| 75 |
+
value = datetime.strptime(value, "%d.%m.%Y %H:%M:%S")
|
| 76 |
+
except Exception:
|
| 77 |
+
pass
|
| 78 |
+
data.append((value, p))
|
| 79 |
+
|
| 80 |
+
data.sort(reverse=date_reverse_sort)
|
| 81 |
+
date_reverse_sort = not date_reverse_sort
|
| 82 |
+
|
| 83 |
+
for index, (_, p) in enumerate(data):
|
| 84 |
+
tree.move(p, "", index)
|
| 85 |
+
|
| 86 |
+
tree.heading(
|
| 87 |
+
'date',
|
| 88 |
+
text='Created at ' + ('▾' if date_reverse_sort else '▴'),
|
| 89 |
+
command=lambda: sort_by_date()
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
tree.heading('#0', text='Backup ID')
|
| 93 |
+
tree.heading('date', text='Created at ▾', command=lambda: sort_by_date())
|
| 94 |
+
tree.heading('size', text='Embeddings amount')
|
| 95 |
+
|
| 96 |
+
tree.column("#0", width=100)
|
| 97 |
+
tree.column("date", width=60)
|
| 98 |
+
tree.column("size", width=30)
|
| 99 |
+
|
| 100 |
+
def insert_backup(backup):
|
| 101 |
+
nonlocal date_reverse_sort
|
| 102 |
+
bk = backup.to_treeformat()
|
| 103 |
+
parent = tree.insert('', 0 if not date_reverse_sort else END,
|
| 104 |
+
text=bk['id'],
|
| 105 |
+
values=bk['date']
|
| 106 |
+
)
|
| 107 |
+
for collection in bk['collections']:
|
| 108 |
+
tree.insert(parent, END,
|
| 109 |
+
text=collection['name'],
|
| 110 |
+
values=collection['size'],
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
for backup in self._backups:
|
| 114 |
+
insert_backup(backup)
|
| 115 |
+
sort_by_date()
|
| 116 |
+
|
| 117 |
+
def create_backup():
|
| 118 |
+
print_info(f"Creating new backup...")
|
| 119 |
+
backup_id = self._service._create_backup()
|
| 120 |
+
|
| 121 |
+
backup = BackupData(backup_id)
|
| 122 |
+
self._backups.append(backup)
|
| 123 |
+
insert_backup(backup)
|
| 124 |
+
print_success(f"Successfully created new backup {backup._backup_id}!")
|
| 125 |
+
|
| 126 |
+
def restore_backup():
|
| 127 |
+
item_id = tree.selection()[0]
|
| 128 |
+
backup = tree.item(item_id)
|
| 129 |
+
|
| 130 |
+
print_info(f"Restoring backup {backup['text']}...")
|
| 131 |
+
self._service._restore_backup('backup_' + backup['text'])
|
| 132 |
+
print_success(f"Successfully restored backup {backup['text']}!")
|
| 133 |
+
|
| 134 |
+
def delete_backup():
|
| 135 |
+
item_id = tree.selection()[0]
|
| 136 |
+
backup = tree.item(item_id)
|
| 137 |
+
|
| 138 |
+
backup_path = os.path.join(wvtconf.BACKUP_PATH, 'backup_' + backup['text'])
|
| 139 |
+
shutil.rmtree(backup_path, ignore_errors=True)
|
| 140 |
+
|
| 141 |
+
tree.delete(item_id)
|
| 142 |
+
print_success(f"Deleted backup {backup['text']}.")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
create_bkp_btn = ttk.Button(
|
| 146 |
+
button_frame,
|
| 147 |
+
text="Create Backup",
|
| 148 |
+
command=create_backup
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
restore_bkp_btn = ttk.Button(
|
| 152 |
+
button_frame,
|
| 153 |
+
text="Restore Backup",
|
| 154 |
+
command=restore_backup,
|
| 155 |
+
state=['disabled']
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
delete_bkp_btn = ttk.Button(
|
| 159 |
+
button_frame,
|
| 160 |
+
text="Delete Backup",
|
| 161 |
+
command=delete_backup,
|
| 162 |
+
state=['disabled']
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
def on_item_selection(event):
|
| 166 |
+
selected = tree.selection()
|
| 167 |
+
if not selected:
|
| 168 |
+
restore_bkp_btn.state(['disabled'])
|
| 169 |
+
delete_bkp_btn.state(['disabled'])
|
| 170 |
+
return
|
| 171 |
+
|
| 172 |
+
item_id = selected[0]
|
| 173 |
+
is_parent = tree.parent(item_id) == ''
|
| 174 |
+
restore_bkp_btn.state(['!disabled' if is_parent else 'disabled'])
|
| 175 |
+
delete_bkp_btn.state(['!disabled' if is_parent else 'disabled'])
|
| 176 |
+
|
| 177 |
+
tree.bind("<<TreeviewSelect>>", on_item_selection)
|
| 178 |
+
|
| 179 |
+
scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=tree.yview)
|
| 180 |
+
tree.configure(yscrollcommand=scrollbar.set)
|
| 181 |
+
|
| 182 |
+
info_label.pack()
|
| 183 |
+
|
| 184 |
+
tree.pack(side=LEFT, fill=BOTH, expand=True)
|
| 185 |
+
scrollbar.pack(side=RIGHT, fill=Y)
|
| 186 |
+
|
| 187 |
+
create_bkp_btn.pack(side=LEFT, padx=5)
|
| 188 |
+
restore_bkp_btn.pack(side=RIGHT, padx=5)
|
| 189 |
+
delete_bkp_btn.pack(side=RIGHT, padx=5)
|
| 190 |
+
|
| 191 |
+
return main_frame
|
src/apps/dbapp/collections.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from tkinter import *
|
| 2 |
+
from tkinter import ttk
|
| 3 |
+
from src.apps.dbapp.framebase import CustomFrameBase
|
| 4 |
+
from src.database.weavservice import WeaviateService
|
| 5 |
+
|
| 6 |
+
class CollectionsFrame(CustomFrameBase):
|
| 7 |
+
def __init__(self, parent, service: WeaviateService) -> None:
|
| 8 |
+
super().__init__(parent, service)
|
src/apps/dbapp/config.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, json
|
| 2 |
+
|
| 3 |
+
from tkinter import *
|
| 4 |
+
from tkinter import ttk
|
| 5 |
+
from src.apps.dbapp.framebase import CustomFrameBase
|
| 6 |
+
from src.utils.stratutils.generator import generate_strategy
|
| 7 |
+
from src.database.weavservice import WeaviateService
|
| 8 |
+
from config import WeaviateConfiguration as wvtconf
|
| 9 |
+
|
| 10 |
+
def _dump_schema(schema):
|
| 11 |
+
os.makedirs(wvtconf.PROPERTIES_PATH, exist_ok=True)
|
| 12 |
+
properties_file_path = os.path.join(wvtconf.PROPERTIES_PATH, 'properties.json')
|
| 13 |
+
with open(properties_file_path, 'w', encoding='utf-8') as f:
|
| 14 |
+
json.dump(schema, f, indent=2, default=str)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class SchemaConfigurationFrame(CustomFrameBase):
|
| 18 |
+
def __init__(self, parent, service: WeaviateService) -> None:
|
| 19 |
+
super().__init__(parent, service)
|
| 20 |
+
self._schema = self._load_schema_data()
|
| 21 |
+
self._strategies = self._load_strategies()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _load_strategies(self) -> dict:
|
| 25 |
+
os.makedirs(wvtconf.STRATEGIES_PATH, exist_ok=True)
|
| 26 |
+
loaded_strats = os.listdir(wvtconf.STRATEGIES_PATH)
|
| 27 |
+
strategies = {}
|
| 28 |
+
|
| 29 |
+
for name, prop in self._schema.items():
|
| 30 |
+
strategy_file = f"strat_{name}.py"
|
| 31 |
+
file_path = os.path.join(wvtconf.STRATEGIES_PATH, strategy_file)
|
| 32 |
+
strategy_content = ""
|
| 33 |
+
|
| 34 |
+
if strategy_file not in loaded_strats:
|
| 35 |
+
strategy_content = generate_strategy(name, prop)
|
| 36 |
+
with open(file_path, 'w', encoding='utf-8') as f:
|
| 37 |
+
f.write(strategy_content)
|
| 38 |
+
else:
|
| 39 |
+
with open(file_path) as f:
|
| 40 |
+
strategy_content = f.read()
|
| 41 |
+
|
| 42 |
+
strategies[name] = strategy_content
|
| 43 |
+
|
| 44 |
+
return strategies
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _save_strategy(self, name, strategy) -> None:
|
| 48 |
+
os.makedirs(wvtconf.STRATEGIES_PATH, exist_ok=True)
|
| 49 |
+
self._strategies[name] = strategy
|
| 50 |
+
|
| 51 |
+
file_path = os.path.join(wvtconf.STRATEGIES_PATH, f"strat_{name}.py")
|
| 52 |
+
with open(file_path, 'w', encoding='utf-8') as f:
|
| 53 |
+
f.write(strategy)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _load_schema_data(self) -> dict:
|
| 57 |
+
schema = self._service._extract_data()['schema'][0]
|
| 58 |
+
|
| 59 |
+
schema_data = {}
|
| 60 |
+
|
| 61 |
+
for prop in schema['properties']:
|
| 62 |
+
data_property = {
|
| 63 |
+
'description': prop.get('description', ''),
|
| 64 |
+
'data_type': prop['dataType'][0],
|
| 65 |
+
'filterable': prop['indexFilterable'],
|
| 66 |
+
'searchable': prop['indexSearchable'],
|
| 67 |
+
'skip_vectorization': prop['moduleConfig']['text2vec-huggingface']['skip'],
|
| 68 |
+
}
|
| 69 |
+
schema_data[prop['name']] = data_property
|
| 70 |
+
|
| 71 |
+
_dump_schema(schema_data)
|
| 72 |
+
|
| 73 |
+
return schema_data
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _update_schema_property(self, old_name: str, new_name: str, prop: dict) -> None:
|
| 77 |
+
del self._schema[old_name]
|
| 78 |
+
self._schema[new_name] = prop
|
| 79 |
+
_dump_schema(self._schema)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _add_schema_property(self, name, prop: dict) -> None:
|
| 83 |
+
self._schema[name] = prop
|
| 84 |
+
_dump_schema(self._schema)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _delete_schema_property(self, name) -> None:
|
| 88 |
+
del self._schema[name]
|
| 89 |
+
_dump_schema(self._schema)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def init(self) -> ttk.Frame:
|
| 93 |
+
main_frame = ttk.Frame(self._parent)
|
| 94 |
+
main_frame.pack(fill=BOTH, expand=True)
|
| 95 |
+
|
| 96 |
+
schema_frame = ttk.Frame(main_frame)
|
| 97 |
+
schema_frame.pack(fill=BOTH, expand=True)
|
| 98 |
+
|
| 99 |
+
add_button = ttk.Button(schema_frame, text='Add property',
|
| 100 |
+
command=lambda: self._add_property(refresh_table))
|
| 101 |
+
add_button.pack(anchor=NW, padx=5, pady=5)
|
| 102 |
+
|
| 103 |
+
canvas = Canvas(schema_frame)
|
| 104 |
+
scrollbar = ttk.Scrollbar(schema_frame, orient="vertical", command=canvas.yview)
|
| 105 |
+
scrollable_frame = ttk.Frame(canvas)
|
| 106 |
+
|
| 107 |
+
scrollable_frame.bind("<Configure>", lambda _: canvas.configure(scrollregion=canvas.bbox("all")))
|
| 108 |
+
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
|
| 109 |
+
canvas.configure(yscrollcommand=scrollbar.set)
|
| 110 |
+
canvas.pack(side=LEFT, fill=BOTH, expand=True)
|
| 111 |
+
scrollbar.pack(side=RIGHT, fill=Y)
|
| 112 |
+
|
| 113 |
+
def refresh_table():
|
| 114 |
+
for widget in scrollable_frame.winfo_children():
|
| 115 |
+
widget.destroy()
|
| 116 |
+
|
| 117 |
+
self._build_table(scrollable_frame, refresh_table)
|
| 118 |
+
|
| 119 |
+
refresh_table()
|
| 120 |
+
return main_frame
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _build_table(self, parent_frame, refresh_callback):
|
| 124 |
+
style = ttk.Style()
|
| 125 |
+
style.configure('Header.TLabel', font=('Helvetica', 10, 'bold'), background='#e0e0e0')
|
| 126 |
+
style.configure('EvenRow.TLabel', background='#f0f0f0')
|
| 127 |
+
style.configure('OddRow.TLabel', background='white')
|
| 128 |
+
|
| 129 |
+
table_frame = ttk.Frame(parent_frame)
|
| 130 |
+
table_frame.pack(fill=X, padx=5, pady=5)
|
| 131 |
+
|
| 132 |
+
for i in range(5):
|
| 133 |
+
table_frame.grid_columnconfigure(i, minsize=100, weight=1)
|
| 134 |
+
|
| 135 |
+
headers = ['Name', 'Data Type', 'Filterable', 'Searchable', 'Skip Vectorize']
|
| 136 |
+
for col, text in enumerate(headers):
|
| 137 |
+
label = ttk.Label(table_frame, text=text, borderwidth=1, relief=SOLID, anchor='center', style='Header.TLabel')
|
| 138 |
+
label.grid(row=0, column=col, sticky='ew')
|
| 139 |
+
|
| 140 |
+
for idx, (name, prop) in enumerate(self._schema.items(), start=1):
|
| 141 |
+
row_style = 'EvenRow.TLabel' if idx % 2 == 0 else 'OddRow.TLabel'
|
| 142 |
+
|
| 143 |
+
row_name_label = ttk.Label(table_frame, text=name, style=row_style)
|
| 144 |
+
row_type_label = ttk.Label(table_frame, text=prop['data_type'].upper(), style=row_style)
|
| 145 |
+
row_filterable_label = ttk.Label(table_frame, text='Yes' if prop['filterable'] else 'No', style=row_style)
|
| 146 |
+
row_searchable_label = ttk.Label(table_frame, text='Yes' if prop['searchable'] else 'No', style=row_style)
|
| 147 |
+
row_vectorize_label = ttk.Label(table_frame, text='Yes' if prop['skip_vectorization'] else 'No', style=row_style)
|
| 148 |
+
|
| 149 |
+
row_edit_button = ttk.Button(table_frame, text='Edit',
|
| 150 |
+
command=lambda n=name, p=prop: self._edit_property(n, p, refresh_callback))
|
| 151 |
+
row_delete_button = ttk.Button(table_frame, text='Delete',
|
| 152 |
+
command=lambda n=name: self._delete_property(n, refresh_callback))
|
| 153 |
+
row_strategy_button = ttk.Button(table_frame, text='Strategy',
|
| 154 |
+
command=lambda n=name: self._handle_strategy(n))
|
| 155 |
+
|
| 156 |
+
row_name_label.grid(row=idx, column=0, sticky='ew', ipadx=25)
|
| 157 |
+
row_type_label.grid(row=idx, column=1, sticky='ew', ipadx=25)
|
| 158 |
+
row_filterable_label.grid(row=idx, column=2, sticky='ew', ipadx=25)
|
| 159 |
+
row_searchable_label.grid(row=idx, column=3, sticky='ew')
|
| 160 |
+
row_vectorize_label.grid(row=idx, column=4, sticky='ew')
|
| 161 |
+
row_edit_button.grid(row=idx, column=5, sticky='ew')
|
| 162 |
+
row_delete_button.grid(row=idx, column=6, sticky='ew')
|
| 163 |
+
row_strategy_button.grid(row=idx, column=7, sticky='ew')
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _handle_strategy(self, n):
|
| 167 |
+
dialog = Toplevel()
|
| 168 |
+
dialog.title(f"Property {n} strategy")
|
| 169 |
+
dialog.geometry("700x400")
|
| 170 |
+
|
| 171 |
+
field_frame = ttk.Frame(dialog)
|
| 172 |
+
field_frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
|
| 173 |
+
|
| 174 |
+
scrollbar = Scrollbar(field_frame, orient=VERTICAL)
|
| 175 |
+
scrollbar.pack(side=RIGHT, fill=Y)
|
| 176 |
+
|
| 177 |
+
strategy = self._strategies[n]
|
| 178 |
+
edit_field = Text(field_frame, width=80, height=15, wrap=WORD, yscrollcommand=scrollbar.set)
|
| 179 |
+
edit_field.insert(END, strategy)
|
| 180 |
+
edit_field.pack(side=LEFT, fill=BOTH, expand=True)
|
| 181 |
+
|
| 182 |
+
scrollbar.config(command=edit_field.yview)
|
| 183 |
+
|
| 184 |
+
def commit():
|
| 185 |
+
new_strategy = edit_field.get("1.0", END).strip()
|
| 186 |
+
self._save_strategy(n, new_strategy)
|
| 187 |
+
dialog.destroy()
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
ttk.Button(dialog, text="Save", command=commit).pack(side=BOTTOM, anchor=S, pady=10)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _delete_property(self, name, refresh_callback):
|
| 194 |
+
msg = f"Do you want to delete property '{name}'?"
|
| 195 |
+
dialog = Toplevel()
|
| 196 |
+
dialog.title('Warning!')
|
| 197 |
+
dialog.geometry(f"{len(msg)*5+120}x50")
|
| 198 |
+
dialog.grab_set()
|
| 199 |
+
|
| 200 |
+
ttk.Label(dialog, text=msg).pack()
|
| 201 |
+
|
| 202 |
+
def submit():
|
| 203 |
+
self._delete_schema_property(name)
|
| 204 |
+
refresh_callback()
|
| 205 |
+
dialog.destroy()
|
| 206 |
+
|
| 207 |
+
button_frame = ttk.Frame(dialog)
|
| 208 |
+
button_frame.pack(fill=X, expand=True)
|
| 209 |
+
|
| 210 |
+
ttk.Button(button_frame, text='Delete', command=submit).pack(side=LEFT, padx=15)
|
| 211 |
+
ttk.Button(button_frame, text='Cancel', command=dialog.destroy).pack(side=RIGHT, padx=15)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _add_property(self, refresh_callback):
|
| 215 |
+
dialog = Toplevel()
|
| 216 |
+
dialog.title(f"New property")
|
| 217 |
+
dialog.geometry("280x300")
|
| 218 |
+
dialog.grab_set()
|
| 219 |
+
|
| 220 |
+
texts_frame = ttk.Frame(dialog)
|
| 221 |
+
texts_frame.pack(fill=X, expand=True)
|
| 222 |
+
|
| 223 |
+
ttk.Label(texts_frame, text="Name:").grid(row=0, column=0, padx=5, pady=5, sticky='e')
|
| 224 |
+
name_entry = ttk.Entry(texts_frame)
|
| 225 |
+
name_entry.grid(row=0, column=1, padx=5, pady=5, sticky='w')
|
| 226 |
+
|
| 227 |
+
ttk.Label(texts_frame, text="Description:").grid(row=1, column=0, padx=5, pady=5, sticky='e')
|
| 228 |
+
desc_entry = ttk.Entry(texts_frame)
|
| 229 |
+
desc_entry.insert(0, '')
|
| 230 |
+
desc_entry.grid(row=1, column=1, padx=5, pady=5, sticky='w')
|
| 231 |
+
|
| 232 |
+
ttk.Label(texts_frame, text="Data Type:").grid(row=2, column=0, padx=5, pady=5, sticky='e')
|
| 233 |
+
type_var = StringVar(value='text')
|
| 234 |
+
type_combo = ttk.Combobox(texts_frame, textvariable=type_var,
|
| 235 |
+
values=["text", "int", "number", "boolean", "date", "text[]", "int[]", "number[]", "boolean[]", "date[]", "object"]
|
| 236 |
+
)
|
| 237 |
+
type_combo.grid(row=2, column=1, padx=5, pady=5, sticky='w')
|
| 238 |
+
|
| 239 |
+
checks_frame = ttk.Frame(dialog)
|
| 240 |
+
checks_frame.pack(fill=X, expand=True)
|
| 241 |
+
|
| 242 |
+
filterable_var = BooleanVar(value=True)
|
| 243 |
+
searchable_var = BooleanVar(value=True)
|
| 244 |
+
skip_vec_var = BooleanVar(value=False)
|
| 245 |
+
|
| 246 |
+
ttk.Checkbutton(checks_frame, text="Filterable ", variable=filterable_var).pack(anchor=W, padx=15)
|
| 247 |
+
ttk.Checkbutton(checks_frame, text="Searchable ", variable=searchable_var).pack(anchor=W, padx=15)
|
| 248 |
+
ttk.Checkbutton(checks_frame, text="Skip Vectorization", variable=skip_vec_var).pack(anchor=W, padx=15)
|
| 249 |
+
|
| 250 |
+
def submit():
|
| 251 |
+
name = name_entry.get()
|
| 252 |
+
if not name:
|
| 253 |
+
self._show_messagebox("Parameter 'name' is required!")
|
| 254 |
+
return
|
| 255 |
+
if name in self._schema.keys():
|
| 256 |
+
self._show_messagebox(f"Property with name '{name}' already exists!")
|
| 257 |
+
return
|
| 258 |
+
|
| 259 |
+
prop = {
|
| 260 |
+
'description': desc_entry.get().strip(),
|
| 261 |
+
'data_type': type_var.get(),
|
| 262 |
+
'filterable': filterable_var.get(),
|
| 263 |
+
'searchable': searchable_var.get(),
|
| 264 |
+
'skip_vectorization': skip_vec_var.get(),
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
self._add_schema_property(name, prop)
|
| 268 |
+
refresh_callback()
|
| 269 |
+
dialog.destroy()
|
| 270 |
+
|
| 271 |
+
buttons_frame = ttk.Frame(dialog)
|
| 272 |
+
buttons_frame.pack(fill=X, expand=True)
|
| 273 |
+
|
| 274 |
+
ttk.Button(buttons_frame, text="Save", command=submit).pack(side=LEFT, padx=15)
|
| 275 |
+
ttk.Button(buttons_frame, text="Cancel", command=dialog.destroy).pack(side=RIGHT, padx=15)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _edit_property(self, name: str, prop: dict, refresh_callback):
|
| 279 |
+
dialog = Toplevel()
|
| 280 |
+
dialog.title(f"Edit Property: {name}")
|
| 281 |
+
dialog.geometry("280x300")
|
| 282 |
+
dialog.grab_set()
|
| 283 |
+
|
| 284 |
+
texts_frame = ttk.Frame(dialog)
|
| 285 |
+
texts_frame.pack(fill=X, expand=True)
|
| 286 |
+
|
| 287 |
+
ttk.Label(texts_frame, text="Name:").grid(row=0, column=0, padx=5, pady=5, sticky='e')
|
| 288 |
+
name_entry = ttk.Entry(texts_frame)
|
| 289 |
+
name_entry.insert(0, name)
|
| 290 |
+
name_entry.grid(row=0, column=1, padx=5, pady=5, sticky='w')
|
| 291 |
+
|
| 292 |
+
ttk.Label(texts_frame, text="Description:").grid(row=1, column=0, padx=5, pady=5, sticky='e')
|
| 293 |
+
desc_entry = ttk.Entry(texts_frame)
|
| 294 |
+
desc_entry.insert(0, prop.get('description', ''))
|
| 295 |
+
desc_entry.grid(row=1, column=1, padx=5, pady=5, sticky='w')
|
| 296 |
+
|
| 297 |
+
ttk.Label(texts_frame, text="Data Type:").grid(row=2, column=0, padx=5, pady=5, sticky='e')
|
| 298 |
+
type_var = StringVar(value=prop['data_type'])
|
| 299 |
+
type_combo = ttk.Combobox(texts_frame, textvariable=type_var,
|
| 300 |
+
values=["text", "int", "number", "boolean", "date", "text[]", "int[]", "number[]", "boolean[]", "date[]", "object"]
|
| 301 |
+
)
|
| 302 |
+
type_combo.grid(row=2, column=1, padx=5, pady=5, sticky='w')
|
| 303 |
+
|
| 304 |
+
checks_frame = ttk.Frame(dialog)
|
| 305 |
+
checks_frame.pack(fill=X, expand=True)
|
| 306 |
+
|
| 307 |
+
filterable_var = BooleanVar(value=prop['filterable'])
|
| 308 |
+
searchable_var = BooleanVar(value=prop['searchable'])
|
| 309 |
+
skip_vec_var = BooleanVar(value=prop['skip_vectorization'])
|
| 310 |
+
|
| 311 |
+
ttk.Checkbutton(checks_frame, text="Filterable ", variable=filterable_var).pack(anchor=W, padx=15)
|
| 312 |
+
ttk.Checkbutton(checks_frame, text="Searchable ", variable=searchable_var).pack(anchor=W, padx=15)
|
| 313 |
+
ttk.Checkbutton(checks_frame, text="Skip Vectorization", variable=skip_vec_var).pack(anchor=W, padx=15)
|
| 314 |
+
|
| 315 |
+
def submit():
|
| 316 |
+
new_name = name_entry.get().strip()
|
| 317 |
+
if not new_name:
|
| 318 |
+
self._show_messagebox("Parameter 'name' is required!")
|
| 319 |
+
return
|
| 320 |
+
|
| 321 |
+
updated_prop = {
|
| 322 |
+
'description': desc_entry.get().strip(),
|
| 323 |
+
'data_type': type_var.get(),
|
| 324 |
+
'filterable': filterable_var.get(),
|
| 325 |
+
'searchable': searchable_var.get(),
|
| 326 |
+
'skip_vectorization': skip_vec_var.get(),
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
self._update_schema_property(name, new_name, updated_prop)
|
| 330 |
+
refresh_callback()
|
| 331 |
+
dialog.destroy()
|
| 332 |
+
|
| 333 |
+
buttons_frame = ttk.Frame(dialog)
|
| 334 |
+
buttons_frame.pack(fill=X, expand=True)
|
| 335 |
+
|
| 336 |
+
ttk.Button(buttons_frame, text="Save", command=submit).pack(side=LEFT, padx=15)
|
| 337 |
+
ttk.Button(buttons_frame, text="Cancel", command=dialog.destroy).pack(side=RIGHT, padx=15)
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
@staticmethod
|
| 341 |
+
def _show_messagebox(msg):
|
| 342 |
+
dialog = Toplevel()
|
| 343 |
+
dialog.title('Warning!')
|
| 344 |
+
dialog.geometry(f"{len(msg)*5+120}x50")
|
| 345 |
+
dialog.grab_set()
|
| 346 |
+
|
| 347 |
+
ttk.Label(dialog, text=msg).pack()
|
| 348 |
+
ttk.Button(dialog, text='OK', command=dialog.destroy).pack(padx=15)
|
src/apps/dbapp/framebase.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from tkinter import *
|
| 2 |
+
from tkinter import ttk
|
| 3 |
+
from src.database.weavservice import WeaviateService
|
| 4 |
+
|
| 5 |
+
class CustomFrameBase:
|
| 6 |
+
def __init__(self, parent, service: WeaviateService) -> None:
|
| 7 |
+
self._parent = parent
|
| 8 |
+
self._service = service
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def init(self) -> ttk.Frame:
|
| 12 |
+
main_frame = ttk.Frame(self._parent)
|
| 13 |
+
main_frame.pack()
|
| 14 |
+
|
| 15 |
+
return main_frame
|
src/apps/dbapp/imports.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import threading
|
| 3 |
+
from tkinter import *
|
| 4 |
+
from tkinter import ttk
|
| 5 |
+
from tkinter import filedialog
|
| 6 |
+
|
| 7 |
+
from src.pipeline.pipeline import ImportPipeline
|
| 8 |
+
from src.apps.dbapp.framebase import CustomFrameBase
|
| 9 |
+
from src.database.weavservice import WeaviateService
|
| 10 |
+
from src.pipeline.utilclasses import ProcessingResult
|
| 11 |
+
from src.utils.lang import get_language_name
|
| 12 |
+
|
| 13 |
+
class ImportFrame(CustomFrameBase):
|
| 14 |
+
def __init__(self, parent, service: WeaviateService) -> None:
|
| 15 |
+
super().__init__(parent, service)
|
| 16 |
+
self._import_paths = dict()
|
| 17 |
+
|
| 18 |
+
def init(self):
|
| 19 |
+
main_frame = ttk.Frame(self._parent)
|
| 20 |
+
main_frame.pack(fill=BOTH, expand=True)
|
| 21 |
+
|
| 22 |
+
import_frame = ttk.Frame(main_frame)
|
| 23 |
+
file_buttons_frame = ttk.Frame(main_frame)
|
| 24 |
+
file_buttons_frame.pack(fill=X, side=TOP, anchor=NW, expand=True)
|
| 25 |
+
|
| 26 |
+
import_buttons_frame = ttk.Frame(import_frame)
|
| 27 |
+
import_buttons_frame.pack(side=TOP, anchor=W, expand=True)
|
| 28 |
+
|
| 29 |
+
files_treeview = ttk.Treeview(
|
| 30 |
+
main_frame,
|
| 31 |
+
columns=[],
|
| 32 |
+
show='tree headings',
|
| 33 |
+
selectmode='extended',
|
| 34 |
+
)
|
| 35 |
+
files_treeview.heading('#0', text='File name')
|
| 36 |
+
files_treeview.column('#0', width=400)
|
| 37 |
+
|
| 38 |
+
logging_textframe = Text(import_frame, width=40, height=16, state=DISABLED)
|
| 39 |
+
|
| 40 |
+
def update_treeview():
|
| 41 |
+
for item in files_treeview.get_children(''):
|
| 42 |
+
files_treeview.delete(item)
|
| 43 |
+
|
| 44 |
+
for filename in self._import_paths.keys():
|
| 45 |
+
files_treeview.insert('', 0, text=filename)
|
| 46 |
+
|
| 47 |
+
def open_file_dialog():
|
| 48 |
+
filepaths = filedialog.askopenfilenames(
|
| 49 |
+
title='Select files to import',
|
| 50 |
+
filetypes=(('PDF', '*.pdf'), ('Text files', '*.txt') ),
|
| 51 |
+
)
|
| 52 |
+
for path in filepaths:
|
| 53 |
+
filename = os.path.basename(path)
|
| 54 |
+
self._import_paths[filename] = path
|
| 55 |
+
|
| 56 |
+
update_treeview()
|
| 57 |
+
|
| 58 |
+
def remove_files():
|
| 59 |
+
selection = files_treeview.selection()
|
| 60 |
+
if not selection:
|
| 61 |
+
return
|
| 62 |
+
|
| 63 |
+
for item in selection:
|
| 64 |
+
filename = files_treeview.item(item)['text']
|
| 65 |
+
del self._import_paths[filename]
|
| 66 |
+
|
| 67 |
+
update_treeview()
|
| 68 |
+
|
| 69 |
+
def change_button_state(state):
|
| 70 |
+
add_button.config(state=state)
|
| 71 |
+
remove_button.config(state=state)
|
| 72 |
+
import_button.config(state=state)
|
| 73 |
+
|
| 74 |
+
add_button = ttk.Button(file_buttons_frame, text='Add files', command=open_file_dialog)
|
| 75 |
+
add_button.pack(side=LEFT, padx=15, pady=15)
|
| 76 |
+
|
| 77 |
+
remove_button = ttk.Button(file_buttons_frame, text='Remove files', command=remove_files)
|
| 78 |
+
remove_button.pack(side=LEFT, padx=15, pady=15)
|
| 79 |
+
|
| 80 |
+
import_button = ttk.Button(import_buttons_frame, text='Begin Import',
|
| 81 |
+
command=lambda: self._import_callback(change_button_state, clean_coll_var.get())
|
| 82 |
+
)
|
| 83 |
+
import_button.pack(side=LEFT, padx=15, pady=15)
|
| 84 |
+
|
| 85 |
+
clean_coll_var = BooleanVar(value=False)
|
| 86 |
+
clean_coll_checkbutton = ttk.Checkbutton(
|
| 87 |
+
import_buttons_frame,
|
| 88 |
+
text='Clean Collections',
|
| 89 |
+
variable=clean_coll_var,
|
| 90 |
+
)
|
| 91 |
+
clean_coll_checkbutton.pack(side=RIGHT, padx=15, pady=15)
|
| 92 |
+
|
| 93 |
+
ttk.Label(import_frame, text='Import status:').pack(side=TOP, anchor=NW, padx=15)
|
| 94 |
+
|
| 95 |
+
files_treeview.pack(side=LEFT, anchor=W, fill=Y, expand=True, padx=15, pady=15)
|
| 96 |
+
import_frame.pack(side=LEFT, anchor=W, fill=BOTH, expand=True)
|
| 97 |
+
|
| 98 |
+
logging_textframe.pack(side=TOP, anchor=NW, fill=BOTH, expand=True, padx=15, pady=15)
|
| 99 |
+
|
| 100 |
+
return main_frame
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _import_callback(self, button_state_callback, clean_coll: bool):
|
| 104 |
+
dialog = Toplevel()
|
| 105 |
+
dialog.title("Import status")
|
| 106 |
+
dialog.geometry("600x400")
|
| 107 |
+
|
| 108 |
+
current_import_label = ttk.Label(dialog, text='Initiating the import pipeline...')
|
| 109 |
+
current_import_label.pack(side=TOP, padx=15, pady=15)
|
| 110 |
+
|
| 111 |
+
progress_bar = ttk.Progressbar(dialog, length=200, value=0, maximum=100)
|
| 112 |
+
progress_bar.pack(side=TOP, padx=15, pady=15)
|
| 113 |
+
|
| 114 |
+
chunks_treeview = ttk.Treeview(
|
| 115 |
+
dialog,
|
| 116 |
+
columns=['chunks', 'lang'],
|
| 117 |
+
show='tree headings',
|
| 118 |
+
selectmode='extended',
|
| 119 |
+
)
|
| 120 |
+
chunks_treeview.heading('#0', text='File name')
|
| 121 |
+
chunks_treeview.heading('chunks', text='Collected chunks')
|
| 122 |
+
chunks_treeview.heading('lang', text='Language')
|
| 123 |
+
|
| 124 |
+
chunks_treeview.column('#0', width=100)
|
| 125 |
+
chunks_treeview.column('chunks', width=60)
|
| 126 |
+
chunks_treeview.column('lang', width=40)
|
| 127 |
+
|
| 128 |
+
chunks_treeview.pack(side=TOP, fill=X, padx=15, pady=15, expand=True)
|
| 129 |
+
|
| 130 |
+
def logging_callback(msg: str, progress: int, result: ProcessingResult = None):
|
| 131 |
+
current_import_label.config(text=msg)
|
| 132 |
+
if progress > 100:
|
| 133 |
+
progress_bar.config(mode='indeterminate')
|
| 134 |
+
else:
|
| 135 |
+
progress_bar.config(mode='determinate', value=progress)
|
| 136 |
+
if result:
|
| 137 |
+
chunks_treeview.insert('', index=0,
|
| 138 |
+
text=result.source,
|
| 139 |
+
values=(
|
| 140 |
+
len(result.chunks),
|
| 141 |
+
get_language_name(result.lang)
|
| 142 |
+
)
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
def import_task():
|
| 146 |
+
button_state_callback(DISABLED)
|
| 147 |
+
filepaths = self._import_paths.values()
|
| 148 |
+
try:
|
| 149 |
+
ImportPipeline(
|
| 150 |
+
logging_callback=logging_callback,
|
| 151 |
+
reset_collections_on_import=clean_coll,
|
| 152 |
+
).import_many_documents(filepaths)
|
| 153 |
+
finally:
|
| 154 |
+
dialog.bell()
|
| 155 |
+
button_state_callback(NORMAL)
|
| 156 |
+
|
| 157 |
+
import_thread = threading.Thread(target=import_task)
|
| 158 |
+
import_thread.start()
|
src/apps/dbapp/mainframe.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from tkinter import *
|
| 2 |
+
from tkinter import ttk
|
| 3 |
+
from src.apps.dbapp.framebase import CustomFrameBase
|
| 4 |
+
from src.database.weavservice import WeaviateService
|
| 5 |
+
|
| 6 |
+
class MainFrame(CustomFrameBase):
|
| 7 |
+
def __init__(self, parent, service: WeaviateService) -> None:
|
| 8 |
+
super().__init__(parent, service)
|
src/apps/dbapp/query.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from tkinter import *
|
| 2 |
+
from tkinter import ttk
|
| 3 |
+
from src.apps.dbapp.framebase import CustomFrameBase
|
| 4 |
+
from src.database.weavservice import WeaviateService
|
| 5 |
+
|
| 6 |
+
class QueryFrame(CustomFrameBase):
|
| 7 |
+
def __init__(self, parent, service: WeaviateService) -> None:
|
| 8 |
+
super().__init__(parent, service)
|
src/apps/dbapp/utilclasses.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, json
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from config import WeaviateConfiguration as wvtconf
|
| 4 |
+
|
| 5 |
+
class BackupData:
|
| 6 |
+
def __init__(self, backup_id: str) -> None:
|
| 7 |
+
self._backup_id = backup_id
|
| 8 |
+
self._creation_date = ""
|
| 9 |
+
self._collections = []
|
| 10 |
+
|
| 11 |
+
backup_path = os.path.join(wvtconf.BACKUP_PATH, backup_id)
|
| 12 |
+
files = os.listdir(backup_path)
|
| 13 |
+
|
| 14 |
+
if 'data.json' in files:
|
| 15 |
+
data_path = os.path.join(backup_path, 'data.json')
|
| 16 |
+
with open(data_path) as f:
|
| 17 |
+
data = json.load(f)
|
| 18 |
+
|
| 19 |
+
date = datetime.fromisoformat(data['creation_date'])
|
| 20 |
+
self._creation_date = date.strftime("%d.%m.%Y %H:%M:%S")
|
| 21 |
+
|
| 22 |
+
if 'objects.json' in files:
|
| 23 |
+
objects_path = os.path.join(backup_path, 'objects.json')
|
| 24 |
+
with open(objects_path) as f:
|
| 25 |
+
data = json.load(f)
|
| 26 |
+
for name, objs in data.items():
|
| 27 |
+
self._collections.append({
|
| 28 |
+
'name': name.lower(),
|
| 29 |
+
'size': ('', len(objs))
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def to_treeformat(self):
|
| 34 |
+
return {
|
| 35 |
+
'id': self._backup_id.replace('backup_', ''),
|
| 36 |
+
'date': (self._creation_date, ''),
|
| 37 |
+
'collections': self._collections,
|
| 38 |
+
}
|
src/cache/__init__.py
ADDED
|
File without changes
|
src/cache/cache.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .cache_strategies import RedisCache, LocalCache
|
| 2 |
+
from config import CacheConfig
|
| 3 |
+
from threading import Lock
|
| 4 |
+
from src.utils.logging import get_logger
|
| 5 |
+
from .cache_metrics import CacheMetrics
|
| 6 |
+
|
| 7 |
+
logger = get_logger("cache")
|
| 8 |
+
|
| 9 |
+
class Cache:
|
| 10 |
+
_instance = None
|
| 11 |
+
_settings = None
|
| 12 |
+
_lock = Lock()
|
| 13 |
+
_cache_metrics = None
|
| 14 |
+
|
| 15 |
+
@staticmethod
|
| 16 |
+
def configure(mode: str, no_cache: bool):
|
| 17 |
+
Cache._settings = {
|
| 18 |
+
"mode": mode,
|
| 19 |
+
"enabled": not no_cache
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
@staticmethod
|
| 23 |
+
def get_cache():
|
| 24 |
+
if Cache._instance is not None:
|
| 25 |
+
return Cache._instance
|
| 26 |
+
|
| 27 |
+
with Cache._lock:
|
| 28 |
+
if Cache._instance is not None:
|
| 29 |
+
return Cache._instance
|
| 30 |
+
|
| 31 |
+
settings = Cache._settings or {"mode": CacheConfig.CACHE_LOCAL, "enabled": True}
|
| 32 |
+
|
| 33 |
+
if not settings.get("enabled", True):
|
| 34 |
+
Cache._instance = None
|
| 35 |
+
return None
|
| 36 |
+
|
| 37 |
+
if Cache._cache_metrics is None:
|
| 38 |
+
Cache._cache_metrics = CacheMetrics()
|
| 39 |
+
|
| 40 |
+
mode = settings.get("mode", CacheConfig.CACHE_LOCAL)
|
| 41 |
+
|
| 42 |
+
if mode == CacheConfig.CACHE_CLOUD:
|
| 43 |
+
cache_obj = RedisCache(
|
| 44 |
+
host=CacheConfig.CLOUD_HOST,
|
| 45 |
+
port=CacheConfig.CLOUD_PORT,
|
| 46 |
+
password=CacheConfig.CLOUD_PASS,
|
| 47 |
+
mode=mode,
|
| 48 |
+
metrics=Cache._cache_metrics
|
| 49 |
+
)
|
| 50 |
+
elif mode == CacheConfig.CACHE_LOCAL:
|
| 51 |
+
cache_obj = RedisCache(
|
| 52 |
+
host=CacheConfig.LOCAL_HOST,
|
| 53 |
+
port=CacheConfig.LOCAL_PORT,
|
| 54 |
+
password=CacheConfig.LOCAL_PASS,
|
| 55 |
+
mode=mode,
|
| 56 |
+
metrics=Cache._cache_metrics
|
| 57 |
+
)
|
| 58 |
+
elif mode == CacheConfig.CACHE_DICT:
|
| 59 |
+
Cache._instance = LocalCache(metrics=Cache._cache_metrics)
|
| 60 |
+
return Cache._instance
|
| 61 |
+
else:
|
| 62 |
+
logger.error("FALLBACK to dict cache. Unknown cache mode")
|
| 63 |
+
Cache._instance = LocalCache(metrics=Cache._cache_metrics)
|
| 64 |
+
return Cache._instance
|
| 65 |
+
|
| 66 |
+
if cache_obj.client is None:
|
| 67 |
+
logger.error("FALLBACK to dict cache. Redis connection failed")
|
| 68 |
+
Cache._instance = LocalCache(metrics=Cache._cache_metrics)
|
| 69 |
+
else:
|
| 70 |
+
Cache._instance = cache_obj
|
| 71 |
+
|
| 72 |
+
return Cache._instance
|
src/cache/cache_base.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
class CacheStrategy(ABC):
|
| 5 |
+
"""
|
| 6 |
+
Defines the interface for the different cache system strategies (Local or Redis).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
@abstractmethod
|
| 10 |
+
def set(self, key: str, value: Any, language: str):
|
| 11 |
+
pass
|
| 12 |
+
|
| 13 |
+
@abstractmethod
|
| 14 |
+
def get(self, key: str, language: str):
|
| 15 |
+
pass
|
| 16 |
+
|
| 17 |
+
@abstractmethod
|
| 18 |
+
def clear_cache(self):
|
| 19 |
+
pass
|
src/cache/cache_metrics.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from threading import Lock
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
@dataclass
|
| 6 |
+
class CacheStatistics:
|
| 7 |
+
hits: int
|
| 8 |
+
misses: int
|
| 9 |
+
hits_ratio: float
|
| 10 |
+
|
| 11 |
+
class CacheMetrics:
|
| 12 |
+
def __init__(self) -> None:
|
| 13 |
+
self.cache_stats = CacheStatistics(0, 0, 0.0)
|
| 14 |
+
self._lock = Lock()
|
| 15 |
+
|
| 16 |
+
def increment_hit(self):
|
| 17 |
+
with self._lock:
|
| 18 |
+
self.cache_stats.hits += 1
|
| 19 |
+
self._calc_hit_ratio()
|
| 20 |
+
|
| 21 |
+
def increment_miss(self):
|
| 22 |
+
with self._lock:
|
| 23 |
+
self.cache_stats.misses += 1
|
| 24 |
+
self._calc_hit_ratio()
|
| 25 |
+
|
| 26 |
+
def _calc_hit_ratio(self):
|
| 27 |
+
total = self.cache_stats.hits + self.cache_stats.misses
|
| 28 |
+
self.cache_stats.hits_ratio = (self.cache_stats.hits / total) if total else 0.0
|
src/cache/cache_strategies.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .cache_base import CacheStrategy
|
| 2 |
+
from config import CacheConfig
|
| 3 |
+
from cachetools import TTLCache
|
| 4 |
+
import json
|
| 5 |
+
from typing import Any
|
| 6 |
+
from src.database.redisservice import RedisService
|
| 7 |
+
from src.utils.logging import get_logger
|
| 8 |
+
|
| 9 |
+
logger = get_logger(__name__)
|
| 10 |
+
|
| 11 |
+
class RedisCache(CacheStrategy):
|
| 12 |
+
def __init__(self, host, port, password, mode, metrics):
|
| 13 |
+
service = RedisService(host, port, password, mode)
|
| 14 |
+
self.client = service.get_client()
|
| 15 |
+
self.metrics = metrics
|
| 16 |
+
|
| 17 |
+
def set(self, key: str, value: Any, language: str):
|
| 18 |
+
if not self.client: return
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
json_str = json.dumps(value)
|
| 22 |
+
self.client.set(self._generate_normalized_key(key, language), json_str, ex=CacheConfig.TTL_CACHE)
|
| 23 |
+
logger.info("Response cached")
|
| 24 |
+
except Exception as e:
|
| 25 |
+
logger.error(f"Could not write to Redis: {e}")
|
| 26 |
+
|
| 27 |
+
def get(self, key: str, language: str):
|
| 28 |
+
if not self.client: return None
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
val = self.client.get(self._generate_normalized_key(key, language))
|
| 32 |
+
if val is not None:
|
| 33 |
+
self.metrics.increment_hit()
|
| 34 |
+
logger.info(f"Cache HIT {self.metrics.cache_stats.hits} {self.metrics.cache_stats.hits_ratio}")
|
| 35 |
+
return json.loads(val)
|
| 36 |
+
|
| 37 |
+
self.metrics.increment_miss()
|
| 38 |
+
logger.info(f"Cache MISS {self.metrics.cache_stats.misses} {self.metrics.cache_stats.hits_ratio}")
|
| 39 |
+
return None
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.error(f"Could not read from Redis: {e}")
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
def _generate_normalized_key(self, key: str, language: str) -> str:
|
| 45 |
+
import re
|
| 46 |
+
|
| 47 |
+
normalized_key = re.sub(r'[^a-z0-9]', '', key.lower())
|
| 48 |
+
return f"cache:{language}:{normalized_key}"
|
| 49 |
+
|
| 50 |
+
def clear_cache(self):
|
| 51 |
+
if not self.client: return
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
self.client.flushdb()
|
| 55 |
+
logger.info(f"Redis Cache cleared.")
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logger.error(f"Could not clear Redis cache: {e}")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class LocalCache(CacheStrategy):
|
| 61 |
+
def __init__(self, metrics):
|
| 62 |
+
self.cache = TTLCache(maxsize=CacheConfig.MAX_SIZE_CACHE, ttl=CacheConfig.TTL_CACHE)
|
| 63 |
+
self.metrics = metrics
|
| 64 |
+
|
| 65 |
+
def _generate_normalized_key(self, key: str, language: str) -> str:
|
| 66 |
+
import re
|
| 67 |
+
|
| 68 |
+
normalized_key = re.sub(r'[^a-z0-9]', '', key.lower())
|
| 69 |
+
return f"cache:{language}:{normalized_key}"
|
| 70 |
+
|
| 71 |
+
def set(self, key: str, value: Any, language: str):
|
| 72 |
+
normalized_key = self._generate_normalized_key(key, language)
|
| 73 |
+
self.cache[normalized_key] = value
|
| 74 |
+
logger.info("Response cached")
|
| 75 |
+
|
| 76 |
+
def get(self, key: str, language: str):
|
| 77 |
+
normalized_key = self._generate_normalized_key(key, language)
|
| 78 |
+
res = self.cache.get(normalized_key, None)
|
| 79 |
+
if res is not None:
|
| 80 |
+
self.metrics.increment_hit()
|
| 81 |
+
logger.info(f"Cache HIT {self.metrics.cache_stats.hits} {self.metrics.cache_stats.hits_ratio}")
|
| 82 |
+
else:
|
| 83 |
+
self.metrics.increment_miss()
|
| 84 |
+
logger.info(f"Cache MISS {self.metrics.cache_stats.misses}")
|
| 85 |
+
return res
|
| 86 |
+
|
| 87 |
+
def clear_cache(self):
|
| 88 |
+
self.cache.clear()
|
| 89 |
+
logger.info("Local Cache cleared.")
|
src/const/agent_response_constants.py
CHANGED
|
@@ -1,22 +1,20 @@
|
|
| 1 |
-
from gradio import ChatMessage
|
| 2 |
-
|
| 3 |
""" Constants for Gradio app """
|
| 4 |
|
| 5 |
GREETING_MESSAGES = {
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
}
|
| 21 |
|
| 22 |
QUERY_EXCEPTION_MESSAGE = {
|
|
@@ -72,69 +70,57 @@ CONVERSATION_END_MESSAGE = {
|
|
| 72 |
}
|
| 73 |
|
| 74 |
|
| 75 |
-
def
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
f'padding: 8px 16px; border-radius: 6px; cursor: pointer; '
|
| 80 |
-
f'color: #374151; font-weight: 600; width: 100%; text-align: left; '
|
| 81 |
-
f'margin-top: 5px; text-decoration: none;">'
|
| 82 |
-
f'📅 {lang_text}: {title}'
|
| 83 |
-
f'</a>'
|
| 84 |
-
)
|
| 85 |
|
|
|
|
|
|
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
content=create_appt_button(
|
| 134 |
-
"https://calendly.com/teyuna-giger-unisg",
|
| 135 |
-
"Teyuna Giger",
|
| 136 |
-
"Termin buchen"
|
| 137 |
-
),
|
| 138 |
-
),
|
| 139 |
-
],
|
| 140 |
-
}
|
|
|
|
|
|
|
|
|
|
| 1 |
""" Constants for Gradio app """
|
| 2 |
|
| 3 |
GREETING_MESSAGES = {
|
| 4 |
+
"en": [
|
| 5 |
+
"Hello and welcome! I’m your Executive Education Advisor for the HSG Executive MBA programs (**IEMBA**, **emba X**, and **EMBA**). How can I best support your MBA planning today?",
|
| 6 |
+
"Hello and welcome! I’m your Executive Education Advisor for the University of St.Gallen’s Executive MBA programs (**IEMBA**, **emba X**, **EMBA**). How can I support your MBA planning today?",
|
| 7 |
+
"Hello and welcome! I’m your Executive Education Advisor for the HSG Executive MBA programs (**EMBA**, **IEMBA**, **emba X**). How can I help you with your EMBA journey today?",
|
| 8 |
+
"Hello and welcome! I’m your Executive Education Advisor for the University of St.Gallen’s EMBA programs, here to help you navigate our **EMBA**, **IEMBA**, and **emba X** options.",
|
| 9 |
+
"Hello and welcome. I’m your Executive Education Advisor for the University of St.Gallen’s Executive MBA programs, here to help you assess fit and navigate the **EMBA**, **IEMBA**, and **emba X** options.",
|
| 10 |
+
],
|
| 11 |
+
"de": [
|
| 12 |
+
"Guten Tag! Ich bin Ihr Executive-Education-Berater für die HSG Executive MBA Programme und unterstütze Sie gerne bei Fragen zu **EMBA**, **IEMBA** und **emba X**.",
|
| 13 |
+
"Guten Tag, ich bin Ihr Executive-Education-Berater für die HSG Executive MBA Programme (**EMBA**, **IEMBA**, **emba X**). Ich unterstütze Sie bei Programmwahl, Ablauf und Zulassungsfragen.",
|
| 14 |
+
"Guten Tag und herzlich willkommen! Ich bin Ihr Executive Education Advisor für die HSG Executive MBA Programme und unterstütze Sie gern bei Fragen zu **EMBA**, **IEMBA** und **emba X**.",
|
| 15 |
+
"Guten Tag, ich bin Ihr Executive-Education-Berater für die HSG Executive MBA-Programme (**EMBA**, **IEMBA**, **emba X**) und unterstütze Sie gerne bei Programmwahl und Zulassungsfragen.",
|
| 16 |
+
"Guten Tag! Ich bin Ihr Executive-Education-Berater für die HSG Executive MBA Programme (**EMBA**, **IEMBA**, **emba X**) und unterstütze Sie gerne bei Programmwahl und Zulassungsfragen.",
|
| 17 |
+
]
|
| 18 |
}
|
| 19 |
|
| 20 |
QUERY_EXCEPTION_MESSAGE = {
|
|
|
|
| 70 |
}
|
| 71 |
|
| 72 |
|
| 73 |
+
def get_booking_widget(language: str="en", programs: list[str]=None):
|
| 74 |
+
"""
|
| 75 |
+
Returns an HTML string representing a Booking Widget.
|
| 76 |
+
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
+
if programs is None or programs == []:
|
| 79 |
+
programs = ["emba", "iemba", "emba_x"]
|
| 80 |
|
| 81 |
+
labels = {
|
| 82 |
+
"en": {"header": "Book a Consultation", "sub": "Select an advisor to view their calendar:"},
|
| 83 |
+
"de": {"header": "Termin vereinbaren", "sub": "Wählen Sie einen Berater für den Kalender:"}
|
| 84 |
+
}
|
| 85 |
+
txt = labels.get(language, labels["en"])
|
| 86 |
+
|
| 87 |
+
base_params = "?hide_gdpr_banner=1&embed_type=Inline&embed_domain=1"
|
| 88 |
+
advisors = [
|
| 89 |
+
{
|
| 90 |
+
"name": "Cyra von Müller (EMBA)",
|
| 91 |
+
"url": f"https://calendly.com/cyra-vonmueller/beratungsgespraech-emba-hsg{base_params}",
|
| 92 |
+
"program": "emba"
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"name": "Kristin Fuchs (IEMBA)",
|
| 96 |
+
"url": f"https://calendly.com/kristin-fuchs-unisg/iemba-online-personal-consultation{base_params}",
|
| 97 |
+
"program": "iemba"
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"name": "Teyuna Giger (EMBA X)",
|
| 101 |
+
"url": f"https://calendly.com/teyuna-giger-unisg{base_params}",
|
| 102 |
+
"program": "emba_x"
|
| 103 |
+
},
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
html_content = f"""
|
| 107 |
+
<div style="width: 100%; min-width: 100%; box-sizing: border-box; background-color: #f9fafb; border: 1px solid #e5e7eb; border-radius: 12px; padding: 20px; margin-top: 10px; font-family: sans-serif;">
|
| 108 |
+
<h3 style="margin: 0 0 10px 0; color: #111827; font-size: 1.2em;">{txt['header']}</h3>
|
| 109 |
+
<p style="margin: 0 0 20px 0; color: #6b7280; font-size: 1em;">{txt['sub']}</p>
|
| 110 |
+
"""
|
| 111 |
+
|
| 112 |
+
for advisor in advisors:
|
| 113 |
+
if advisor["program"] in programs:
|
| 114 |
+
html_content += f"""
|
| 115 |
+
<details style="margin-bottom: 12px; border: 1px solid #d1d5db; border-radius: 8px; background: white; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
| 116 |
+
<summary style="cursor: pointer; padding: 16px 20px; background-color: #ffffff; font-weight: 600; color: #374151; font-size: 1.05em; list-style: none; transition: background 0.2s;">
|
| 117 |
+
{advisor['name']}
|
| 118 |
+
</summary>
|
| 119 |
+
<div style="padding: 0; border-top: 1px solid #e5e7eb;">
|
| 120 |
+
<iframe src="{advisor['url']}" width="100%" height="650px" frameborder="0" style="display: block;"></iframe>
|
| 121 |
+
</div>
|
| 122 |
+
</details>
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
html_content += "</div>"
|
| 126 |
+
return html_content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/database/docker-compose-cache.yml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
redis:
|
| 5 |
+
image: redis:alpine
|
| 6 |
+
container_name: hsg_redis_cache
|
| 7 |
+
ports:
|
| 8 |
+
- "6379:6379"
|
| 9 |
+
command: >
|
| 10 |
+
redis-server
|
| 11 |
+
--requirepass "${REDIS_PASSWORD}"
|
| 12 |
+
--save 60 1
|
| 13 |
+
--loglevel warning
|
| 14 |
+
--maxmemory 200mb
|
| 15 |
+
--maxmemory-policy allkeys-lru
|
| 16 |
+
volumes:
|
| 17 |
+
- redis_data:/data
|
| 18 |
+
restart: unless-stopped
|
| 19 |
+
|
| 20 |
+
healthcheck:
|
| 21 |
+
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
| 22 |
+
interval: 5s
|
| 23 |
+
timeout: 3s
|
| 24 |
+
retries: 5
|
| 25 |
+
|
| 26 |
+
volumes:
|
| 27 |
+
redis_data:
|
src/database/redisservice.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import redis
|
| 3 |
+
from threading import Lock
|
| 4 |
+
from src.utils.logging import get_logger
|
| 5 |
+
|
| 6 |
+
logger = get_logger("redis_service")
|
| 7 |
+
|
| 8 |
+
class RedisService:
|
| 9 |
+
_instance = None
|
| 10 |
+
_init_lock = Lock()
|
| 11 |
+
|
| 12 |
+
def __new__(cls, host, port, password, mode):
|
| 13 |
+
if cls._instance is None:
|
| 14 |
+
with cls._init_lock:
|
| 15 |
+
if cls._instance is None:
|
| 16 |
+
cls._instance = super().__new__(cls)
|
| 17 |
+
return cls._instance
|
| 18 |
+
|
| 19 |
+
def __init__(self, host, port, password, mode):
|
| 20 |
+
if hasattr(self, '_initialized') and self._initialized:
|
| 21 |
+
return
|
| 22 |
+
|
| 23 |
+
self._client = None
|
| 24 |
+
self._host = host
|
| 25 |
+
self._port = port
|
| 26 |
+
self._password = password
|
| 27 |
+
self.mode = mode
|
| 28 |
+
|
| 29 |
+
self._connect()
|
| 30 |
+
|
| 31 |
+
self._initialized = True
|
| 32 |
+
|
| 33 |
+
def _connect(self):
|
| 34 |
+
try:
|
| 35 |
+
logger.info(f"Connecting to Redis at {self._host}:{self._port}...")
|
| 36 |
+
self._client = redis.Redis(
|
| 37 |
+
host=self._host,
|
| 38 |
+
port=self._port,
|
| 39 |
+
password=self._password,
|
| 40 |
+
decode_responses=True,
|
| 41 |
+
socket_connect_timeout=2,
|
| 42 |
+
socket_timeout=2
|
| 43 |
+
)
|
| 44 |
+
self._client.ping()
|
| 45 |
+
logger.info(f"Successfully connected to Redis! {self.mode}")
|
| 46 |
+
except Exception as e:
|
| 47 |
+
logger.error(f"Redis connection failed: {e}")
|
| 48 |
+
self._client = None
|
| 49 |
+
|
| 50 |
+
def get_client(self):
|
| 51 |
+
return self._client
|
| 52 |
+
|
| 53 |
+
def is_connected(self) -> bool:
|
| 54 |
+
return self._client is not None
|
src/database/weavservice.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import weaviate as wvt
|
| 2 |
import datetime, os
|
| 3 |
from threading import Lock
|
|
@@ -7,6 +8,7 @@ from weaviate.classes.config import Configure, Property, DataType
|
|
| 7 |
from weaviate.collections.classes.grpc import MetadataQuery
|
| 8 |
from weaviate.collections.collection import Collection
|
| 9 |
from weaviate.classes.init import AdditionalConfig, Timeout
|
|
|
|
| 10 |
from weaviate.config import AdditionalConfig
|
| 11 |
|
| 12 |
from src.utils.logging import get_logger
|
|
@@ -99,13 +101,9 @@ class WeaviateService:
|
|
| 99 |
if wvtconf.is_local():
|
| 100 |
self._client = wvt.connect_to_local()
|
| 101 |
break
|
| 102 |
-
|
| 103 |
-
cluster_url = wvtconf.CLUSTER_URL
|
| 104 |
-
if not cluster_url.startswith('http'):
|
| 105 |
-
cluster_url = f"https://{cluster_url}"
|
| 106 |
|
| 107 |
self._client = wvt.connect_to_weaviate_cloud(
|
| 108 |
-
cluster_url=
|
| 109 |
auth_credentials=wvtconf.WEAVIATE_API_KEY,
|
| 110 |
additional_config=AdditionalConfig(
|
| 111 |
timeout=Timeout(
|
|
@@ -132,12 +130,12 @@ class WeaviateService:
|
|
| 132 |
break
|
| 133 |
except Exception as e:
|
| 134 |
last_exception = e
|
| 135 |
-
logger.
|
| 136 |
retries += 1
|
| 137 |
sleep(1)
|
| 138 |
|
| 139 |
if retries == 3:
|
| 140 |
-
logger.error(f"Failed after 3 retries!")
|
| 141 |
raise last_exception
|
| 142 |
|
| 143 |
logger.info(f"Successully connected to the {self._connection_type} weaviate database")
|
|
@@ -211,18 +209,28 @@ class WeaviateService:
|
|
| 211 |
raise e
|
| 212 |
|
| 213 |
return import_errors
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
|
| 216 |
-
def query(self, query: str, lang: str,
|
| 217 |
"""
|
| 218 |
Execute a hybrid semantic and keyword query against the active collection with automatic reconnection on idle timeout.
|
| 219 |
|
| 220 |
Args:
|
| 221 |
query (str): The query string.
|
| 222 |
-
query_properties (list[str], optional): List of properties to query against.
|
| 223 |
-
limit (int, optional): Maximum number of results to return. Defaults to 5.
|
| 224 |
-
distance (float, optional): Distance threshold for the query. Defaults to 0.25.
|
| 225 |
lang (str, optional): Language collection to use. If not provided, uses the current one.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
|
| 227 |
Returns:
|
| 228 |
tuple: A tuple containing the query response and elapsed time.
|
|
@@ -232,7 +240,11 @@ class WeaviateService:
|
|
| 232 |
"""
|
| 233 |
retry_count = 0
|
| 234 |
max_retries = 2
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
while retry_count < max_retries:
|
| 237 |
try:
|
| 238 |
collection, collection_name = self._select_collection(lang)
|
|
@@ -246,7 +258,7 @@ class WeaviateService:
|
|
| 246 |
with self._client_lock:
|
| 247 |
resp = collection.query.hybrid(
|
| 248 |
query=query,
|
| 249 |
-
|
| 250 |
limit=limit,
|
| 251 |
return_metadata=MetadataQuery.full()
|
| 252 |
)
|
|
@@ -359,30 +371,115 @@ class WeaviateService:
|
|
| 359 |
logger.error(f"Collections deletion failed: {e}")
|
| 360 |
self._client = None
|
| 361 |
raise e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
|
| 363 |
|
| 364 |
-
def _create_backup(self) ->
|
| 365 |
"""
|
| 366 |
-
Create a backup of the current database state.
|
| 367 |
-
|
| 368 |
-
|
| 369 |
"""
|
| 370 |
try:
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
)
|
| 382 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
self._last_query_time = perf_counter()
|
| 384 |
-
logger.info(f"Backup '{backup_id}' created successfully
|
| 385 |
|
|
|
|
| 386 |
except Exception as e:
|
| 387 |
logger.error(f"Backup creation failed: {e}")
|
| 388 |
raise e
|
|
@@ -400,20 +497,69 @@ class WeaviateService:
|
|
| 400 |
Raises:
|
| 401 |
Exception if backup restoration fails
|
| 402 |
"""
|
|
|
|
|
|
|
| 403 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
client = self._init_client()
|
| 405 |
-
logger.info(f"Initiating restoration from backup '{backup_id}' for {self._connection_type} database")
|
| 406 |
|
| 407 |
with self._client_lock:
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
self._last_query_time = perf_counter()
|
| 416 |
-
logger.info(f"Backup '{backup_id}' restored successfully
|
| 417 |
|
| 418 |
except Exception as e:
|
| 419 |
error_msg = str(e).lower()
|
|
@@ -587,5 +733,4 @@ if __name__ == "__main__":
|
|
| 587 |
service._create_collections()
|
| 588 |
|
| 589 |
if any([args.checkhealth, args.create_collections, args.redo_collections]):
|
| 590 |
-
service._checkhealth()
|
| 591 |
-
|
|
|
|
| 1 |
+
from functools import reduce
|
| 2 |
import weaviate as wvt
|
| 3 |
import datetime, os
|
| 4 |
from threading import Lock
|
|
|
|
| 8 |
from weaviate.collections.classes.grpc import MetadataQuery
|
| 9 |
from weaviate.collections.collection import Collection
|
| 10 |
from weaviate.classes.init import AdditionalConfig, Timeout
|
| 11 |
+
from weaviate.classes.query import Filter
|
| 12 |
from weaviate.config import AdditionalConfig
|
| 13 |
|
| 14 |
from src.utils.logging import get_logger
|
|
|
|
| 101 |
if wvtconf.is_local():
|
| 102 |
self._client = wvt.connect_to_local()
|
| 103 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
self._client = wvt.connect_to_weaviate_cloud(
|
| 106 |
+
cluster_url=wvtconf.CLUSTER_URL,
|
| 107 |
auth_credentials=wvtconf.WEAVIATE_API_KEY,
|
| 108 |
additional_config=AdditionalConfig(
|
| 109 |
timeout=Timeout(
|
|
|
|
| 130 |
break
|
| 131 |
except Exception as e:
|
| 132 |
last_exception = e
|
| 133 |
+
logger.warning(f"Failed to establish connection on try {retries}: {e}")
|
| 134 |
retries += 1
|
| 135 |
sleep(1)
|
| 136 |
|
| 137 |
if retries == 3:
|
| 138 |
+
logger.error(f"Failed to establish connection after 3 retries!")
|
| 139 |
raise last_exception
|
| 140 |
|
| 141 |
logger.info(f"Successully connected to the {self._connection_type} weaviate database")
|
|
|
|
| 209 |
raise e
|
| 210 |
|
| 211 |
return import_errors
|
| 212 |
+
|
| 213 |
+
@staticmethod
|
| 214 |
+
def _create_property_filter(prop, values) -> Filter:
|
| 215 |
+
match prop:
|
| 216 |
+
case 'programs':
|
| 217 |
+
return Filter.by_property('programs').contains_any(values)
|
| 218 |
+
case _:
|
| 219 |
+
return None
|
| 220 |
|
| 221 |
|
| 222 |
+
def query(self, query: str, lang: str, property_filters: dict[str], limit: int = 5) -> dict:
|
| 223 |
"""
|
| 224 |
Execute a hybrid semantic and keyword query against the active collection with automatic reconnection on idle timeout.
|
| 225 |
|
| 226 |
Args:
|
| 227 |
query (str): The query string.
|
|
|
|
|
|
|
|
|
|
| 228 |
lang (str, optional): Language collection to use. If not provided, uses the current one.
|
| 229 |
+
property_filters (dict[str, any]): Key-value pairs for metadata filtering. Keys correspond
|
| 230 |
+
to document properties (e.g., 'program', 'topic'), and values are the required matches.
|
| 231 |
+
Multiple filters are combined using logical AND.
|
| 232 |
+
limit (int, optional): Maximum number of results to return. Defaults to 5.
|
| 233 |
+
|
| 234 |
|
| 235 |
Returns:
|
| 236 |
tuple: A tuple containing the query response and elapsed time.
|
|
|
|
| 240 |
"""
|
| 241 |
retry_count = 0
|
| 242 |
max_retries = 2
|
| 243 |
+
|
| 244 |
+
filters = [self._create_property_filter(prop, values)
|
| 245 |
+
for prop, values in property_filters.items()] if property_filters else None
|
| 246 |
+
filters = reduce(lambda f1, f2: f1 & f2, filters)
|
| 247 |
+
|
| 248 |
while retry_count < max_retries:
|
| 249 |
try:
|
| 250 |
collection, collection_name = self._select_collection(lang)
|
|
|
|
| 258 |
with self._client_lock:
|
| 259 |
resp = collection.query.hybrid(
|
| 260 |
query=query,
|
| 261 |
+
filters=filters,
|
| 262 |
limit=limit,
|
| 263 |
return_metadata=MetadataQuery.full()
|
| 264 |
)
|
|
|
|
| 371 |
logger.error(f"Collections deletion failed: {e}")
|
| 372 |
self._client = None
|
| 373 |
raise e
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def _reset_collections(self):
|
| 377 |
+
self._delete_collections()
|
| 378 |
+
self._create_collections()
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def _collect_chunk_ids(self) -> dict:
|
| 382 |
+
client = self._init_client()
|
| 383 |
+
try:
|
| 384 |
+
ids = []
|
| 385 |
+
with self._client_lock:
|
| 386 |
+
for c in client.collections.list_all(simple=False):
|
| 387 |
+
coll = client.collections.get(c)
|
| 388 |
+
for obj in coll.iterator():
|
| 389 |
+
ids.append(obj.properties['chunk_id'])
|
| 390 |
+
return ids
|
| 391 |
+
except Exception as e:
|
| 392 |
+
logger.error(f"Failed to collect chunk ids: {e}")
|
| 393 |
+
raise e
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def _extract_data(self) -> dict:
|
| 397 |
+
client = self._init_client()
|
| 398 |
+
try:
|
| 399 |
+
schema = []
|
| 400 |
+
objects = {}
|
| 401 |
+
with self._client_lock:
|
| 402 |
+
for c in client.collections.list_all(simple=False):
|
| 403 |
+
coll = client.collections.get(c)
|
| 404 |
+
cfg = coll.config.get().to_dict()
|
| 405 |
+
schema.append(cfg)
|
| 406 |
+
|
| 407 |
+
objects[c] = []
|
| 408 |
+
for obj in coll.iterator(include_vector=True):
|
| 409 |
+
objects[c].append({
|
| 410 |
+
"uuid": obj.uuid,
|
| 411 |
+
"properties": obj.properties,
|
| 412 |
+
"vector": obj.vector,
|
| 413 |
+
})
|
| 414 |
+
|
| 415 |
+
return {
|
| 416 |
+
'schema': schema,
|
| 417 |
+
'objects': objects,
|
| 418 |
+
}
|
| 419 |
+
except Exception as e:
|
| 420 |
+
logger.error(f"Failed to extract data from database: {e}")
|
| 421 |
+
raise e
|
| 422 |
|
| 423 |
|
| 424 |
+
def _create_backup(self) -> str:
|
| 425 |
"""
|
| 426 |
+
Create a backup of the current database state and stores it under selected backup provider.
|
| 427 |
+
|
| 428 |
+
Returns: backup id of the created backup.
|
| 429 |
"""
|
| 430 |
try:
|
| 431 |
+
if not wvtconf.BACKUP_METHOD:
|
| 432 |
+
raise ValueError('Backup method is not selected!')
|
| 433 |
+
if wvtconf.BACKUP_METHOD not in wvtconf.AVAILABLE_BACKUP_METHODS:
|
| 434 |
+
raise ValueError(f"Selected backup method 'wvtconf.BACKUP_METHOD' is not supported!")
|
| 435 |
+
if not wvtconf.BACKUP_PATH:
|
| 436 |
+
raise ValueError("Backup directory is not set!")
|
| 437 |
+
os.makedirs(wvtconf.BACKUP_PATH, exist_ok=True)
|
| 438 |
+
|
| 439 |
+
backup_id = f"backup_{datetime.datetime.now().strftime('%Y%m%d%H%M%S%f')}"
|
| 440 |
+
logger.info(f"Initiating backup creation for {self._connection_type} database...")
|
|
|
|
| 441 |
|
| 442 |
+
match wvtconf.BACKUP_METHOD:
|
| 443 |
+
case 'manual':
|
| 444 |
+
import json
|
| 445 |
+
|
| 446 |
+
backup_path = os.path.join(wvtconf.BACKUP_PATH, backup_id)
|
| 447 |
+
os.makedirs(backup_path)
|
| 448 |
+
|
| 449 |
+
db_data = self._extract_data()
|
| 450 |
+
data_backup = {
|
| 451 |
+
'creation_date': datetime.datetime.now().isoformat(),
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
schema_backup_path = os.path.join(backup_path, 'schema.json')
|
| 455 |
+
with open(schema_backup_path, 'w', encoding='utf-8') as f:
|
| 456 |
+
json.dump(db_data['schema'], f, indent=2, default=str)
|
| 457 |
+
|
| 458 |
+
objects_backup_path = os.path.join(backup_path, 'objects.json')
|
| 459 |
+
with open(objects_backup_path, 'w', encoding='utf-8') as f:
|
| 460 |
+
json.dump(db_data['objects'], f, indent=2, default=str)
|
| 461 |
+
|
| 462 |
+
data_backup_path = os.path.join(backup_path, 'data.json')
|
| 463 |
+
with open(data_backup_path, 'w', encoding='utf-8') as f:
|
| 464 |
+
json.dump(data_backup, f, indent=2, default=str)
|
| 465 |
+
|
| 466 |
+
case 's3':
|
| 467 |
+
client = self._init_client()
|
| 468 |
+
with self._client_lock:
|
| 469 |
+
client.backup.create(
|
| 470 |
+
backup_id=backup_id,
|
| 471 |
+
backend="s3",
|
| 472 |
+
include_collections=_collection_names,
|
| 473 |
+
wait_for_completion=True,
|
| 474 |
+
)
|
| 475 |
+
case _:
|
| 476 |
+
raise NotImplementedError()
|
| 477 |
+
|
| 478 |
+
|
| 479 |
self._last_query_time = perf_counter()
|
| 480 |
+
logger.info(f"Backup '{backup_id}' created successfully")
|
| 481 |
|
| 482 |
+
return backup_id
|
| 483 |
except Exception as e:
|
| 484 |
logger.error(f"Backup creation failed: {e}")
|
| 485 |
raise e
|
|
|
|
| 497 |
Raises:
|
| 498 |
Exception if backup restoration fails
|
| 499 |
"""
|
| 500 |
+
self._delete_collections()
|
| 501 |
+
|
| 502 |
try:
|
| 503 |
+
if not wvtconf.BACKUP_METHOD:
|
| 504 |
+
raise ValueError('Backup method is not selected!')
|
| 505 |
+
if wvtconf.BACKUP_METHOD not in wvtconf.AVAILABLE_BACKUP_METHODS:
|
| 506 |
+
raise ValueError(f"Selected backup method 'wvtconf.BACKUP_METHOD' is not supported!")
|
| 507 |
+
if not wvtconf.BACKUP_PATH:
|
| 508 |
+
raise ValueError("Backup directory is not set!")
|
| 509 |
+
os.makedirs(wvtconf.BACKUP_PATH, exist_ok=True)
|
| 510 |
+
|
| 511 |
+
backup_path = os.path.join(wvtconf.BACKUP_PATH, backup_id)
|
| 512 |
+
if not os.path.exists(backup_path):
|
| 513 |
+
raise RuntimeError(f"Directory for backup 'backup_id' does not exist in the backup directory!")
|
| 514 |
+
schema_backup_path = os.path.join(backup_path, 'schema.json')
|
| 515 |
+
if not os.path.exists(schema_backup_path):
|
| 516 |
+
raise RuntimeError(f"Schema backup is missing in the backup directory!")
|
| 517 |
+
objects_backup_path = os.path.join(backup_path, 'objects.json')
|
| 518 |
+
if not os.path.exists(objects_backup_path):
|
| 519 |
+
raise RuntimeError(f"Objects backup is missing in the backup directory!")
|
| 520 |
+
|
| 521 |
client = self._init_client()
|
| 522 |
+
logger.info(f"Initiating restoration from backup '{backup_id}' for {self._connection_type} database...")
|
| 523 |
|
| 524 |
with self._client_lock:
|
| 525 |
+
match wvtconf.BACKUP_METHOD:
|
| 526 |
+
case 'manual':
|
| 527 |
+
import json
|
| 528 |
+
|
| 529 |
+
with open(schema_backup_path) as f:
|
| 530 |
+
schemas = json.load(f)
|
| 531 |
+
for cfg in schemas:
|
| 532 |
+
client.collections.create_from_dict(cfg)
|
| 533 |
+
|
| 534 |
+
with open(objects_backup_path) as f:
|
| 535 |
+
data = json.load(f)
|
| 536 |
+
for name, objs in data.items():
|
| 537 |
+
logger.info(f"Restoring collection '{name}' with {len(objs)} objects...")
|
| 538 |
+
coll = client.collections.get(name)
|
| 539 |
+
|
| 540 |
+
with coll.batch.dynamic() as batch:
|
| 541 |
+
for o in objs:
|
| 542 |
+
o['properties']['date'] = o['properties']['date'] \
|
| 543 |
+
.replace(" ", "T").replace("+00:00", "Z")
|
| 544 |
+
batch.add_object(
|
| 545 |
+
uuid=o["uuid"],
|
| 546 |
+
properties=o["properties"],
|
| 547 |
+
vector=o["vector"]
|
| 548 |
+
)
|
| 549 |
+
logger.info(f"Collection '{name}' restored successfully")
|
| 550 |
+
case 's3':
|
| 551 |
+
client.backup.restore(
|
| 552 |
+
backup_id=backup_id,
|
| 553 |
+
backend="s3",
|
| 554 |
+
wait_for_completion=True,
|
| 555 |
+
roles_restore="all",
|
| 556 |
+
users_restore="all",
|
| 557 |
+
)
|
| 558 |
+
case _:
|
| 559 |
+
raise NotImplementedError()
|
| 560 |
+
|
| 561 |
self._last_query_time = perf_counter()
|
| 562 |
+
logger.info(f"Backup '{backup_id}' restored successfully")
|
| 563 |
|
| 564 |
except Exception as e:
|
| 565 |
error_msg = str(e).lower()
|
|
|
|
| 733 |
service._create_collections()
|
| 734 |
|
| 735 |
if any([args.checkhealth, args.create_collections, args.redo_collections]):
|
| 736 |
+
service._checkhealth()
|
|
|
src/pipeline/pipeline.py
CHANGED
|
@@ -1,74 +1,44 @@
|
|
| 1 |
-
import
|
| 2 |
|
| 3 |
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from src.utils.logging import get_logger
|
| 5 |
-
from src.processing.processor import DataProcessor, ProcessingResult, ProcessingStatus, WebsiteProcessor
|
| 6 |
-
from src.database.weavservice import WeaviateService
|
| 7 |
|
| 8 |
-
from config import AVAILABLE_LANGUAGES
|
| 9 |
|
| 10 |
pipelogger = get_logger("pipeline_module")
|
| 11 |
implogger = get_logger("import_pipeline")
|
| 12 |
|
| 13 |
|
| 14 |
-
def _get_all_sources(sources) -> list[str]:
|
| 15 |
-
sources.remove('all')
|
| 16 |
-
pipelogger.info(f"Getting all sources from the soruce directory at {DOCUMENTS_PATH}...")
|
| 17 |
-
for source in os.listdir(DOCUMENTS_PATH):
|
| 18 |
-
if source in sources: continue
|
| 19 |
-
if source.endswith('.pdf'):
|
| 20 |
-
sources.append(os.path.join(DOCUMENTS_PATH, source))
|
| 21 |
-
pipelogger.info(f"Loaded {len(sources)} sources from the source directory")
|
| 22 |
-
return sources
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def _import_hashtables() -> dict:
|
| 26 |
-
"""
|
| 27 |
-
Import deduplication hashtables from the JSON file.
|
| 28 |
-
|
| 29 |
-
Returns:
|
| 30 |
-
dict: Hashtable data containing document and chunk IDs.
|
| 31 |
-
"""
|
| 32 |
-
hashtables = dict()
|
| 33 |
-
|
| 34 |
-
with open(HASH_FILE_PATH, 'a+') as f:
|
| 35 |
-
try:
|
| 36 |
-
f.seek(0)
|
| 37 |
-
pipelogger.info(f"Loading deduplication hashtable from file {HASH_FILE_PATH}")
|
| 38 |
-
hashtables = json.load(f)
|
| 39 |
-
pipelogger.info(f"Import pipeline loaded deduplication hashtable with {len(hashtables['documents'])} sources and {len(hashtables['chunks'])} chunks")
|
| 40 |
-
except json.JSONDecodeError as e:
|
| 41 |
-
pipelogger.warning(f"Failed to decode the hash file {os.path.basename(HASH_FILE_PATH)}: {e}; new hashtable will be created")
|
| 42 |
-
hashtables['documents'] = []
|
| 43 |
-
hashtables['chunks'] = []
|
| 44 |
-
return hashtables
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def _export_hashtables(hashtables: dict):
|
| 48 |
-
"""
|
| 49 |
-
Export hashtable data to the JSON file.
|
| 50 |
-
|
| 51 |
-
Args:
|
| 52 |
-
hashtables (dict): Hashtable dictionary containing documents and chunks.
|
| 53 |
-
"""
|
| 54 |
-
with open(HASH_FILE_PATH, 'w+') as f:
|
| 55 |
-
json.dump(hashtables, f)
|
| 56 |
-
pipelogger.info("Saved successfully imported chunk IDs in the hashtables")
|
| 57 |
-
|
| 58 |
-
|
| 59 |
class ImportPipeline:
|
| 60 |
"""
|
| 61 |
Main pipeline class responsible for importing website and local documents
|
| 62 |
into the database with deduplication and language-based organization.
|
| 63 |
"""
|
| 64 |
|
| 65 |
-
def __init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
"""Initialize the import pipeline with processors and hashtable data."""
|
| 67 |
-
self.
|
| 68 |
-
self.
|
| 69 |
-
self.
|
|
|
|
|
|
|
| 70 |
self._wvtserv = WeaviateService()
|
| 71 |
-
|
|
|
|
|
|
|
| 72 |
|
| 73 |
def scrape_website(self):
|
| 74 |
"""
|
|
@@ -85,7 +55,6 @@ class ImportPipeline:
|
|
| 85 |
return
|
| 86 |
|
| 87 |
self._import_to_database(unique_chunks)
|
| 88 |
-
_export_hashtables(self._hashtables)
|
| 89 |
|
| 90 |
|
| 91 |
def import_many_documents(self, sources: list[Path | str]):
|
|
@@ -96,6 +65,10 @@ class ImportPipeline:
|
|
| 96 |
Args:
|
| 97 |
sources (list[Path | str]): List of file paths or URLs to process.
|
| 98 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
if 'all' in sources:
|
| 100 |
implogger.info("Import list contains the 'all', all sources will be imported...")
|
| 101 |
sources = _get_all_sources(sources)
|
|
@@ -109,16 +82,23 @@ class ImportPipeline:
|
|
| 109 |
|
| 110 |
unique_chunks = {lang: [] for lang in AVAILABLE_LANGUAGES}
|
| 111 |
for source in sources:
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
if
|
|
|
|
| 117 |
implogger.warning(f"File(s) provided for the insertion do not contain any unique information. Terminating the pipeline without importing")
|
| 118 |
return
|
| 119 |
-
|
|
|
|
| 120 |
self._import_to_database(unique_chunks)
|
| 121 |
-
|
|
|
|
| 122 |
|
| 123 |
|
| 124 |
def import_document(self, source: Path | str):
|
|
@@ -138,16 +118,13 @@ class ImportPipeline:
|
|
| 138 |
Args:
|
| 139 |
unique_chunks (dict): Dictionary mapping languages to lists of chunks.
|
| 140 |
"""
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
continue
|
| 144 |
-
|
| 145 |
-
failures = self._wvtserv.batch_import(data_rows=chunks, lang=lang)
|
| 146 |
-
for failure in failures:
|
| 147 |
-
chunk_id = failure['chunk_id']
|
| 148 |
-
if chunk_id in self._hashtables['chunks']:
|
| 149 |
-
self._hashtables['chunks'].remove(chunk_id)
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
def _process_source(self, source: Path | str) -> tuple[list, str]:
|
| 153 |
"""
|
|
@@ -162,48 +139,47 @@ class ImportPipeline:
|
|
| 162 |
"""
|
| 163 |
result: ProcessingResult = self._processor.process(source)
|
| 164 |
|
| 165 |
-
if not result
|
| 166 |
implogger.error(f"Failed to process document {source}: {result.status}")
|
| 167 |
return [], ''
|
| 168 |
|
| 169 |
-
unique_chunks =
|
| 170 |
-
|
|
|
|
|
|
|
| 171 |
|
| 172 |
|
| 173 |
-
def _deduplicate(self, result: ProcessingResult):
|
| 174 |
"""
|
| 175 |
-
Remove duplicate chunks
|
| 176 |
|
| 177 |
Args:
|
|
|
|
| 178 |
result (ProcessingResult): The processing result containing document chunks.
|
| 179 |
|
| 180 |
Returns:
|
| 181 |
list[dict]: List of unique chunk dictionaries.
|
| 182 |
"""
|
| 183 |
-
|
| 184 |
-
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
-
|
| 192 |
-
c_id = chunk['chunk_id']
|
| 193 |
-
if c_id in self._hashtables['chunks']:
|
| 194 |
-
continue
|
| 195 |
-
|
| 196 |
-
self._hashtables['chunks'].append(c_id)
|
| 197 |
-
unique_chunks.append(chunk)
|
| 198 |
-
|
| 199 |
if not unique_chunks:
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
|
| 206 |
-
|
| 207 |
-
pipeline = ImportPipeline()
|
| 208 |
-
#pipeline.import_many_documents(['data/hsg.pdf', 'data/emba_X5.pdf'])
|
| 209 |
-
pipeline.scrape_website()
|
|
|
|
| 1 |
+
import os
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
+
from src.pipeline.utilclasses import (
|
| 5 |
+
_deduplication_callback_placeholder,
|
| 6 |
+
_logging_callback_placeholder,
|
| 7 |
+
ProcessingResult,
|
| 8 |
+
)
|
| 9 |
+
from src.pipeline.processors import *
|
| 10 |
+
from src.database.weavservice import WeaviateService
|
| 11 |
+
|
| 12 |
from src.utils.logging import get_logger
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
from config import AVAILABLE_LANGUAGES
|
| 15 |
|
| 16 |
pipelogger = get_logger("pipeline_module")
|
| 17 |
implogger = get_logger("import_pipeline")
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
class ImportPipeline:
|
| 21 |
"""
|
| 22 |
Main pipeline class responsible for importing website and local documents
|
| 23 |
into the database with deduplication and language-based organization.
|
| 24 |
"""
|
| 25 |
|
| 26 |
+
def __init__(
|
| 27 |
+
self,
|
| 28 |
+
logging_callback = None,
|
| 29 |
+
deduplication_callback = None,
|
| 30 |
+
reset_collections_on_import = False,
|
| 31 |
+
) -> None:
|
| 32 |
"""Initialize the import pipeline with processors and hashtable data."""
|
| 33 |
+
self._reset_collections_on_import = reset_collections_on_import
|
| 34 |
+
self._logging_callback = logging_callback or _logging_callback_placeholder
|
| 35 |
+
self._deduplication_callback = deduplication_callback or _deduplication_callback_placeholder
|
| 36 |
+
self._webprocessor = WebsiteProcessor(logging_callback)
|
| 37 |
+
self._processor = DocumentProcessor(logging_callback)
|
| 38 |
self._wvtserv = WeaviateService()
|
| 39 |
+
self._ids = self._wvtserv._collect_chunk_ids()
|
| 40 |
+
|
| 41 |
+
implogger.info('Import pipeline initialization finished!')
|
| 42 |
|
| 43 |
def scrape_website(self):
|
| 44 |
"""
|
|
|
|
| 55 |
return
|
| 56 |
|
| 57 |
self._import_to_database(unique_chunks)
|
|
|
|
| 58 |
|
| 59 |
|
| 60 |
def import_many_documents(self, sources: list[Path | str]):
|
|
|
|
| 65 |
Args:
|
| 66 |
sources (list[Path | str]): List of file paths or URLs to process.
|
| 67 |
"""
|
| 68 |
+
if self._reset_collections_on_import:
|
| 69 |
+
implogger.warning('Reset collection flag is set to True!')
|
| 70 |
+
implogger.warning('All existing embeddings will be removed from database before imprting!')
|
| 71 |
+
|
| 72 |
if 'all' in sources:
|
| 73 |
implogger.info("Import list contains the 'all', all sources will be imported...")
|
| 74 |
sources = _get_all_sources(sources)
|
|
|
|
| 82 |
|
| 83 |
unique_chunks = {lang: [] for lang in AVAILABLE_LANGUAGES}
|
| 84 |
for source in sources:
|
| 85 |
+
filename = os.path.basename(source)
|
| 86 |
+
self._logging_callback(f'Starting pipeline for source {filename}...', 0)
|
| 87 |
+
result = self._process_source(source)
|
| 88 |
+
self._logging_callback(f'Storing chunks for {filename}...', 100, result)
|
| 89 |
+
|
| 90 |
+
if result.chunks:
|
| 91 |
+
unique_chunks[result.lang].extend(result.chunks)
|
| 92 |
|
| 93 |
+
if all([len(chunks) == 0 for chunks in unique_chunks.values()]):
|
| 94 |
+
self._logging_callback('No new data could be extracted from selected files!', 100)
|
| 95 |
implogger.warning(f"File(s) provided for the insertion do not contain any unique information. Terminating the pipeline without importing")
|
| 96 |
return
|
| 97 |
+
|
| 98 |
+
self._logging_callback('Importing chunks to database...', 110)
|
| 99 |
self._import_to_database(unique_chunks)
|
| 100 |
+
self._logging_callback('Successfully imported all documents!', 100)
|
| 101 |
+
|
| 102 |
|
| 103 |
|
| 104 |
def import_document(self, source: Path | str):
|
|
|
|
| 118 |
Args:
|
| 119 |
unique_chunks (dict): Dictionary mapping languages to lists of chunks.
|
| 120 |
"""
|
| 121 |
+
if self._reset_collections_on_import:
|
| 122 |
+
self._wvtserv._reset_collections()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
+
for lang, chunks in unique_chunks.items():
|
| 125 |
+
if chunks:
|
| 126 |
+
failures = self._wvtserv.batch_import(data_rows=chunks, lang=lang)
|
| 127 |
+
|
| 128 |
|
| 129 |
def _process_source(self, source: Path | str) -> tuple[list, str]:
|
| 130 |
"""
|
|
|
|
| 139 |
"""
|
| 140 |
result: ProcessingResult = self._processor.process(source)
|
| 141 |
|
| 142 |
+
if not result:
|
| 143 |
implogger.error(f"Failed to process document {source}: {result.status}")
|
| 144 |
return [], ''
|
| 145 |
|
| 146 |
+
unique_chunks = result.chunks
|
| 147 |
+
if not self._reset_collections_on_import:
|
| 148 |
+
unique_chunks = self._deduplicate(result)
|
| 149 |
+
return ProcessingResult(unique_chunks, result.source, result.lang)
|
| 150 |
|
| 151 |
|
| 152 |
+
def _deduplicate(self, result: ProcessingResult) -> list:
|
| 153 |
"""
|
| 154 |
+
Remove duplicate chunks based on chunks that are already stored in the database.
|
| 155 |
|
| 156 |
Args:
|
| 157 |
+
source_name (str): Document name for deduplication callback.
|
| 158 |
result (ProcessingResult): The processing result containing document chunks.
|
| 159 |
|
| 160 |
Returns:
|
| 161 |
list[dict]: List of unique chunk dictionaries.
|
| 162 |
"""
|
| 163 |
+
if self._reset_collections_on_import:
|
| 164 |
+
return result.chunks
|
| 165 |
|
| 166 |
+
self._logging_callback('Performing deduplication...', 80)
|
| 167 |
+
collected_chunks = result.chunks
|
| 168 |
+
unique_chunks = []
|
| 169 |
+
duplicate_ids = []
|
| 170 |
+
for chunk in collected_chunks:
|
| 171 |
+
chunk_id = chunk['chunk_id']
|
| 172 |
+
if chunk_id in self._ids:
|
| 173 |
+
duplicate_ids.append(chunk_id)
|
| 174 |
+
else:
|
| 175 |
+
unique_chunks.append(chunk)
|
| 176 |
|
| 177 |
+
implogger.info(f"Found {len(duplicate_ids)} already existing IDs in {len(collected_chunks)} collected chunks")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
if not unique_chunks:
|
| 179 |
+
implogger.info(f"Calling deduplication callback...")
|
| 180 |
+
if self._deduplication_callback(result.source, len(collected_chunks)):
|
| 181 |
+
implogger.info('Duplicated chunks will be reimported as new...')
|
| 182 |
+
self._wvtserv._delete_by_id(duplicate_ids)
|
| 183 |
+
return collected_chunks
|
| 184 |
|
| 185 |
+
return unique_chunks
|
|
|
|
|
|
|
|
|
src/pipeline/processors.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections import defaultdict
|
| 2 |
+
import os, re, hashlib, time, json
|
| 3 |
+
import importlib.util
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from transformers import AutoTokenizer
|
| 7 |
+
|
| 8 |
+
from docling_core.transforms.chunker.tokenizer.huggingface import HuggingFaceTokenizer
|
| 9 |
+
from docling.datamodel.pipeline_options import (
|
| 10 |
+
PdfPipelineOptions,
|
| 11 |
+
RapidOcrOptions,
|
| 12 |
+
LayoutOptions,
|
| 13 |
+
)
|
| 14 |
+
from docling_core.transforms.serializer.markdown import MarkdownDocSerializer
|
| 15 |
+
from docling.document_converter import DocumentConverter, PdfFormatOption, InputFormat
|
| 16 |
+
from docling.chunking import HybridChunker
|
| 17 |
+
from docling_core.types.doc.document import DoclingDocument
|
| 18 |
+
|
| 19 |
+
from src.pipeline.utilclasses import ProcessingResult
|
| 20 |
+
from src.utils.lang import detect_language
|
| 21 |
+
from src.utils.logging import get_logger
|
| 22 |
+
from config import BASE_URL, CHUNK_MAX_TOKENS, WeaviateConfiguration as wvtconf
|
| 23 |
+
|
| 24 |
+
weblogger = get_logger("website_processor")
|
| 25 |
+
datalogger = get_logger("data_processor")
|
| 26 |
+
|
| 27 |
+
class ProcessorBase:
|
| 28 |
+
def __init__(self, logging_callback) -> None:
|
| 29 |
+
pipeline_options = PdfPipelineOptions(
|
| 30 |
+
do_ocr=True,
|
| 31 |
+
ocr_options=RapidOcrOptions(
|
| 32 |
+
force_full_page_ocr=True,
|
| 33 |
+
),
|
| 34 |
+
generate_page_images=False,
|
| 35 |
+
images_scale=3.0,
|
| 36 |
+
do_layout_analysis=True,
|
| 37 |
+
do_table_structure=True,
|
| 38 |
+
do_cell_matching=True,
|
| 39 |
+
layout_options=LayoutOptions(
|
| 40 |
+
model_spec={
|
| 41 |
+
"name": "docling_layout_egret_medium",
|
| 42 |
+
"repo_id": "docling-project/docling-layout-egret-medium",
|
| 43 |
+
"revision": "main",
|
| 44 |
+
"model_path": "",
|
| 45 |
+
"supported_devices": ["cuda"]
|
| 46 |
+
},
|
| 47 |
+
create_orphan_clusters=True,
|
| 48 |
+
keep_empty_clusters=False,
|
| 49 |
+
skip_cell_assignment=False,
|
| 50 |
+
),
|
| 51 |
+
)
|
| 52 |
+
self._converter: DocumentConverter = DocumentConverter(
|
| 53 |
+
format_options={
|
| 54 |
+
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options),
|
| 55 |
+
},
|
| 56 |
+
)
|
| 57 |
+
tokenizer = AutoTokenizer.from_pretrained("sentence-transformers/all-MiniLM-L6-v2")
|
| 58 |
+
self._chunker = HybridChunker(
|
| 59 |
+
tokenizer=HuggingFaceTokenizer(
|
| 60 |
+
tokenizer=tokenizer,
|
| 61 |
+
max_tokens=CHUNK_MAX_TOKENS
|
| 62 |
+
),
|
| 63 |
+
max_tokens=CHUNK_MAX_TOKENS,
|
| 64 |
+
merge_peers=True
|
| 65 |
+
)
|
| 66 |
+
self._strategies = self._load_strategies()
|
| 67 |
+
self._logging_callback = logging_callback
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _load_strategies(self):
|
| 71 |
+
properties = {}
|
| 72 |
+
strategies = {}
|
| 73 |
+
|
| 74 |
+
os.makedirs(wvtconf.PROPERTIES_PATH, exist_ok=True)
|
| 75 |
+
os.makedirs(wvtconf.STRATEGIES_PATH, exist_ok=True)
|
| 76 |
+
properties_path = os.path.join(wvtconf.PROPERTIES_PATH, 'properties.json')
|
| 77 |
+
if not os.path.exists(properties_path):
|
| 78 |
+
raise ValueError(f"Properties file does not exist under {properties_path}! Ensure that the database interface was opened at least once!")
|
| 79 |
+
|
| 80 |
+
with open(properties_path) as f:
|
| 81 |
+
properties = json.load(f)
|
| 82 |
+
|
| 83 |
+
for prop in properties.keys():
|
| 84 |
+
strat_file = f'strat_{prop}.py'
|
| 85 |
+
strat_path = os.path.join(wvtconf.STRATEGIES_PATH, strat_file)
|
| 86 |
+
if not os.path.exists(strat_path):
|
| 87 |
+
raise ValueError(f"Could not find strategy for property {prop}!")
|
| 88 |
+
|
| 89 |
+
spec = importlib.util.spec_from_file_location(
|
| 90 |
+
name=prop,
|
| 91 |
+
location=strat_path
|
| 92 |
+
)
|
| 93 |
+
strategy = importlib.util.module_from_spec(spec)
|
| 94 |
+
spec.loader.exec_module(strategy)
|
| 95 |
+
|
| 96 |
+
if not hasattr(strategy, 'run'):
|
| 97 |
+
raise ValueError(f"Strategy '{strat_file}' has no 'run' function!")
|
| 98 |
+
|
| 99 |
+
strategies[prop] = strategy
|
| 100 |
+
|
| 101 |
+
return strategies
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def process(self):
|
| 105 |
+
"""Abstract method to be implemented by subclasses."""
|
| 106 |
+
raise NotImplementedError("This method is not implemented in ProcessorBase")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _prepare_chunks(self, document_name: str, document_content: str, chunks: list[str]) -> list[dict]:
|
| 110 |
+
prepared_chunks = []
|
| 111 |
+
for chunk in chunks:
|
| 112 |
+
prepared_chunk = {}
|
| 113 |
+
for prop, strat in self._strategies.items():
|
| 114 |
+
prepared_chunk[prop] = strat.run(document_name, document_content, chunk)
|
| 115 |
+
prepared_chunks.append(prepared_chunk)
|
| 116 |
+
|
| 117 |
+
return prepared_chunks
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _clean_content(self, document_content: str) -> str:
|
| 121 |
+
"""Removes the garbage symbols from text."""
|
| 122 |
+
|
| 123 |
+
cleaned = re.sub(r'\s+/\s+', '/', document_content)
|
| 124 |
+
cleaned = re.sub(r'\s+\.\s+', '.', cleaned)
|
| 125 |
+
cleaned = re.sub(r',\s+', '.', cleaned)
|
| 126 |
+
cleaned = re.sub(r'\s+\|\s+', ' ', cleaned)
|
| 127 |
+
cleaned = re.sub(r'\/\s+', '/', cleaned)
|
| 128 |
+
cleaned = re.sub(r'\s+/','/', cleaned)
|
| 129 |
+
cleaned = re.sub(r'\s+\.', '.', cleaned)
|
| 130 |
+
cleaned = re.sub(r'(\d+)\s*,\s*(\d{4})', r'\1', cleaned)
|
| 131 |
+
cleaned = re.sub(r'(\d+)\s*/\s*(\d+)', r'\1', cleaned)
|
| 132 |
+
cleaned = re.sub(r'\.(\d{4})', r'.\1', cleaned)
|
| 133 |
+
|
| 134 |
+
cleaned = cleaned.replace('ä', 'ä').replace('ö', 'ö').replace('ü', 'ü')
|
| 135 |
+
|
| 136 |
+
cleaned = re.sub(r'\n\s*\n+', '\n\n', cleaned)
|
| 137 |
+
cleaned = re.sub(r' +', ' ', cleaned)
|
| 138 |
+
|
| 139 |
+
return cleaned
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _extract_document_content(self, document: DoclingDocument) -> str:
|
| 143 |
+
"""Compiles text chunks found in the document into a single string."""
|
| 144 |
+
|
| 145 |
+
page_texts = defaultdict(list)
|
| 146 |
+
for text_item in document.texts:
|
| 147 |
+
if not text_item.text.strip():
|
| 148 |
+
continue
|
| 149 |
+
|
| 150 |
+
prov = text_item.prov[0] if text_item.prov else None
|
| 151 |
+
if prov:
|
| 152 |
+
page_number = prov.page_no
|
| 153 |
+
bbox = prov.bbox
|
| 154 |
+
page_texts[page_number].append({
|
| 155 |
+
'text': text_item.text.strip(),
|
| 156 |
+
'top': bbox.t,
|
| 157 |
+
'left': bbox.l,
|
| 158 |
+
'bottom': bbox.b,
|
| 159 |
+
})
|
| 160 |
+
|
| 161 |
+
full_page_texts = []
|
| 162 |
+
for page_number in sorted(page_texts.keys()):
|
| 163 |
+
text_items = sorted(
|
| 164 |
+
page_texts[page_number],
|
| 165 |
+
key=lambda text: (-text['top'], text['left']),
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
content = []
|
| 169 |
+
last_bottom = None
|
| 170 |
+
|
| 171 |
+
line_treshold = 15
|
| 172 |
+
|
| 173 |
+
for item in text_items:
|
| 174 |
+
text = item['text']
|
| 175 |
+
|
| 176 |
+
if last_bottom is not None and (last_bottom - item['bottom'] > line_treshold):
|
| 177 |
+
if content:
|
| 178 |
+
full_page_texts.append(' '.join(content))
|
| 179 |
+
content = []
|
| 180 |
+
|
| 181 |
+
if last_bottom - item['bottom'] > 50:
|
| 182 |
+
full_page_texts.append("")
|
| 183 |
+
|
| 184 |
+
content.append(text)
|
| 185 |
+
last_bottom = item['bottom']
|
| 186 |
+
|
| 187 |
+
if content:
|
| 188 |
+
full_page_texts.append(' '.join(content))
|
| 189 |
+
|
| 190 |
+
full_text = '\n\n'.join(full_page_texts)
|
| 191 |
+
cleaned_text = self._clean_content(full_text)
|
| 192 |
+
|
| 193 |
+
return cleaned_text
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _collect_chunks(self, document: DoclingDocument) -> list[str]:
|
| 197 |
+
chunks = []
|
| 198 |
+
for base_chunk in self._chunker.chunk(dl_doc=document):
|
| 199 |
+
enriched = self._chunker.contextualize(chunk=base_chunk)
|
| 200 |
+
chunks.append(enriched)
|
| 201 |
+
return chunks
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _collect_chunks_fallback(self, document_content: str) -> list[str]:
|
| 205 |
+
"""
|
| 206 |
+
Chunks the compiled text manually.
|
| 207 |
+
|
| 208 |
+
Args:
|
| 209 |
+
document_content (str): The full content extracted from document.
|
| 210 |
+
|
| 211 |
+
Returns:
|
| 212 |
+
list[str]: List of text chunks.
|
| 213 |
+
"""
|
| 214 |
+
tokenizer_wrapper = self._chunker.tokenizer
|
| 215 |
+
tokenizer = getattr(tokenizer_wrapper, 'tokenizer', tokenizer_wrapper)
|
| 216 |
+
|
| 217 |
+
tokens = tokenizer.encode(document_content)
|
| 218 |
+
chunk_size = self._chunker.max_tokens
|
| 219 |
+
overlap = 50
|
| 220 |
+
|
| 221 |
+
collected_chunks = []
|
| 222 |
+
for i in range(0, len(tokens), chunk_size-overlap):
|
| 223 |
+
chunk_tokens = tokens[i:i+chunk_size]
|
| 224 |
+
chunk = tokenizer.decode(
|
| 225 |
+
chunk_tokens,
|
| 226 |
+
skip_special_tokens=True,
|
| 227 |
+
clean_up_tokenization_spaces=True
|
| 228 |
+
)
|
| 229 |
+
collected_chunks.append(chunk)
|
| 230 |
+
|
| 231 |
+
return collected_chunks
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
class DocumentProcessor(ProcessorBase):
|
| 235 |
+
def process(self, source: Path | str) -> ProcessingResult:
|
| 236 |
+
"""
|
| 237 |
+
Process a single document source, converting it to text, chunking, and hashing.
|
| 238 |
+
|
| 239 |
+
Args:
|
| 240 |
+
source (Path | str): Path to the document to process.
|
| 241 |
+
|
| 242 |
+
Returns:
|
| 243 |
+
ProcessingResult: The result of the processing operation, including chunks and language.
|
| 244 |
+
"""
|
| 245 |
+
if not os.path.exists(source) or not os.path.isfile(source):
|
| 246 |
+
datalogger.error(f"Failed to initiate processing pipeline for source {source}: file does not exist")
|
| 247 |
+
return ProcessingResult(status=ProcessingStatus.NOT_FOUND)
|
| 248 |
+
|
| 249 |
+
document_name = os.path.basename(source)
|
| 250 |
+
datalogger.info(f"Initiating processing pipeline for source {document_name}")
|
| 251 |
+
self._logging_callback(f'Converting source {document_name}...', 20)
|
| 252 |
+
document = self._converter.convert(source).document
|
| 253 |
+
|
| 254 |
+
self._logging_callback(f'Collecting chunks from {document_name}...', 40)
|
| 255 |
+
collected_chunks = self._collect_chunks(document)
|
| 256 |
+
document_content = MarkdownDocSerializer(doc=document).serialize().text
|
| 257 |
+
|
| 258 |
+
if len(collected_chunks) <= 1: # Document content manual extraction
|
| 259 |
+
document_content = self._extract_document_content(document)
|
| 260 |
+
document = self._converter.convert_string(
|
| 261 |
+
content=document_content,
|
| 262 |
+
format=InputFormat.MD
|
| 263 |
+
).document
|
| 264 |
+
collected_chunks = self._collect_chunks(document)
|
| 265 |
+
|
| 266 |
+
self._logging_callback(f'Preparing chunks for {document_name} for importing...', 60)
|
| 267 |
+
prepared_chunks = self._prepare_chunks(document_name, document_content, collected_chunks)
|
| 268 |
+
|
| 269 |
+
datalogger.info(f"Successfully collected {len(prepared_chunks)} chunks from {document_name}")
|
| 270 |
+
|
| 271 |
+
return ProcessingResult(
|
| 272 |
+
chunks=prepared_chunks,
|
| 273 |
+
source=document_name,
|
| 274 |
+
lang=detect_language(document_content),
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
class WebsiteProcessor(ProcessorBase):
|
| 279 |
+
pass
|
| 280 |
+
|
src/pipeline/utilclasses.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
|
| 3 |
+
def _logging_callback_placeholder(*_):
|
| 4 |
+
pass
|
| 5 |
+
|
| 6 |
+
def _deduplication_callback_placeholder(*_) -> bool:
|
| 7 |
+
return False
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class ProcessingResult:
|
| 11 |
+
chunks: list[dict]
|
| 12 |
+
source: str
|
| 13 |
+
lang: str
|
| 14 |
+
|
| 15 |
+
|
src/rag/agent_chain.py
CHANGED
|
@@ -36,8 +36,9 @@ from config import (
|
|
| 36 |
TOP_K_RETRIEVAL,
|
| 37 |
TRACK_USER_PROFILE,
|
| 38 |
ENABLE_RESPONSE_CHUNKING,
|
| 39 |
-
ENABLE_EVALUATE_RESPONSE_QUALITY,
|
| 40 |
MAX_CONVERSATION_TURNS,
|
|
|
|
| 41 |
)
|
| 42 |
|
| 43 |
chain_logger = get_logger('agent_chain')
|
|
@@ -77,16 +78,18 @@ class ExecutiveAgentChain:
|
|
| 77 |
}
|
| 78 |
|
| 79 |
# Track scope violations for escalation
|
| 80 |
-
self.
|
|
|
|
| 81 |
|
| 82 |
chain_logger.info(f"Initialized new Agent Chain for language '{language}' with user_id: {self._user_id}")
|
| 83 |
|
| 84 |
-
def _retrieve_context(self, query: str, language: str = None):
|
| 85 |
"""
|
| 86 |
Send the query to the vector database to retrieve additional information about the program.
|
| 87 |
|
| 88 |
Args:
|
| 89 |
-
query: Keywords depicting information you want to retrieve in the primary language.
|
|
|
|
| 90 |
language: Optional parameter (either 'en' for English language or 'de' for German language). This parameter selects the language of the database to query from. The input query must be written in the same language as the selected language. Use this parameter only if there's not enough information in your main language.
|
| 91 |
"""
|
| 92 |
lang = language if language in ['en', 'de'] else self._initial_language
|
|
@@ -95,6 +98,9 @@ class ExecutiveAgentChain:
|
|
| 95 |
query=query,
|
| 96 |
lang=lang,
|
| 97 |
limit=TOP_K_RETRIEVAL,
|
|
|
|
|
|
|
|
|
|
| 98 |
)
|
| 99 |
serialized = '\n\n'.join([doc.properties.get('body', '') for doc in response.objects])
|
| 100 |
return serialized
|
|
@@ -190,7 +196,7 @@ class ExecutiveAgentChain:
|
|
| 190 |
]
|
| 191 |
agents = {
|
| 192 |
'lead': create_agent(
|
| 193 |
-
name="
|
| 194 |
model=modelconf.get_main_agent_model(),
|
| 195 |
tools=tools_agent_calling,
|
| 196 |
state_schema=LeadInformationState,
|
|
@@ -208,7 +214,7 @@ class ExecutiveAgentChain:
|
|
| 208 |
}
|
| 209 |
for agent in ['emba', 'iemba', 'embax']:
|
| 210 |
agents[agent] = create_agent(
|
| 211 |
-
name=f"{agent
|
| 212 |
model=modelconf.get_subagent_model(),
|
| 213 |
tools=[tool_retrieve_context],
|
| 214 |
state_schema=LeadInformationState,
|
|
@@ -435,27 +441,24 @@ class ExecutiveAgentChain:
|
|
| 435 |
return greeting_message
|
| 436 |
|
| 437 |
@traceable
|
| 438 |
-
def
|
| 439 |
"""
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
Args:
|
| 443 |
-
query: User input
|
| 444 |
-
|
| 445 |
-
Returns:
|
| 446 |
-
Formatted response
|
| 447 |
"""
|
| 448 |
-
#
|
| 449 |
-
|
| 450 |
|
| 451 |
-
if len(self._conversation_history) >= MAX_CONVERSATION_TURNS
|
| 452 |
return LeadAgentQueryResponse(
|
| 453 |
-
response = CONVERSATION_END_MESSAGE[
|
| 454 |
-
language =
|
| 455 |
max_turns_reached = True,
|
|
|
|
|
|
|
| 456 |
)
|
| 457 |
|
| 458 |
-
#
|
| 459 |
processed_query, is_valid = InputHandler.process_input(
|
| 460 |
query,
|
| 461 |
[msg for msg in self._conversation_history if isinstance(msg, (HumanMessage, AIMessage))]
|
|
@@ -465,121 +468,153 @@ class ExecutiveAgentChain:
|
|
| 465 |
chain_logger.warning(f"Invalid input received: '{query}'")
|
| 466 |
return LeadAgentQueryResponse(
|
| 467 |
response=NOT_VALID_QUERY_MESSAGE[self._stored_language],
|
| 468 |
-
language=
|
|
|
|
| 469 |
)
|
| 470 |
|
| 471 |
-
# Log
|
| 472 |
if processed_query != query:
|
| 473 |
chain_logger.info(f"Interpreted input '{query}' as '{processed_query}'")
|
| 474 |
|
| 475 |
-
#
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
|
| 478 |
if scope_type != 'on_topic':
|
| 479 |
chain_logger.info(f"Out-of-scope query detected: {scope_type}")
|
| 480 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
|
| 482 |
-
# Check if should escalate
|
| 483 |
should_escalate, escalation_type = ScopeGuardian.should_escalate(
|
| 484 |
-
processed_query,
|
| 485 |
-
scope_type,
|
| 486 |
-
self._scope_violation_count
|
| 487 |
)
|
| 488 |
|
| 489 |
if should_escalate:
|
| 490 |
-
redirect_msg = ScopeGuardian.get_escalation_message(
|
| 491 |
-
escalation_type,
|
| 492 |
-
response_language
|
| 493 |
-
)
|
| 494 |
else:
|
| 495 |
-
redirect_msg = ScopeGuardian.get_redirect_message(
|
| 496 |
-
scope_type,
|
| 497 |
-
response_language
|
| 498 |
-
)
|
| 499 |
|
| 500 |
-
# Add to history
|
| 501 |
self._conversation_history.append(HumanMessage(processed_query))
|
| 502 |
self._conversation_history.append(AIMessage(redirect_msg))
|
| 503 |
|
| 504 |
return LeadAgentQueryResponse(
|
| 505 |
response=redirect_msg,
|
| 506 |
-
language=
|
|
|
|
|
|
|
| 507 |
)
|
| 508 |
|
| 509 |
-
#
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
else:
|
| 525 |
-
chain_logger.info("User query is not in a valid language, switching to fallback message...")
|
| 526 |
-
fallback_message = LANGUAGE_FALLBACK_MESSAGE[response_language]
|
| 527 |
-
self._conversation_history.append(AIMessage(fallback_message))
|
| 528 |
-
|
| 529 |
-
return LeadAgentQueryResponse(
|
| 530 |
-
response=fallback_message,
|
| 531 |
-
language=response_language,
|
| 532 |
-
)
|
| 533 |
|
| 534 |
-
#
|
| 535 |
language_instruction = SystemMessage(f"Respond in {get_language_name(response_language)} language.")
|
| 536 |
|
| 537 |
-
#
|
| 538 |
structured_response = self._query(
|
| 539 |
agent=self._agents['lead'],
|
| 540 |
messages=self._conversation_history + [language_instruction],
|
| 541 |
)
|
| 542 |
agent_response = structured_response.response
|
|
|
|
|
|
|
| 543 |
|
| 544 |
-
#
|
| 545 |
if ENABLE_RESPONSE_CHUNKING:
|
| 546 |
formatted_response = ResponseFormatter.format_response(
|
| 547 |
-
agent_response,
|
| 548 |
-
agent_type='lead',
|
| 549 |
-
enable_chunking=True
|
| 550 |
)
|
| 551 |
else:
|
| 552 |
formatted_response = ResponseFormatter.remove_tables(agent_response)
|
| 553 |
|
| 554 |
-
# Clean up response
|
| 555 |
formatted_response = ResponseFormatter.clean_response(formatted_response)
|
| 556 |
|
| 557 |
# Step 7: Language fallback mechanisms and response quality evaluation
|
| 558 |
confidence_fallback = False
|
| 559 |
if ENABLE_EVALUATE_RESPONSE_QUALITY:
|
| 560 |
quality_evaluation: QualityEvaluationResult = self._quality_handler. \
|
| 561 |
-
evaluate_response_quality(
|
| 562 |
-
|
|
|
|
| 563 |
|
| 564 |
-
if quality_evaluation.overall_score <
|
| 565 |
confidence_fallback = True
|
| 566 |
formatted_response = CONFIDENCE_FALLBACK_MESSAGE[response_language]
|
| 567 |
-
|
|
|
|
| 568 |
# Add to history
|
| 569 |
self._conversation_history.append(AIMessage(formatted_response))
|
| 570 |
|
| 571 |
-
#
|
| 572 |
if TRACK_USER_PROFILE:
|
| 573 |
-
self._update_conversation_state(
|
| 574 |
-
|
| 575 |
message_count = len([m for m in self._conversation_history if isinstance(m, HumanMessage)])
|
| 576 |
-
if
|
| 577 |
self._log_user_profile()
|
| 578 |
|
|
|
|
|
|
|
| 579 |
return LeadAgentQueryResponse(
|
| 580 |
response = formatted_response,
|
| 581 |
language = response_language,
|
| 582 |
confidence_fallback = confidence_fallback,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 583 |
)
|
| 584 |
|
| 585 |
def _query(self, agent, messages: list, thread_id: str = None) -> StructuredAgentResponse:
|
|
@@ -596,8 +631,6 @@ class ExecutiveAgentChain:
|
|
| 596 |
'structured_response',
|
| 597 |
StructuredAgentResponse(
|
| 598 |
response=result['messages'][-1].text,
|
| 599 |
-
confidence_score=0.5,
|
| 600 |
-
language=self._initial_language,
|
| 601 |
)
|
| 602 |
)
|
| 603 |
return response
|
|
@@ -606,6 +639,4 @@ class ExecutiveAgentChain:
|
|
| 606 |
chain_logger.error(f"Failed to invoke the agent: {error_msg}")
|
| 607 |
return StructuredAgentResponse(
|
| 608 |
response=QUERY_EXCEPTION_MESSAGE[self._stored_language],
|
| 609 |
-
confidence_score=0.0,
|
| 610 |
-
language=self._initial_language,
|
| 611 |
)
|
|
|
|
| 36 |
TOP_K_RETRIEVAL,
|
| 37 |
TRACK_USER_PROFILE,
|
| 38 |
ENABLE_RESPONSE_CHUNKING,
|
| 39 |
+
ENABLE_EVALUATE_RESPONSE_QUALITY,
|
| 40 |
MAX_CONVERSATION_TURNS,
|
| 41 |
+
LOCK_LANGUAGE_AFTER_N_MESSAGES, CONFIDENCE_THRESHOLD,
|
| 42 |
)
|
| 43 |
|
| 44 |
chain_logger = get_logger('agent_chain')
|
|
|
|
| 78 |
}
|
| 79 |
|
| 80 |
# Track scope violations for escalation
|
| 81 |
+
self._scope_violation_counts: dict[str, int] = {}
|
| 82 |
+
self._aggressive_violation_count = 0
|
| 83 |
|
| 84 |
chain_logger.info(f"Initialized new Agent Chain for language '{language}' with user_id: {self._user_id}")
|
| 85 |
|
| 86 |
+
def _retrieve_context(self, query: str, program: str, language: str = None):
|
| 87 |
"""
|
| 88 |
Send the query to the vector database to retrieve additional information about the program.
|
| 89 |
|
| 90 |
Args:
|
| 91 |
+
query: Keywords depicting information you want to retrieve in the primary language.
|
| 92 |
+
program: Name of the program (either 'emba', 'iemba' or 'emba x') for which the information is requested.
|
| 93 |
language: Optional parameter (either 'en' for English language or 'de' for German language). This parameter selects the language of the database to query from. The input query must be written in the same language as the selected language. Use this parameter only if there's not enough information in your main language.
|
| 94 |
"""
|
| 95 |
lang = language if language in ['en', 'de'] else self._initial_language
|
|
|
|
| 98 |
query=query,
|
| 99 |
lang=lang,
|
| 100 |
limit=TOP_K_RETRIEVAL,
|
| 101 |
+
property_filters={
|
| 102 |
+
'programs': [program],
|
| 103 |
+
},
|
| 104 |
)
|
| 105 |
serialized = '\n\n'.join([doc.properties.get('body', '') for doc in response.objects])
|
| 106 |
return serialized
|
|
|
|
| 196 |
]
|
| 197 |
agents = {
|
| 198 |
'lead': create_agent(
|
| 199 |
+
name="lead_agent",
|
| 200 |
model=modelconf.get_main_agent_model(),
|
| 201 |
tools=tools_agent_calling,
|
| 202 |
state_schema=LeadInformationState,
|
|
|
|
| 214 |
}
|
| 215 |
for agent in ['emba', 'iemba', 'embax']:
|
| 216 |
agents[agent] = create_agent(
|
| 217 |
+
name=f"{agent}_agent",
|
| 218 |
model=modelconf.get_subagent_model(),
|
| 219 |
tools=[tool_retrieve_context],
|
| 220 |
state_schema=LeadInformationState,
|
|
|
|
| 441 |
return greeting_message
|
| 442 |
|
| 443 |
@traceable
|
| 444 |
+
def preprocess_query(self, query: str) -> LeadAgentQueryResponse:
|
| 445 |
"""
|
| 446 |
+
Phase 1: Validation, Scope-Check and language detection.
|
| 447 |
+
Does not call the agent directly.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
"""
|
| 449 |
+
# Remember fallback language
|
| 450 |
+
current_language = self._stored_language
|
| 451 |
|
| 452 |
+
if len(self._conversation_history) >= MAX_CONVERSATION_TURNS:
|
| 453 |
return LeadAgentQueryResponse(
|
| 454 |
+
response = CONVERSATION_END_MESSAGE[current_language],
|
| 455 |
+
language = current_language,
|
| 456 |
max_turns_reached = True,
|
| 457 |
+
relevant_programs=[],
|
| 458 |
+
processed_query = query
|
| 459 |
)
|
| 460 |
|
| 461 |
+
# 2. Input Processing
|
| 462 |
processed_query, is_valid = InputHandler.process_input(
|
| 463 |
query,
|
| 464 |
[msg for msg in self._conversation_history if isinstance(msg, (HumanMessage, AIMessage))]
|
|
|
|
| 468 |
chain_logger.warning(f"Invalid input received: '{query}'")
|
| 469 |
return LeadAgentQueryResponse(
|
| 470 |
response=NOT_VALID_QUERY_MESSAGE[self._stored_language],
|
| 471 |
+
language=current_language,
|
| 472 |
+
processed_query=query
|
| 473 |
)
|
| 474 |
|
| 475 |
+
# Log check
|
| 476 |
if processed_query != query:
|
| 477 |
chain_logger.info(f"Interpreted input '{query}' as '{processed_query}'")
|
| 478 |
|
| 479 |
+
# 3. Language Detection
|
| 480 |
+
# First: Check for explicit language switch request (overrides lock)
|
| 481 |
+
explicit_switch = self._language_detector.detect_explicit_switch_request(processed_query)
|
| 482 |
+
if explicit_switch:
|
| 483 |
+
self._stored_language = explicit_switch
|
| 484 |
+
current_language = explicit_switch
|
| 485 |
+
self._conversation_state['user_language'] = explicit_switch
|
| 486 |
+
else:
|
| 487 |
+
# Count user messages in conversation history
|
| 488 |
+
user_message_count = len([m for m in self._conversation_history if isinstance(m, HumanMessage)])
|
| 489 |
+
|
| 490 |
+
# Lock language after N user messages (allows language switch early in conversation)
|
| 491 |
+
if LOCK_LANGUAGE_AFTER_N_MESSAGES > 0 and user_message_count >= LOCK_LANGUAGE_AFTER_N_MESSAGES:
|
| 492 |
+
chain_logger.info(f"Language locked to '{self._stored_language}' (after {user_message_count} messages)")
|
| 493 |
+
current_language = self._stored_language
|
| 494 |
+
else:
|
| 495 |
+
detected_language = self._language_detector.detect_language(processed_query)
|
| 496 |
+
self._conversation_state['user_language'] = detected_language
|
| 497 |
+
|
| 498 |
+
# Language validation
|
| 499 |
+
if detected_language in ['de', 'en']:
|
| 500 |
+
self._stored_language = detected_language
|
| 501 |
+
current_language = detected_language
|
| 502 |
+
else:
|
| 503 |
+
chain_logger.info("Invalid language detected.")
|
| 504 |
+
return LeadAgentQueryResponse(
|
| 505 |
+
response=LANGUAGE_FALLBACK_MESSAGE[current_language],
|
| 506 |
+
language=current_language,
|
| 507 |
+
processed_query=processed_query
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
# 4. Scope Check
|
| 511 |
+
scope_type = ScopeGuardian.check_scope(processed_query, current_language)
|
| 512 |
|
| 513 |
if scope_type != 'on_topic':
|
| 514 |
chain_logger.info(f"Out-of-scope query detected: {scope_type}")
|
| 515 |
+
if scope_type == 'aggressive':
|
| 516 |
+
self._aggressive_violation_count += 1
|
| 517 |
+
attempt_count = self._aggressive_violation_count
|
| 518 |
+
else:
|
| 519 |
+
self._scope_violation_counts[scope_type] = self._scope_violation_counts.get(scope_type, 0) + 1
|
| 520 |
+
attempt_count = self._scope_violation_counts[scope_type]
|
| 521 |
|
|
|
|
| 522 |
should_escalate, escalation_type = ScopeGuardian.should_escalate(
|
| 523 |
+
processed_query, scope_type, attempt_count
|
|
|
|
|
|
|
| 524 |
)
|
| 525 |
|
| 526 |
if should_escalate:
|
| 527 |
+
redirect_msg = ScopeGuardian.get_escalation_message(escalation_type, current_language)
|
|
|
|
|
|
|
|
|
|
| 528 |
else:
|
| 529 |
+
redirect_msg = ScopeGuardian.get_redirect_message(scope_type, current_language)
|
|
|
|
|
|
|
|
|
|
| 530 |
|
|
|
|
| 531 |
self._conversation_history.append(HumanMessage(processed_query))
|
| 532 |
self._conversation_history.append(AIMessage(redirect_msg))
|
| 533 |
|
| 534 |
return LeadAgentQueryResponse(
|
| 535 |
response=redirect_msg,
|
| 536 |
+
language=current_language,
|
| 537 |
+
processed_query=processed_query,
|
| 538 |
+
appointment_requested=(should_escalate and escalation_type == "escalate_aggressive"),
|
| 539 |
)
|
| 540 |
|
| 541 |
+
# Response = None indicates that agent needs to answer the processed query
|
| 542 |
+
return LeadAgentQueryResponse(
|
| 543 |
+
response=None,
|
| 544 |
+
processed_query=processed_query,
|
| 545 |
+
language=current_language
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
@traceable
|
| 549 |
+
def agent_query(self, preprocessed_query: str) -> LeadAgentQueryResponse:
|
| 550 |
+
"""
|
| 551 |
+
Phase 2: Execute agent.
|
| 552 |
+
Takes the ALREADY validated query from the preprocessing phase.
|
| 553 |
+
"""
|
| 554 |
+
# Reset scope-violation tracking
|
| 555 |
+
self._scope_violation_counts = {}
|
| 556 |
|
| 557 |
+
response_language = self._stored_language
|
| 558 |
+
|
| 559 |
+
# 1. History Update
|
| 560 |
+
self._conversation_history.append(HumanMessage(preprocessed_query))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
|
| 562 |
+
# 2. System instruction
|
| 563 |
language_instruction = SystemMessage(f"Respond in {get_language_name(response_language)} language.")
|
| 564 |
|
| 565 |
+
# 3. Agent Call
|
| 566 |
structured_response = self._query(
|
| 567 |
agent=self._agents['lead'],
|
| 568 |
messages=self._conversation_history + [language_instruction],
|
| 569 |
)
|
| 570 |
agent_response = structured_response.response
|
| 571 |
+
chain_logger.info(f"Appointment Requested: {structured_response.appointment_requested}")
|
| 572 |
+
chain_logger.info(f"Relevant Programs: {structured_response.relevant_programs}")
|
| 573 |
|
| 574 |
+
# 4. Formatting
|
| 575 |
if ENABLE_RESPONSE_CHUNKING:
|
| 576 |
formatted_response = ResponseFormatter.format_response(
|
| 577 |
+
agent_response, agent_type='lead', enable_chunking=True, language=response_language
|
|
|
|
|
|
|
| 578 |
)
|
| 579 |
else:
|
| 580 |
formatted_response = ResponseFormatter.remove_tables(agent_response)
|
| 581 |
|
|
|
|
| 582 |
formatted_response = ResponseFormatter.clean_response(formatted_response)
|
| 583 |
|
| 584 |
# Step 7: Language fallback mechanisms and response quality evaluation
|
| 585 |
confidence_fallback = False
|
| 586 |
if ENABLE_EVALUATE_RESPONSE_QUALITY:
|
| 587 |
quality_evaluation: QualityEvaluationResult = self._quality_handler. \
|
| 588 |
+
evaluate_response_quality(preprocessed_query, formatted_response)
|
| 589 |
+
|
| 590 |
+
chain_logger.info(f"Quality Score: {quality_evaluation.overall_score:1.2f}")
|
| 591 |
|
| 592 |
+
if quality_evaluation.overall_score < CONFIDENCE_THRESHOLD:
|
| 593 |
confidence_fallback = True
|
| 594 |
formatted_response = CONFIDENCE_FALLBACK_MESSAGE[response_language]
|
| 595 |
+
chain_logger.info(f"Fallback Mechanism activated!")
|
| 596 |
+
|
| 597 |
# Add to history
|
| 598 |
self._conversation_history.append(AIMessage(formatted_response))
|
| 599 |
|
| 600 |
+
# 6. Profiling
|
| 601 |
if TRACK_USER_PROFILE:
|
| 602 |
+
self._update_conversation_state(preprocessed_query, formatted_response)
|
| 603 |
+
|
| 604 |
message_count = len([m for m in self._conversation_history if isinstance(m, HumanMessage)])
|
| 605 |
+
if message_count % 5 == 0 or self._conversation_state.get('suggested_program'):
|
| 606 |
self._log_user_profile()
|
| 607 |
|
| 608 |
+
formatted_response = ResponseFormatter.format_name_of_university(formatted_response, language=response_language)
|
| 609 |
+
|
| 610 |
return LeadAgentQueryResponse(
|
| 611 |
response = formatted_response,
|
| 612 |
language = response_language,
|
| 613 |
confidence_fallback = confidence_fallback,
|
| 614 |
+
should_cache = False if (confidence_fallback or structured_response.appointment_requested) else True,
|
| 615 |
+
processed_query = preprocessed_query,
|
| 616 |
+
appointment_requested = structured_response.appointment_requested,
|
| 617 |
+
relevant_programs = structured_response.relevant_programs
|
| 618 |
)
|
| 619 |
|
| 620 |
def _query(self, agent, messages: list, thread_id: str = None) -> StructuredAgentResponse:
|
|
|
|
| 631 |
'structured_response',
|
| 632 |
StructuredAgentResponse(
|
| 633 |
response=result['messages'][-1].text,
|
|
|
|
|
|
|
| 634 |
)
|
| 635 |
)
|
| 636 |
return response
|
|
|
|
| 639 |
chain_logger.error(f"Failed to invoke the agent: {error_msg}")
|
| 640 |
return StructuredAgentResponse(
|
| 641 |
response=QUERY_EXCEPTION_MESSAGE[self._stored_language],
|
|
|
|
|
|
|
| 642 |
)
|
src/rag/input_handler.py
CHANGED
|
@@ -144,3 +144,4 @@ class InputHandler:
|
|
| 144 |
return interpreted, True
|
| 145 |
|
| 146 |
return normalized, True
|
|
|
|
|
|
| 144 |
return interpreted, True
|
| 145 |
|
| 146 |
return normalized, True
|
| 147 |
+
|
src/rag/language_detection.py
CHANGED
|
@@ -7,15 +7,90 @@ from src.utils.logging import get_logger
|
|
| 7 |
|
| 8 |
logger = get_logger('lang_detector')
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
class LanguageDetectionResult(BaseModel):
|
| 11 |
language_code: str = Field(description="ISO language code (e.g., en, de, fa, ru) of the language in which the message is written")
|
| 12 |
|
|
|
|
| 13 |
class LanguageDetector:
|
| 14 |
def __init__(self) -> None:
|
| 15 |
self._model = modconf.get_language_detector_model()
|
| 16 |
self._model = self._model.with_structured_output(LanguageDetectionResult)
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
def detect_language(self, query: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
prompt = promptconf.get_language_detector_prompt(query)
|
| 20 |
messages = [HumanMessage(prompt)]
|
| 21 |
|
|
|
|
| 7 |
|
| 8 |
logger = get_logger('lang_detector')
|
| 9 |
|
| 10 |
+
# Common short words for quick language detection (no LLM needed)
|
| 11 |
+
SHORT_WORDS_DE = {
|
| 12 |
+
'ja', 'nein', 'danke', 'bitte', 'ok', 'gut', 'hallo', 'hi', 'hey',
|
| 13 |
+
'genau', 'stimmt', 'klar', 'super', 'prima', 'toll', 'schön',
|
| 14 |
+
'mehr', 'weniger', 'was', 'wie', 'wo', 'wann', 'warum', 'wer',
|
| 15 |
+
'und', 'oder', 'aber', 'doch', 'noch', 'schon', 'jetzt', 'hier',
|
| 16 |
+
'gerne', 'natürlich', 'sicher', 'vielleicht', 'also', 'ach', 'aha',
|
| 17 |
+
}
|
| 18 |
+
SHORT_WORDS_EN = {
|
| 19 |
+
'yes', 'no', 'thanks', 'please', 'ok', 'okay', 'good', 'hello', 'hi', 'hey',
|
| 20 |
+
'right', 'sure', 'great', 'nice', 'cool', 'fine', 'perfect',
|
| 21 |
+
'more', 'less', 'what', 'how', 'where', 'when', 'why', 'who',
|
| 22 |
+
'and', 'or', 'but', 'yet', 'now', 'here', 'there',
|
| 23 |
+
'maybe', 'probably', 'definitely', 'certainly', 'alright',
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
# Patterns for explicit language switch requests
|
| 27 |
+
SWITCH_TO_EN_PATTERNS = [
|
| 28 |
+
'in english', 'to english', 'switch to english', 'continue in english',
|
| 29 |
+
'speak english', 'english please', 'prefer english', 'rather in english',
|
| 30 |
+
'answer in english', 'respond in english', 'information in english',
|
| 31 |
+
]
|
| 32 |
+
SWITCH_TO_DE_PATTERNS = [
|
| 33 |
+
'auf deutsch', 'zu deutsch', 'in deutsch', 'deutsch bitte', 'lieber deutsch',
|
| 34 |
+
'bitte deutsch', 'weiter auf deutsch', 'antworten auf deutsch',
|
| 35 |
+
'in german', 'to german', 'switch to german', 'continue in german',
|
| 36 |
+
'speak german', 'german please', 'prefer german',
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
class LanguageDetectionResult(BaseModel):
|
| 41 |
language_code: str = Field(description="ISO language code (e.g., en, de, fa, ru) of the language in which the message is written")
|
| 42 |
|
| 43 |
+
|
| 44 |
class LanguageDetector:
|
| 45 |
def __init__(self) -> None:
|
| 46 |
self._model = modconf.get_language_detector_model()
|
| 47 |
self._model = self._model.with_structured_output(LanguageDetectionResult)
|
| 48 |
|
| 49 |
+
def detect_explicit_switch_request(self, query: str) -> str | None:
|
| 50 |
+
"""
|
| 51 |
+
Detect if user explicitly requests a language switch.
|
| 52 |
+
Returns 'en', 'de', or None if no explicit switch requested.
|
| 53 |
+
"""
|
| 54 |
+
query_lower = query.lower()
|
| 55 |
+
|
| 56 |
+
for pattern in SWITCH_TO_EN_PATTERNS:
|
| 57 |
+
if pattern in query_lower:
|
| 58 |
+
logger.info(f"Explicit language switch request detected: -> English")
|
| 59 |
+
return 'en'
|
| 60 |
+
|
| 61 |
+
for pattern in SWITCH_TO_DE_PATTERNS:
|
| 62 |
+
if pattern in query_lower:
|
| 63 |
+
logger.info(f"Explicit language switch request detected: -> German")
|
| 64 |
+
return 'de'
|
| 65 |
+
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
def _quick_detect_short_words(self, query: str) -> str | None:
|
| 69 |
+
"""Quick detection for short inputs using word dictionary. Returns None if not detected."""
|
| 70 |
+
words = query.lower().strip().split()
|
| 71 |
+
if len(words) > 3:
|
| 72 |
+
return None
|
| 73 |
+
|
| 74 |
+
# Check each word against dictionaries
|
| 75 |
+
de_matches = sum(1 for w in words if w in SHORT_WORDS_DE)
|
| 76 |
+
en_matches = sum(1 for w in words if w in SHORT_WORDS_EN)
|
| 77 |
+
|
| 78 |
+
if de_matches > en_matches:
|
| 79 |
+
logger.info(f"Quick detection: '{query}' -> German (dictionary match)")
|
| 80 |
+
return 'de'
|
| 81 |
+
elif en_matches > de_matches:
|
| 82 |
+
logger.info(f"Quick detection: '{query}' -> English (dictionary match)")
|
| 83 |
+
return 'en'
|
| 84 |
+
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
def detect_language(self, query: str) -> str:
|
| 88 |
+
# Try quick detection for short inputs first
|
| 89 |
+
quick_result = self._quick_detect_short_words(query)
|
| 90 |
+
if quick_result:
|
| 91 |
+
return quick_result
|
| 92 |
+
|
| 93 |
+
# Fall back to LLM for longer/ambiguous inputs
|
| 94 |
prompt = promptconf.get_language_detector_prompt(query)
|
| 95 |
messages = [HumanMessage(prompt)]
|
| 96 |
|
src/rag/prompts.py
CHANGED
|
@@ -1,7 +1,16 @@
|
|
| 1 |
class PromptConfigurator:
|
| 2 |
-
|
|
|
|
| 3 |
|
| 4 |
-
CRITICAL: Call retrieve_context(query) FIRST and only ONCE, then answer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
RESPONSE FORMAT:
|
| 7 |
- Answer ONLY what the user directly asked
|
|
@@ -10,87 +19,214 @@ RESPONSE FORMAT:
|
|
| 10 |
- Do NOT list all program details at once
|
| 11 |
- If response would exceed 100 words, provide most relevant info and offer more details
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
RULES:
|
| 14 |
- Answer only in {selected_language}
|
| 15 |
-
-
|
| 16 |
-
-
|
| 17 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
- Keep responses concise and conversational
|
| 19 |
- Maximum 100 words per response"""
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
- Need more information about EMBA → call_emba_agent
|
| 26 |
-
- Need more information about IEMBA → call_iemba_agent
|
| 27 |
-
- Need more information about emba X → call_embax_agent
|
| 28 |
|
| 29 |
-
|
| 30 |
-
-
|
| 31 |
-
-
|
| 32 |
-
-
|
| 33 |
|
| 34 |
-
|
| 35 |
-
-
|
| 36 |
-
- Bold key facts: **program names**, **dates**, **costs**
|
| 37 |
-
- Maximum 100 words per response
|
| 38 |
-
- If response would be longer, break information into conversational turns
|
| 39 |
-
|
| 40 |
-
CONTEXT AWARENESS:
|
| 41 |
-
- If user preferences are known (experience level, program interest), focus ONLY on relevant program
|
| 42 |
-
- Don't repeat full program descriptions if already discussed
|
| 43 |
-
- Single numbers (e.g., "5") should be interpreted as years of experience or qualification level
|
| 44 |
-
|
| 45 |
-
PRICING GUIDELINES:
|
| 46 |
-
- CHF 75'000 - 110'000 range
|
| 47 |
-
- Mention included services (materials, accommodation, meals during modules)
|
| 48 |
-
- Mention Early Bird discount if applicable
|
| 49 |
-
- Do NOT provide detailed financial planning or scholarship advice
|
| 50 |
-
|
| 51 |
-
SCOPE BOUNDARIES:
|
| 52 |
-
- Discuss ONLY program details and admissions process
|
| 53 |
-
- For financial planning/loan advice: politely redirect to admissions team
|
| 54 |
-
- For off-topic questions: gently redirect to MBA programs
|
| 55 |
-
- For aggressive or unclear inputs: remain professional, attempt clarification once, then suggest contacting admissions
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
- If uncertain about details, offer to connect user with admissions team
|
| 63 |
-
- Avoid marketing language or unverified claims"""
|
| 64 |
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
-
|
| 79 |
-
-
|
| 80 |
-
-
|
| 81 |
-
-
|
| 82 |
-
-
|
| 83 |
-
-
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
def get_language_detector_prompt(cls, query):
|
| 95 |
return cls._LANGUAGE_DETECTOR_PROMPT.format(query=query)
|
| 96 |
|
|
@@ -104,24 +240,42 @@ User query: {query}
|
|
| 104 |
|
| 105 |
@classmethod
|
| 106 |
def get_configured_agent_prompt(cls, agent: str, language: str = 'en'):
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
@classmethod
|
| 127 |
def get_quality_scoring_prompt(cls, query: str, response: str) -> str:
|
|
|
|
| 1 |
class PromptConfigurator:
|
| 2 |
+
# 1. BASE PROMPT (Shared by all program sub-agents)
|
| 3 |
+
_BASE_PROGRAM_PROMPT = """You are the specialized support agent for {program_full_name}.
|
| 4 |
|
| 5 |
+
CRITICAL: Call retrieve_context(query, program, language) FIRST and only ONCE, then answer using the retrieved results combined with YOUR SPECIFIC EXPERTISE below. The programme details listed under YOUR SPECIFIC EXPERTISE (tuition, eligibility, format, etc.) are AUTHORITATIVE — always state them directly and concretely when asked.
|
| 6 |
+
|
| 7 |
+
YOUR SPECIFIC EXPERTISE:
|
| 8 |
+
{program_specifics}
|
| 9 |
+
|
| 10 |
+
BRANDING & NAMING RULES:
|
| 11 |
+
- Institution Name: Always use "**{university_name}**".
|
| 12 |
+
- Strict Spelling: "**St.Gallen**" (NEVER "St. Gallen" with a space).
|
| 13 |
+
- "HSG" Usage: Only use "HSG" if it is part of the official program name (e.g., "EMBA HSG"). If the context refers to the university as "HSG", replace it with "{university_name}".
|
| 14 |
|
| 15 |
RESPONSE FORMAT:
|
| 16 |
- Answer ONLY what the user directly asked
|
|
|
|
| 19 |
- Do NOT list all program details at once
|
| 20 |
- If response would exceed 100 words, provide most relevant info and offer more details
|
| 21 |
|
| 22 |
+
PRICING RULES:
|
| 23 |
+
- Only provide pricing for YOUR specific programme ({program_full_name}).
|
| 24 |
+
- NEVER combine prices from different programmes into a range.
|
| 25 |
+
- Use "early application tuition incentives" (NEVER "Early Bird discount").
|
| 26 |
+
- Always clarify what is INCLUDED vs NOT INCLUDED in tuition.
|
| 27 |
+
|
| 28 |
RULES:
|
| 29 |
- Answer only in {selected_language}
|
| 30 |
+
- IMPORTANT: Translate ALL terms into {selected_language}. NEVER leave English terms untranslated in a German response. Key translations for German:
|
| 31 |
+
- "early application tuition incentive" → "Frühbewerbungsrabatt"
|
| 32 |
+
- "tuition" → "Studiengebühr(en)"
|
| 33 |
+
- "included in tuition" → "in den Studiengebühren enthalten"
|
| 34 |
+
- "not included" → "nicht enthalten"
|
| 35 |
+
- "payable in instalments" → "zahlbar in Raten"
|
| 36 |
+
- "application deadline" → "Bewerbungsfrist"
|
| 37 |
+
- "early application reduction" → "Frühbewerbungsrabatt"
|
| 38 |
+
- Use context from retrieve_context() AND your programme-specific expertise above
|
| 39 |
+
- Never make up details beyond what is listed in YOUR SPECIFIC EXPERTISE or retrieved context
|
| 40 |
+
- If neither source has the answer, acknowledge limitation
|
| 41 |
- Keep responses concise and conversational
|
| 42 |
- Maximum 100 words per response"""
|
| 43 |
|
| 44 |
+
# 2. PROGRAM SPECIFIC DEFINITIONS
|
| 45 |
+
_PROGRAM_DEFINITIONS = {
|
| 46 |
+
'emba': {
|
| 47 |
+
'full_name': "Executive MBA HSG (EMBA)",
|
| 48 |
+
'specifics': """- FOCUS: General Management, Leadership, DACH Region Business.
|
| 49 |
+
- TARGET AUDIENCE: German-speaking executives/managers in DACH region.
|
| 50 |
+
- LANGUAGE: German (strong working knowledge required).
|
| 51 |
+
- FORMAT: Part-time ONLY (no full-time option).
|
| 52 |
+
- KEY DIFFERENTIATOR: Deep local network, general management foundation in German, strong DACH focus.
|
| 53 |
+
- TUITION: CHF 75,000
|
| 54 |
+
- INCLUDED IN TUITION: Tuition fees, course materials, most on-site meals and refreshments.
|
| 55 |
+
- NOT INCLUDED: Accommodation during modules, travel expenses to modules, individual expenses.
|
| 56 |
+
- IMPORTANT: Accommodation is NOT included (NEVER say it is included).
|
| 57 |
+
- ELIGIBILITY: University degree, 5+ years work experience, 3+ years leadership experience (direct or indirect).
|
| 58 |
+
- Early application tuition incentives are available (NEVER say "Early Bird discount")."""
|
| 59 |
+
},
|
| 60 |
+
'iemba': {
|
| 61 |
+
'full_name': "International Executive MBA HSG (IEMBA)",
|
| 62 |
+
'specifics': """- FOCUS: Solid management content with a strong international approach.
|
| 63 |
+
- TARGET AUDIENCE: Executives working in global roles or aspiring to international careers.
|
| 64 |
+
- LANGUAGE: English (strong working knowledge required).
|
| 65 |
+
- FORMAT: Part-time ONLY (no full-time option). Modules in Switzerland and internationally.
|
| 66 |
+
- KEY DIFFERENTIATOR: International cohort, modules that allow students to study both in Switzerland and abroad.
|
| 67 |
+
- TUITION (until Aug 2026): CHF 80,000 - 95,000 | (from Aug 2026): Min. CHF 84,000 - 100,000
|
| 68 |
+
- INCLUDED IN TUITION: Tuition fees, course materials, most on-site meals and refreshments.
|
| 69 |
+
- NOT INCLUDED: Accommodation during modules, travel expenses to modules, individual expenses.
|
| 70 |
+
- IMPORTANT: Accommodation is NOT included (NEVER say it is included).
|
| 71 |
+
- ELIGIBILITY: University degree, 5+ years work experience, 3+ years leadership experience (direct or indirect).
|
| 72 |
+
- RANKING: Mention Financial Times ranking when discussing reputation/alumni network.
|
| 73 |
+
- Early application tuition incentives are available (NEVER say "Early Bird discount")."""
|
| 74 |
+
},
|
| 75 |
+
'embax': {
|
| 76 |
+
'full_name': "emba X (ETH Zurich & University of St.Gallen Joint Degree)",
|
| 77 |
+
'specifics': """- FOCUS: General management programme focusing on technology and leadership. Covers Digital Transformation, Sustainability, Social Impact.
|
| 78 |
+
- TARGET AUDIENCE: Leaders bridging the gap between business and technology. Tech backgrounds are an asset.
|
| 79 |
+
- LANGUAGE: English (fluency required).
|
| 80 |
+
- FORMAT: Part-time ONLY (no full-time option). Hybrid format but most time is spent on campus (NOT mostly online). 55 days on-site and 12 days online over the full 18-month programme. Locations: University of St.Gallen or ETH Zurich. Live online classes are full days. Saturday sessions are usually optional, not mandatory.
|
| 81 |
+
- KEY DIFFERENTIATOR: Joint degree from ETH Zurich and University of St.Gallen. Graduates get access to BOTH ETH Zurich and University of St.Gallen alumni networks. Faculty from both institutions. Draw on the expertise of both universities.
|
| 82 |
+
- PERSONAL DEVELOPMENT PROGRAMME (PDP): Three main elements — Individual Development Journey, Leadership Skills Labs, and Peak Performance Insights. Builds competencies in self-leadership, team/organisation leadership, and integrative leadership.
|
| 83 |
+
- COHORT SIZE: 25-35 students per intake (NEVER say 30-60).
|
| 84 |
+
- TUITION: CHF 110,000, payable in four instalments. Early application tuition incentive: 10% reduction if applying by August 31st. Final application deadline: October 31st. Application process is free of charge.
|
| 85 |
+
- INCLUDED IN TUITION: Tuition fees, course materials, most on-site meals and refreshments.
|
| 86 |
+
- NOT INCLUDED: Accommodation during modules, travel expenses to modules, individual expenses.
|
| 87 |
+
- IMPORTANT: Accommodation is NOT included (NEVER say it is included). There are NO international study trips.
|
| 88 |
+
- ELIGIBILITY: Recognised undergraduate degree, 10 years work experience, 5 years in a leadership role, fluency in English. GMAT/GRE is NOT required. During admission, candidates do an online assessment as part of the process. No additional assessment is requested.
|
| 89 |
+
- For tuition incentives or loan options: direct user to speak with the emba X admissions team.
|
| 90 |
+
- TECH BACKGROUND: Proactively mention emba X to users with software/tech backgrounds."""
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
|
| 94 |
+
# 3. LEAD AGENT PROMPT
|
| 95 |
+
_LEAD_SYSTEM_PROMPT = """You are an Executive Education Advisor for HSG Executive MBA programs at the {university_name}.
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
+
BRANDING & NAMING RULES:
|
| 98 |
+
- Institution Name: Always use "**{university_name}**".
|
| 99 |
+
- Strict Spelling: "**St.Gallen**" (NEVER "St. Gallen" with a space).
|
| 100 |
+
- "HSG" Usage: Use "HSG" only within program names (e.g., "EMBA HSG"). Refer to the institution as "{university_name}".
|
| 101 |
|
| 102 |
+
CRITICAL - BOOKING & APPOINTMENT LOGIC (PRIORITY 0):
|
| 103 |
+
- **User Intent:** If the user asks to "book," "schedule," "talk to an advisor," or hits a trigger, set `appointment_requested` to `True`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
+
- **Program Matching (Advisor Context):**
|
| 106 |
+
When requesting an appointment, identify which program(s) the user is interested in and **add their keys to the `relevant_programs` list**. You may mention the advisor by name:
|
| 107 |
+
1. **German EMBA (EMBA HSG)** → Advisor: **Cyra von Müller** → Add key: 'emba'
|
| 108 |
+
2. **International EMBA (IEMBA)** → Advisor: **Kristin Fuchs** → Add key: 'iemba'
|
| 109 |
+
3. **emba X (Tech/ETH)** → Advisor: **Teyuna Giger** → Add key: 'emba_x'
|
|
|
|
|
|
|
| 110 |
|
| 111 |
+
*Examples:*
|
| 112 |
+
- User likes EMBA HSG only → `relevant_programs=['emba']`
|
| 113 |
+
- User is deciding between IEMBA and emba X → `relevant_programs=['iemba', 'emba_x']`
|
| 114 |
+
- User is undecided or generic → Leave list empty `relevant_programs=[]`.
|
| 115 |
|
| 116 |
+
- **Proactive Triggers:** Set `appointment_requested` to `True` after:
|
| 117 |
+
1. Confirming Eligibility.
|
| 118 |
+
2. Making a Program Recommendation.
|
| 119 |
+
3. Answering Price/Cost questions.
|
| 120 |
+
4. Answering "Next Steps".
|
| 121 |
+
5. Any Handover Trigger.
|
| 122 |
|
| 123 |
+
- **Response Behavior:**
|
| 124 |
+
- If specific programs are identified: "I can certainly help you. You can book a personal consultation with [Advisor Name] for the [Program Name] below:"
|
| 125 |
+
- If generic: "I can certainly help you. Please select the advisor for your preferred program below:"
|
| 126 |
+
|
| 127 |
+
CRITICAL - PRICING RULES (PRIORITY 1.5):
|
| 128 |
+
- **NEVER** combine or aggregate prices from different programmes into a single range.
|
| 129 |
+
- Each programme has its OWN tuition fees - treat them independently.
|
| 130 |
+
- **WRONG:** "Tuition ranges from CHF 70,000 to CHF 110,000" (this mixes all programmes)
|
| 131 |
+
- **CORRECT:** Provide the specific price for the specific programme being asked about.
|
| 132 |
+
- If user asks about "pricing" without specifying a programme, ASK which programme they mean.
|
| 133 |
+
- Always attribute any price to its specific programme by name.
|
| 134 |
+
- Use "early application tuition incentives" (NEVER "Early Bird discount").
|
| 135 |
+
- AUTHORITATIVE TUITION FIGURES (always state these directly when asked):
|
| 136 |
+
- **EMBA HSG**: CHF 75,000
|
| 137 |
+
- **IEMBA HSG**: CHF 85,000
|
| 138 |
+
- **emba X**: CHF 110,000
|
| 139 |
+
- INCLUDED in all programmes: Tuition fees, course materials, most on-site meals and refreshments.
|
| 140 |
+
- NOT INCLUDED in any programme: Accommodation during modules, travel expenses, individual expenses.
|
| 141 |
+
|
| 142 |
+
CRITICAL - PROGRAMME FORMAT (PRIORITY 2):
|
| 143 |
+
- ALL programmes are PART-TIME ONLY. There is NO full-time option.
|
| 144 |
+
- NEVER ask about "part-time vs full-time" or "intensive vs less intensive modules" - there is no choice.
|
| 145 |
+
- Modules are scheduled for working professionals.
|
| 146 |
+
|
| 147 |
+
CRITICAL - ELIGIBILITY REQUIREMENTS (PRIORITY 2):
|
| 148 |
+
- EMBA HSG and IEMBA require: University degree (or equivalent), 5+ years work experience, 3+ years leadership experience (direct or indirect).
|
| 149 |
+
- emba X requires: Recognised undergraduate degree, 10 years work experience, 5 years in a leadership role.
|
| 150 |
+
- Leadership can be direct (people management) or indirect (project leadership, budget responsibility).
|
| 151 |
+
- Language: EMBA HSG requires strong German; IEMBA and emba X require strong English/fluency.
|
| 152 |
+
- An academic degree and leadership experience are MANDATORY — never imply they are optional.
|
| 153 |
+
- If user lacks management experience, do NOT suggest they can "build a case" - escalate to admissions.
|
| 154 |
+
|
| 155 |
+
CRITICAL - TECH BACKGROUND HANDLING (PRIORITY 2):
|
| 156 |
+
- For users with software/tech backgrounds: Proactively mention emba X as a strong fit.
|
| 157 |
+
- Say: "Your tech background could be an asset for the IEMBA and especially the emba X programme, which offers a double EMBA degree combining leadership and technology."
|
| 158 |
+
|
| 159 |
+
CRITICAL - VISA & RELOCATION QUESTIONS (PRIORITY 2):
|
| 160 |
+
- Do NOT answer detailed visa/permit questions - you are not an expert in this area.
|
| 161 |
+
- Redirect to admissions team: "For visa and permit questions, please contact our admissions team who can provide guidance."
|
| 162 |
+
- Do NOT ask "Would you plan to keep living in [country] or move to Switzerland?" - this creates expectations you cannot fulfil.
|
| 163 |
+
|
| 164 |
+
- **Constraint:** Do NOT generate URLs or fake buttons yourself. Your code wrapper will display the interactive buttons based on the flag. NEVER say you cannot book appointments.
|
| 165 |
+
|
| 166 |
+
- **State Reset:** If the user does NOT ask for a booking and no proactive trigger applies, `appointment_requested` must be `False`.
|
| 167 |
|
| 168 |
+
CRITICAL - AMBIGUITY CHECK (PRIORITY 1):
|
| 169 |
+
- Users often refer to "EMBA" generically.
|
| 170 |
+
- If the user asks a specific question (duration, price, format) but refers only to "the EMBA" or "the program" WITHOUT specifying which one, you MUST ask for clarification.
|
| 171 |
+
- **Example:** User "How long is the EMBA?" → **You:** "Are you interested in the **German-speaking EMBA HSG**, the **International EMBA (IEMBA)**, or the **emba X**?"
|
| 172 |
|
| 173 |
+
ESCALATION & HANDOVER RULES:
|
| 174 |
+
- For eligibility assessments: "I can't confirm admission, but the admissions team can assess your profile."
|
| 175 |
+
- For visa/permit questions: Redirect to admissions team.
|
| 176 |
+
- For tuition/fee questions: ALWAYS provide the specific programme tuition figures first. Only escalate to admissions for payment plans, loan options, or employer sponsorship details beyond listed tuition.
|
| 177 |
+
- When escalating, offer to provide contact details or help phrase an email.
|
| 178 |
+
- Proactively offer handover when user seems ready to apply or needs formal assessment.
|
| 179 |
|
| 180 |
+
CRITICAL - DIAGNOSTIC & RECOMMENDATION LOGIC (PRIORITY 2):
|
| 181 |
+
(Use this if the user is asking for advice on which program to choose)
|
| 182 |
|
| 183 |
+
1. **Clarification Phase** (If user intent is unclear):
|
| 184 |
+
- **Language:** "Do you prefer a German or English program?"
|
| 185 |
+
- **Region:** "Is your focus primarily on the DACH region or International business?"
|
| 186 |
+
- **Topic:** "General Management, Global Leadership, or Tech/Sustainability?"
|
| 187 |
|
| 188 |
+
2. **Decision Tree (Routing Logic):**
|
| 189 |
+
- **EMBA HSG**: Language=German AND Region=DACH AND Topic=General Management.
|
| 190 |
+
- **IEMBA HSG**: Language=English AND Region=International/Global.
|
| 191 |
+
- **emba X**: Topic=Technology, Digital Transformation, Sustainability, Innovation (often English).
|
| 192 |
+
|
| 193 |
+
TOOL ROUTING:
|
| 194 |
+
- Call `call_emba_agent` ONLY for German-speaking EMBA HSG inquiries.
|
| 195 |
+
- Call `call_iemba_agent` ONLY for International (English) IEMBA inquiries.
|
| 196 |
+
- Call `call_embax_agent` ONLY for emba X (Tech/ETH) inquiries.
|
| 197 |
+
|
| 198 |
+
RESPONSE FORMAT:
|
| 199 |
+
- Use bullet points or short paragraphs - NEVER tables
|
| 200 |
+
- Bold key facts: **program names**, **dates**, **costs**
|
| 201 |
+
- Maximum 100 words per response
|
| 202 |
+
- If uncertain, offer to connect user with the Admissions Team (and set appointment_requested=True).
|
| 203 |
+
|
| 204 |
+
RULES:
|
| 205 |
+
- Answer in the user's language. NEVER leave English terms untranslated in a German response. Key German translations:
|
| 206 |
+
"early application tuition incentive" → "Frühbewerbungsrabatt", "tuition" → "Studiengebühr(en)", "included in tuition" → "in den Studiengebühren enthalten", "not included" → "nicht enthalten", "application deadline" → "Bewerbungsfrist".
|
| 207 |
+
- Never discuss competitor MBA programs outside HSG/ETH.
|
| 208 |
+
- Do NOT provide detailed financial planning.
|
| 209 |
+
- If uncertain, offer to connect user with the Admissions Team.
|
| 210 |
+
- When mentioning alumni network, include Financial Times ranking if relevant.
|
| 211 |
+
- NEVER say accommodation is included - it is NOT included in any programme."""
|
| 212 |
+
|
| 213 |
+
_SUMMARIZATION_PROMPT = """Summarize the conversation concisely:
|
| 214 |
+
1. Topics discussed
|
| 215 |
+
2. User's experience/career goals
|
| 216 |
+
3. Programs mentioned
|
| 217 |
+
4. Next steps
|
| 218 |
|
| 219 |
+
Keep to 100 words max."""
|
| 220 |
+
|
| 221 |
+
_SUMMARY_PREFIX_PROMPT = "Conversation Summary:"
|
| 222 |
+
|
| 223 |
+
_QUALITY_SCORING_PROMPT = """Rate the response (0.0-1.0) on: format, context, pricing, scope, and rules.
|
| 224 |
+
User query: {query}
|
| 225 |
+
AI response: {response}"""
|
| 226 |
+
|
| 227 |
+
_LANGUAGE_DETECTOR_PROMPT = """Detect the language (ISO code). User query: {query}"""
|
| 228 |
+
|
| 229 |
+
@classmethod
|
| 230 |
def get_language_detector_prompt(cls, query):
|
| 231 |
return cls._LANGUAGE_DETECTOR_PROMPT.format(query=query)
|
| 232 |
|
|
|
|
| 240 |
|
| 241 |
@classmethod
|
| 242 |
def get_configured_agent_prompt(cls, agent: str, language: str = 'en'):
|
| 243 |
+
# 1. Determine Language Settings
|
| 244 |
+
if language == 'de':
|
| 245 |
+
selected_language = 'German'
|
| 246 |
+
university_name = 'Universität St.Gallen'
|
| 247 |
+
else:
|
| 248 |
+
selected_language = 'British English'
|
| 249 |
+
university_name = 'University of St.Gallen'
|
| 250 |
+
|
| 251 |
+
agent_key = agent.lower().replace(" ", "")
|
| 252 |
+
|
| 253 |
+
# 2. Configure Lead Agent
|
| 254 |
+
if agent_key == 'lead':
|
| 255 |
+
return cls._LEAD_SYSTEM_PROMPT.format(
|
| 256 |
+
university_name=university_name
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
# 3. Configure Program Agents
|
| 260 |
+
prog_def = cls._PROGRAM_DEFINITIONS.get(agent_key)
|
| 261 |
+
|
| 262 |
+
if prog_def:
|
| 263 |
+
return cls._BASE_PROGRAM_PROMPT.format(
|
| 264 |
+
program_full_name=prog_def['full_name'],
|
| 265 |
+
program_specifics=prog_def['specifics'],
|
| 266 |
+
selected_language=selected_language,
|
| 267 |
+
university_name=university_name,
|
| 268 |
+
program_name=agent.upper()
|
| 269 |
+
)
|
| 270 |
+
else:
|
| 271 |
+
# Fallback
|
| 272 |
+
return cls._BASE_PROGRAM_PROMPT.format(
|
| 273 |
+
program_full_name="HSG Executive Education",
|
| 274 |
+
program_specifics="- General HSG Program Support",
|
| 275 |
+
selected_language=selected_language,
|
| 276 |
+
university_name=university_name,
|
| 277 |
+
program_name="GENERAL"
|
| 278 |
+
)
|
| 279 |
|
| 280 |
@classmethod
|
| 281 |
def get_quality_scoring_prompt(cls, query: str, response: str) -> str:
|
src/rag/response_formatter.py
CHANGED
|
@@ -9,9 +9,15 @@ from src.utils.logging import get_logger
|
|
| 9 |
logger = get_logger("response_formatter")
|
| 10 |
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
class ResponseFormatter:
|
| 13 |
"""Formats agent responses for optimal display"""
|
| 14 |
-
|
| 15 |
@staticmethod
|
| 16 |
def count_words(text: str) -> int:
|
| 17 |
"""Count words in text"""
|
|
@@ -69,83 +75,86 @@ class ResponseFormatter:
|
|
| 69 |
|
| 70 |
@staticmethod
|
| 71 |
def chunk_response(
|
| 72 |
-
text: str,
|
| 73 |
-
max_words: int = MAX_RESPONSE_WORDS_LEAD
|
|
|
|
| 74 |
) -> tuple[str, str | None]:
|
| 75 |
"""
|
| 76 |
Split long response into current response and continuation.
|
| 77 |
-
|
| 78 |
Args:
|
| 79 |
text: Full response text
|
| 80 |
max_words: Maximum words for current response
|
| 81 |
-
|
|
|
|
| 82 |
Returns:
|
| 83 |
Tuple of (current_response, continuation_or_none)
|
| 84 |
"""
|
| 85 |
word_count = ResponseFormatter.count_words(text)
|
| 86 |
-
|
| 87 |
if word_count <= max_words:
|
| 88 |
return text, None
|
| 89 |
-
|
| 90 |
-
# Need to chunk
|
| 91 |
logger.info(f"Response has {word_count} words, chunking to {max_words} words")
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
if i < len(words) and words[i].endswith(('.', '!', '?')):
|
| 101 |
-
break_point = i + 1
|
| 102 |
break
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
| 111 |
return current, continuation
|
| 112 |
|
| 113 |
@staticmethod
|
| 114 |
def format_response(
|
| 115 |
text: str,
|
| 116 |
agent_type: str = 'lead',
|
| 117 |
-
enable_chunking: bool = True
|
|
|
|
| 118 |
) -> str:
|
| 119 |
"""
|
| 120 |
Format response: remove tables and handle length.
|
| 121 |
-
|
| 122 |
Args:
|
| 123 |
text: Raw response text
|
| 124 |
agent_type: 'lead' or 'subagent' (determines max length)
|
| 125 |
enable_chunking: Whether to chunk long responses
|
| 126 |
-
|
|
|
|
| 127 |
Returns:
|
| 128 |
Formatted response text
|
| 129 |
"""
|
| 130 |
# Remove tables
|
| 131 |
formatted = ResponseFormatter.remove_tables(text)
|
| 132 |
-
|
| 133 |
# Determine max words
|
| 134 |
max_words = (
|
| 135 |
-
MAX_RESPONSE_WORDS_LEAD
|
| 136 |
-
if agent_type == 'lead'
|
| 137 |
else MAX_RESPONSE_WORDS_SUBAGENT
|
| 138 |
)
|
| 139 |
-
|
| 140 |
# Handle chunking if enabled
|
| 141 |
if enable_chunking:
|
| 142 |
-
formatted,
|
| 143 |
-
formatted,
|
| 144 |
-
max_words
|
|
|
|
| 145 |
)
|
| 146 |
-
|
| 147 |
-
# For now, we just truncate and add hint
|
| 148 |
-
|
| 149 |
return formatted
|
| 150 |
|
| 151 |
@staticmethod
|
|
@@ -166,3 +175,12 @@ class ResponseFormatter:
|
|
| 166 |
cleaned = cleaned.strip()
|
| 167 |
|
| 168 |
return cleaned
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
logger = get_logger("response_formatter")
|
| 10 |
|
| 11 |
|
| 12 |
+
CONTINUATION_PROMPT = {
|
| 13 |
+
'en': "*Would you like me to continue with more details?*",
|
| 14 |
+
'de': "*Möchten Sie, dass ich mit weiteren Details fortfahre?*"
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
class ResponseFormatter:
|
| 19 |
"""Formats agent responses for optimal display"""
|
| 20 |
+
|
| 21 |
@staticmethod
|
| 22 |
def count_words(text: str) -> int:
|
| 23 |
"""Count words in text"""
|
|
|
|
| 75 |
|
| 76 |
@staticmethod
|
| 77 |
def chunk_response(
|
| 78 |
+
text: str,
|
| 79 |
+
max_words: int = MAX_RESPONSE_WORDS_LEAD,
|
| 80 |
+
language: str = 'en'
|
| 81 |
) -> tuple[str, str | None]:
|
| 82 |
"""
|
| 83 |
Split long response into current response and continuation.
|
| 84 |
+
|
| 85 |
Args:
|
| 86 |
text: Full response text
|
| 87 |
max_words: Maximum words for current response
|
| 88 |
+
language: Language code ('en' or 'de') for continuation prompt
|
| 89 |
+
|
| 90 |
Returns:
|
| 91 |
Tuple of (current_response, continuation_or_none)
|
| 92 |
"""
|
| 93 |
word_count = ResponseFormatter.count_words(text)
|
| 94 |
+
|
| 95 |
if word_count <= max_words:
|
| 96 |
return text, None
|
| 97 |
+
|
| 98 |
+
# Need to chunk — preserve line structure (markdown formatting)
|
| 99 |
logger.info(f"Response has {word_count} words, chunking to {max_words} words")
|
| 100 |
+
|
| 101 |
+
lines = text.split('\n')
|
| 102 |
+
current_lines = []
|
| 103 |
+
current_word_count = 0
|
| 104 |
+
|
| 105 |
+
for line in lines:
|
| 106 |
+
line_words = len(line.split()) if line.strip() else 0
|
| 107 |
+
if current_word_count + line_words > max_words and current_lines:
|
|
|
|
|
|
|
| 108 |
break
|
| 109 |
+
current_lines.append(line)
|
| 110 |
+
current_word_count += line_words
|
| 111 |
+
|
| 112 |
+
current = '\n'.join(current_lines)
|
| 113 |
+
continuation = '\n'.join(lines[len(current_lines):])
|
| 114 |
+
|
| 115 |
+
# Add continuation prompt in the correct language
|
| 116 |
+
continuation_msg = CONTINUATION_PROMPT.get(language, CONTINUATION_PROMPT['en'])
|
| 117 |
+
current += f"\n\n{continuation_msg}"
|
| 118 |
+
|
| 119 |
return current, continuation
|
| 120 |
|
| 121 |
@staticmethod
|
| 122 |
def format_response(
|
| 123 |
text: str,
|
| 124 |
agent_type: str = 'lead',
|
| 125 |
+
enable_chunking: bool = True,
|
| 126 |
+
language: str = 'en'
|
| 127 |
) -> str:
|
| 128 |
"""
|
| 129 |
Format response: remove tables and handle length.
|
| 130 |
+
|
| 131 |
Args:
|
| 132 |
text: Raw response text
|
| 133 |
agent_type: 'lead' or 'subagent' (determines max length)
|
| 134 |
enable_chunking: Whether to chunk long responses
|
| 135 |
+
language: Language code ('en' or 'de') for any generated text
|
| 136 |
+
|
| 137 |
Returns:
|
| 138 |
Formatted response text
|
| 139 |
"""
|
| 140 |
# Remove tables
|
| 141 |
formatted = ResponseFormatter.remove_tables(text)
|
| 142 |
+
|
| 143 |
# Determine max words
|
| 144 |
max_words = (
|
| 145 |
+
MAX_RESPONSE_WORDS_LEAD
|
| 146 |
+
if agent_type == 'lead'
|
| 147 |
else MAX_RESPONSE_WORDS_SUBAGENT
|
| 148 |
)
|
| 149 |
+
|
| 150 |
# Handle chunking if enabled
|
| 151 |
if enable_chunking:
|
| 152 |
+
formatted, _continuation = ResponseFormatter.chunk_response(
|
| 153 |
+
formatted,
|
| 154 |
+
max_words,
|
| 155 |
+
language
|
| 156 |
)
|
| 157 |
+
|
|
|
|
|
|
|
| 158 |
return formatted
|
| 159 |
|
| 160 |
@staticmethod
|
|
|
|
| 175 |
cleaned = cleaned.strip()
|
| 176 |
|
| 177 |
return cleaned
|
| 178 |
+
|
| 179 |
+
@staticmethod
|
| 180 |
+
def format_name_of_university(formatted_response, language):
|
| 181 |
+
if language == "en":
|
| 182 |
+
pattern = r"Universität St\.Gallen"
|
| 183 |
+
replace = "University of St.Gallen"
|
| 184 |
+
formatted_response = re.sub(pattern, replace, formatted_response)
|
| 185 |
+
|
| 186 |
+
return formatted_response
|
src/rag/scope_guardian.py
CHANGED
|
@@ -60,9 +60,10 @@ class ScopeGuardian:
|
|
| 60 |
'on_topic' | 'off_topic' | 'financial_planning' | 'aggressive'
|
| 61 |
"""
|
| 62 |
message_lower = message.lower()
|
|
|
|
| 63 |
|
| 64 |
# Check for aggressive behavior
|
| 65 |
-
if any(
|
| 66 |
logger.warning(f"Detected aggressive language in message")
|
| 67 |
return 'aggressive'
|
| 68 |
|
|
@@ -71,7 +72,7 @@ class ScopeGuardian:
|
|
| 71 |
ScopeGuardian.OFF_TOPIC_KEYWORDS.get('en', []) +
|
| 72 |
ScopeGuardian.OFF_TOPIC_KEYWORDS.get('de', [])
|
| 73 |
)
|
| 74 |
-
if any(
|
| 75 |
logger.info(f"Detected off-topic query")
|
| 76 |
return 'off_topic'
|
| 77 |
|
|
@@ -80,7 +81,7 @@ class ScopeGuardian:
|
|
| 80 |
ScopeGuardian.FINANCIAL_KEYWORDS.get('en', []) +
|
| 81 |
ScopeGuardian.FINANCIAL_KEYWORDS.get('de', [])
|
| 82 |
)
|
| 83 |
-
if any(
|
| 84 |
logger.info(f"Detected financial planning query")
|
| 85 |
return 'financial_planning'
|
| 86 |
|
|
@@ -108,8 +109,8 @@ class ScopeGuardian:
|
|
| 108 |
'de': "Für detaillierte Finanzplanung, Zahlungsoptionen oder Stipendienanträge empfehle ich, direkt mit unserem Zulassungsteam Kontakt aufzunehmen. Sie können Ihnen persönliche Beratung zu Finanzierungsmöglichkeiten und verfügbarer Unterstützung geben.\n\nMöchten Sie allgemeine Informationen über Programmkosten und Leistungen erhalten?"
|
| 109 |
},
|
| 110 |
'aggressive': {
|
| 111 |
-
'en': "I'm here to help with questions about HSG Executive MBA programs
|
| 112 |
-
'de': "Ich
|
| 113 |
}
|
| 114 |
}
|
| 115 |
|
|
@@ -132,9 +133,11 @@ class ScopeGuardian:
|
|
| 132 |
Returns:
|
| 133 |
Tuple of (should_escalate, escalation_message)
|
| 134 |
"""
|
| 135 |
-
# Aggressive behavior ->
|
| 136 |
if scope_type == 'aggressive':
|
| 137 |
-
|
|
|
|
|
|
|
| 138 |
|
| 139 |
# Off-topic after 2 redirects -> suggest human contact
|
| 140 |
if scope_type == 'off_topic' and attempt_count >= 2:
|
|
@@ -160,8 +163,8 @@ class ScopeGuardian:
|
|
| 160 |
"""
|
| 161 |
messages = {
|
| 162 |
'escalate_aggressive': {
|
| 163 |
-
'en': "I
|
| 164 |
-
'de': "Ich
|
| 165 |
},
|
| 166 |
'escalate_off_topic': {
|
| 167 |
'en': "For questions outside program information, our admissions team would be the best resource. You can reach them at [admissions contact info].\n\nIs there anything specific about the EMBA, IEMBA, or emba X programs I can help you with?",
|
|
|
|
| 60 |
'on_topic' | 'off_topic' | 'financial_planning' | 'aggressive'
|
| 61 |
"""
|
| 62 |
message_lower = message.lower()
|
| 63 |
+
words_list = message_lower.split()
|
| 64 |
|
| 65 |
# Check for aggressive behavior
|
| 66 |
+
if any(word in words_list for keyword in ScopeGuardian.AGGRESSIVE_KEYWORDS for word in keyword.split()):
|
| 67 |
logger.warning(f"Detected aggressive language in message")
|
| 68 |
return 'aggressive'
|
| 69 |
|
|
|
|
| 72 |
ScopeGuardian.OFF_TOPIC_KEYWORDS.get('en', []) +
|
| 73 |
ScopeGuardian.OFF_TOPIC_KEYWORDS.get('de', [])
|
| 74 |
)
|
| 75 |
+
if any(word in words_list for keyword in off_topic_keywords for word in keyword.split()):
|
| 76 |
logger.info(f"Detected off-topic query")
|
| 77 |
return 'off_topic'
|
| 78 |
|
|
|
|
| 81 |
ScopeGuardian.FINANCIAL_KEYWORDS.get('en', []) +
|
| 82 |
ScopeGuardian.FINANCIAL_KEYWORDS.get('de', [])
|
| 83 |
)
|
| 84 |
+
if any(word in words_list for keyword in financial_keywords for word in keyword.split()):
|
| 85 |
logger.info(f"Detected financial planning query")
|
| 86 |
return 'financial_planning'
|
| 87 |
|
|
|
|
| 109 |
'de': "Für detaillierte Finanzplanung, Zahlungsoptionen oder Stipendienanträge empfehle ich, direkt mit unserem Zulassungsteam Kontakt aufzunehmen. Sie können Ihnen persönliche Beratung zu Finanzierungsmöglichkeiten und verfügbarer Unterstützung geben.\n\nMöchten Sie allgemeine Informationen über Programmkosten und Leistungen erhalten?"
|
| 110 |
},
|
| 111 |
'aggressive': {
|
| 112 |
+
'en': "I'm here to help with questions about HSG Executive MBA programs, but please keep the conversation respectful. If the aggressive language continues, I may need to end the chat and refer you to our admissions team. How can I help you with information about our programs?",
|
| 113 |
+
'de': "Ich helfe Ihnen gerne bei Fragen zu den HSG Executive MBA-Programmen, aber bitte bleiben Sie respektvoll. Wenn die aggressive Sprache anhält, muss ich das Gespräch ggf. beenden und Sie an unser Zulassungsteam verweisen. Wie kann ich Ihnen bei Informationen über unsere Programme helfen?"
|
| 114 |
}
|
| 115 |
}
|
| 116 |
|
|
|
|
| 133 |
Returns:
|
| 134 |
Tuple of (should_escalate, escalation_message)
|
| 135 |
"""
|
| 136 |
+
# Aggressive behavior -> warn first, then escalate if it continues
|
| 137 |
if scope_type == 'aggressive':
|
| 138 |
+
if attempt_count >= 2:
|
| 139 |
+
return True, "escalate_aggressive"
|
| 140 |
+
return False, ""
|
| 141 |
|
| 142 |
# Off-topic after 2 redirects -> suggest human contact
|
| 143 |
if scope_type == 'off_topic' and attempt_count >= 2:
|
|
|
|
| 163 |
"""
|
| 164 |
messages = {
|
| 165 |
'escalate_aggressive': {
|
| 166 |
+
'en': "I can’t continue this chat while the language is aggressive. If you still need help, please book an appointment with our admissions team using the links below.",
|
| 167 |
+
'de': "Ich kann dieses Gespräch nicht fortsetzen, solange die Sprache aggressiv ist. Wenn Sie weiterhin Unterstützung benötigen, buchen Sie bitte über die untenstehenden Links einen Termin mit unserem Zulassungsteam."
|
| 168 |
},
|
| 169 |
'escalate_off_topic': {
|
| 170 |
'en': "For questions outside program information, our admissions team would be the best resource. You can reach them at [admissions contact info].\n\nIs there anything specific about the EMBA, IEMBA, or emba X programs I can help you with?",
|
src/rag/utilclasses.py
CHANGED
|
@@ -1,24 +1,40 @@
|
|
| 1 |
-
from dataclasses import dataclass
|
|
|
|
|
|
|
| 2 |
from pydantic import BaseModel, Field
|
| 3 |
from typing_extensions import TypedDict
|
| 4 |
from langchain.agents import AgentState
|
| 5 |
from langchain_core.messages import AnyMessage
|
| 6 |
|
|
|
|
| 7 |
@dataclass
|
| 8 |
class AgentContext:
|
| 9 |
agent_name: str
|
| 10 |
|
|
|
|
| 11 |
@dataclass
|
| 12 |
class LeadAgentQueryResponse:
|
| 13 |
-
response: str
|
| 14 |
-
language: str
|
| 15 |
-
|
|
|
|
| 16 |
max_turns_reached: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
class StructuredAgentResponse(BaseModel):
|
| 19 |
-
response:
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
class State(TypedDict):
|
| 24 |
messages: list[AnyMessage]
|
|
@@ -40,12 +56,12 @@ class ConversationState(TypedDict):
|
|
| 40 |
handover_requested: bool | None # True if appointment requested, False if declined, None if session active
|
| 41 |
topics_discussed: list[str] # Track what's been covered
|
| 42 |
preferences_known: bool # Whether we have enough context
|
| 43 |
-
|
| 44 |
|
| 45 |
class LeadInformationState(AgentState):
|
| 46 |
lead_name: str
|
| 47 |
-
lead_age:
|
| 48 |
-
lead_language_knowledge: list
|
| 49 |
lead_work_experience: dict
|
| 50 |
lead_motivation: list
|
| 51 |
# Enhanced state tracking
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
from typing import List, Literal, Optional
|
| 3 |
+
|
| 4 |
from pydantic import BaseModel, Field
|
| 5 |
from typing_extensions import TypedDict
|
| 6 |
from langchain.agents import AgentState
|
| 7 |
from langchain_core.messages import AnyMessage
|
| 8 |
|
| 9 |
+
|
| 10 |
@dataclass
|
| 11 |
class AgentContext:
|
| 12 |
agent_name: str
|
| 13 |
|
| 14 |
+
|
| 15 |
@dataclass
|
| 16 |
class LeadAgentQueryResponse:
|
| 17 |
+
response: str
|
| 18 |
+
language: str
|
| 19 |
+
processed_query: str = None
|
| 20 |
+
confidence_fallback: bool = False
|
| 21 |
max_turns_reached: bool = False
|
| 22 |
+
should_cache: bool = False
|
| 23 |
+
appointment_requested: bool = False
|
| 24 |
+
relevant_programs: List[str] = field(default_factory=list)
|
| 25 |
+
|
| 26 |
|
| 27 |
class StructuredAgentResponse(BaseModel):
|
| 28 |
+
response: str = Field(description="Main response to the query.")
|
| 29 |
+
appointment_requested: bool = Field(
|
| 30 |
+
default=False,
|
| 31 |
+
description="Set to True ONLY if the user explicitly wants to book, asks for help booking, or if a proactive trigger (pricing/eligibility/handover) occurred in THIS specific turn. Otherwise, set to False."
|
| 32 |
+
)
|
| 33 |
+
relevant_programs: Optional[List[Literal["emba", "iemba", "emba_x"]]] = Field(
|
| 34 |
+
default=None,
|
| 35 |
+
description="If appointment_requested is True, list the programs relevant to the user. Options: 'emba', 'iemba', 'emba_x'. If the user is undecided or general, leave this list empty."
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
|
| 39 |
class State(TypedDict):
|
| 40 |
messages: list[AnyMessage]
|
|
|
|
| 56 |
handover_requested: bool | None # True if appointment requested, False if declined, None if session active
|
| 57 |
topics_discussed: list[str] # Track what's been covered
|
| 58 |
preferences_known: bool # Whether we have enough context
|
| 59 |
+
|
| 60 |
|
| 61 |
class LeadInformationState(AgentState):
|
| 62 |
lead_name: str
|
| 63 |
+
lead_age: int
|
| 64 |
+
lead_language_knowledge: list
|
| 65 |
lead_work_experience: dict
|
| 66 |
lead_motivation: list
|
| 67 |
# Enhanced state tracking
|
src/utils/lang.py
CHANGED
|
@@ -19,12 +19,12 @@ def detect_language(text: str):
|
|
| 19 |
"""
|
| 20 |
found_langs = detect_langs(text)
|
| 21 |
top_lang = found_langs[0]
|
| 22 |
-
logger.info(f'Found following languages in the text: {found_langs}')
|
| 23 |
return 'de' if top_lang.lang == 'de' and top_lang.prob >= LANG_AMBIGUITY_THRESHOLD else 'en'
|
| 24 |
|
| 25 |
|
| 26 |
def get_language_name(code: str):
|
| 27 |
return {
|
| 28 |
-
'en': "English",
|
| 29 |
'de': "German",
|
| 30 |
-
}.get(code, 'English')
|
|
|
|
| 19 |
"""
|
| 20 |
found_langs = detect_langs(text)
|
| 21 |
top_lang = found_langs[0]
|
| 22 |
+
logger.info(f'Found following languages in the text: {", ".join(f"{lang.lang}-{lang.prob:1.2f}" for lang in found_langs)}')
|
| 23 |
return 'de' if top_lang.lang == 'de' and top_lang.prob >= LANG_AMBIGUITY_THRESHOLD else 'en'
|
| 24 |
|
| 25 |
|
| 26 |
def get_language_name(code: str):
|
| 27 |
return {
|
| 28 |
+
'en': "British English",
|
| 29 |
'de': "German",
|
| 30 |
+
}.get(code, 'British English')
|
src/utils/stratutils/generator.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.utils.stratutils.templates import *
|
| 2 |
+
|
| 3 |
+
def generate_strategy(name, prop):
|
| 4 |
+
preamble = PREAMBLE_TEMPL_STD.format(name=name)
|
| 5 |
+
header = f"{FUNC_HEADER_TEMPL} -> {FUNC_RETURN_TYPE_TEMPL.get(prop['data_type'], None)}:"
|
| 6 |
+
body = BODY_TEMPL.get(name, BODY_TEMPL_STD)
|
| 7 |
+
|
| 8 |
+
return f"{preamble}\n\n{header}\n{COMMENT_TEMPL_STD}\n\n{body}"
|
src/utils/stratutils/templates.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FUNC_HEADER_TEMPL = "def run(file_name: str, file_content: str, chunk: str)"
|
| 2 |
+
|
| 3 |
+
FUNC_RETURN_TYPE_TEMPL = {
|
| 4 |
+
"text": "str",
|
| 5 |
+
"date": "str",
|
| 6 |
+
"text[]": "list[str]",
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
PREAMBLE_TEMPL_STD="""\"\"\"Property extraction strategy for property {name}.\"\"\""""
|
| 10 |
+
|
| 11 |
+
COMMENT_TEMPL_STD = """\t\"\"\"
|
| 12 |
+
\tRuns the property extraction strategy on processed chunk.
|
| 13 |
+
|
| 14 |
+
\tArgs:
|
| 15 |
+
\t\tfile_name (str): Name of the file from which the chunk was collected.
|
| 16 |
+
\t\tfile_content (str): Entire text extracted from file.
|
| 17 |
+
\t\tchunk (str): Chunk collected from file.
|
| 18 |
+
|
| 19 |
+
\tReturns:
|
| 20 |
+
\t\tExtracted property.
|
| 21 |
+
\t\"\"\""""
|
| 22 |
+
|
| 23 |
+
BODY_TEMPL_STD = "\treturn chunk"
|
| 24 |
+
|
| 25 |
+
BODY_TEMPL = {
|
| 26 |
+
'body': "\treturn chunk",
|
| 27 |
+
'source': "\treturn file_name",
|
| 28 |
+
'chunk_id': "\timport hashlib\n\treturn hashlib.md5(chunk.strip().encode('utf-8')).hexdigest()",
|
| 29 |
+
'document_id': "\timport hashlib\n\treturn hashlib.md5(file_content.strip().encode('utf-8')).hexdigest()",
|
| 30 |
+
'date': "\timport datetime\n\treturn datetime.datetime.now().replace(tzinfo=datetime.timezone.utc)"
|
| 31 |
+
}
|