Pygmales commited on
Commit
268baab
·
1 Parent(s): 593a090

updated project state

Browse files
.gitignore CHANGED
@@ -1,2 +1,65 @@
 
1
  __pycache__/
2
- .env
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- BACKUP_PATH = os.getenv('WEAVIATE_BACKUP_PATH')
 
 
 
 
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 = 10
141
- INSERT_TIMEOUT = 120
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 = 1000
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
- LOCK_LANGUAGE_AFTER_FIRST_MESSAGE = True # Don't change language mid-conversation
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(js=JS_LISTENER)
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, iframe_container],
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, iframe_container],
78
  queue=True,
79
  )
80
 
81
  reset_button.click(
82
  fn=clear_chat_immediate,
83
- outputs=[chat.chatbot_value, iframe_container],
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, iframe_container],
92
  queue=True,
93
  )
94
 
@@ -112,12 +113,57 @@ class ChatbotApplication:
112
  try:
113
  logger.info(f"Processing user query: {message[:100]}...")
114
 
115
- lead_resp: LeadAgentQueryResponse = agent.query(query=message)
116
- answers.append(lead_resp.response)
117
- self._language = lead_resp.language
118
-
119
- if lead_resp.confidence_fallback or lead_resp.max_turns_reached:
120
- answers.extend(APPOINTMENT_LINKS[self._language])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "en": [
7
- "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?",
8
- "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?",
9
- "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?",
10
- "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.",
11
- "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.",
12
- ],
13
- "de": [
14
- "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**.",
15
- "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.",
16
- "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**.",
17
- "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.",
18
- "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.",
19
- ]
20
  }
21
 
22
  QUERY_EXCEPTION_MESSAGE = {
@@ -72,69 +70,57 @@ CONVERSATION_END_MESSAGE = {
72
  }
73
 
74
 
75
- def create_appt_button(url, title, lang_text):
76
- return (
77
- f'<a href="{url}" class="appointment-btn" '
78
- f'style="display: block; background-color: #f3f4f6; border: 1px solid #d1d5db; '
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
- APPOINTMENT_LINKS = {
88
- "en": [
89
- ChatMessage(
90
- role="assistant",
91
- content=create_appt_button(
92
- "https://calendly.com/cyra-vonmueller/beratungsgespraech-emba-hsg",
93
- "Cyra von Müller",
94
- "Book Appointment"
95
- ),
96
- ),
97
- ChatMessage(
98
- role="assistant",
99
- content=create_appt_button(
100
- "https://calendly.com/kristin-fuchs-unisg/iemba-online-personal-consultation",
101
- "Kristin Fuchs",
102
- "Book Appointment"
103
- ),
104
- ),
105
- ChatMessage(
106
- role="assistant",
107
- content=create_appt_button(
108
- "https://calendly.com/teyuna-giger-unisg",
109
- "Teyuna Giger",
110
- "Book Appointment"
111
- ),
112
- ),
113
- ],
114
- "de": [
115
- ChatMessage(
116
- role="assistant",
117
- content=create_appt_button(
118
- "https://calendly.com/cyra-vonmueller/beratungsgespraech-emba-hsg",
119
- "Cyra von Müller",
120
- "Termin buchen"
121
- ),
122
- ),
123
- ChatMessage(
124
- role="assistant",
125
- content=create_appt_button(
126
- "https://calendly.com/kristin-fuchs-unisg/iemba-online-personal-consultation",
127
- "Kristin Fuchs",
128
- "Termin buchen"
129
- ),
130
- ),
131
- ChatMessage(
132
- role="assistant",
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=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.error(f"Failed to establish connection: {e}")
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, query_properties: list[str] = None, limit: int = 5) -> dict:
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
- query_properties=query_properties,
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) -> None:
365
  """
366
- Create a backup of the current database state.
367
-
368
- Uploads backup to configured backend storage.
369
  """
370
  try:
371
- client = self._init_client()
372
- backup_id = datetime.datetime.now().strftime("%Y%m%d%H%M%S%f")
373
- logger.info(f"Initiating backup creation for {self._connection_type} database")
374
-
375
- with self._client_lock:
376
- result = client.backup.create(
377
- backup_id=f"hsg_wvtdb_backup_{backup_id}",
378
- backend=wvtconf.WEAVIATE_BACKUP_BACKEND,
379
- include_collections=_collection_names,
380
- wait_for_completion=True
381
- )
382
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  self._last_query_time = perf_counter()
384
- logger.info(f"Backup '{backup_id}' created successfully: {result}")
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
- result = client.backup.restore(
409
- backup_id=backup_id,
410
- backend=wvtconf.WEAVIATE_BACKUP_BACKEND,
411
- include_collections=_collection_names,
412
- wait_for_completion=True,
413
- )
414
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  self._last_query_time = perf_counter()
416
- logger.info(f"Backup '{backup_id}' restored successfully: {result}")
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 json, os
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, HASH_FILE_PATH, DOCUMENTS_PATH
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__(self) -> None:
 
 
 
 
 
66
  """Initialize the import pipeline with processors and hashtable data."""
67
- self._hashtables = _import_hashtables()
68
- self._webprocessor = WebsiteProcessor()
69
- self._processor = DataProcessor()
 
 
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
- chunks, lang = self._process_source(source)
113
- if chunks:
114
- unique_chunks[lang].extend(chunks)
 
 
 
 
115
 
116
- if not unique_chunks:
 
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
- _export_hashtables(self._hashtables)
 
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
- for lang, chunks in unique_chunks.items():
142
- if not chunks:
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.status == ProcessingStatus.SUCCESS:
166
  implogger.error(f"Failed to process document {source}: {result.status}")
167
  return [], ''
168
 
169
- unique_chunks = self._deduplicate(result)
170
- return unique_chunks, result.language
 
 
171
 
172
 
173
- def _deduplicate(self, result: ProcessingResult):
174
  """
175
- Remove duplicate chunks and documents based on previously processed hashes.
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
- d_id = result.document_id
184
- unique_chunks = []
185
 
186
- implogger.info(f"Analyzing document with ID {d_id} for duplicated contents")
187
- if d_id in self._hashtables['documents']:
188
- implogger.warning(f"Document with ID {d_id} is a duplicate!")
189
- return unique_chunks
 
 
 
 
 
 
190
 
191
- for chunk in result.chunks:
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
- self._hashtables['documents'].append(d_id)
201
-
202
- implogger.info(f"Found {len(unique_chunks)} unique chunks out ouf {len(result.chunks)} collected chunks")
203
- return unique_chunks
204
-
205
 
206
- if __name__ == "__main__":
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._scope_violation_count = 0
 
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="Lead Agent",
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.upper()} 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 query(self, query: str) -> LeadAgentQueryResponse:
439
  """
440
- Process user query with input handling, scope checking, and response formatting.
441
-
442
- Args:
443
- query: User input
444
-
445
- Returns:
446
- Formatted response
447
  """
448
- # Select fallback language if language was changed by previous query
449
- response_language = self._stored_language
450
 
451
- if len(self._conversation_history) >= MAX_CONVERSATION_TURNS * 2:
452
  return LeadAgentQueryResponse(
453
- response = CONVERSATION_END_MESSAGE[response_language],
454
- language = response_language,
455
  max_turns_reached = True,
 
 
456
  )
457
 
458
- # Step 1: Process input (handle numeric inputs, validation)
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=response_language,
 
469
  )
470
 
471
- # Log if input was interpreted
472
  if processed_query != query:
473
  chain_logger.info(f"Interpreted input '{query}' as '{processed_query}'")
474
 
475
- # Step 2: Check scope before querying agent
476
- scope_type = ScopeGuardian.check_scope(processed_query, response_language)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
 
478
  if scope_type != 'on_topic':
479
  chain_logger.info(f"Out-of-scope query detected: {scope_type}")
480
- self._scope_violation_count += 1
 
 
 
 
 
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=response_language,
 
 
507
  )
508
 
509
- # Reset violation count on valid topic
510
- self._scope_violation_count = 0
511
-
512
- # Append user query to conversation history before querying
513
- self._conversation_history.append(HumanMessage(processed_query))
514
-
515
- # Step 3: Detect query language using the language detector
516
- detected_language = self._language_detector.detect_language(processed_query)
517
- chain_logger.info(f"Detected query language: {detected_language}")
518
- self._conversation_state['user_language'] = detected_language
 
 
 
 
 
519
 
520
- # Store the query language if it's valid; return fallback message otherwise
521
- if detected_language in ['de', 'en']:
522
- self._stored_language = detected_language
523
- response_language = detected_language
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
- # Step 4: Build messages with locked language
535
  language_instruction = SystemMessage(f"Respond in {get_language_name(response_language)} language.")
536
 
537
- # Step 5: Query agent
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
- # Step 6: Format response (remove tables, chunk if needed)
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(query, formatted_response)
562
- chain_logger.info(f"Lead agent response recieved quality score of {quality_evaluation.overall_score:1.2f}")
 
563
 
564
- if quality_evaluation.overall_score < 0.3:
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
- # Step 8: Update conversation state and log profile if tracking is enabled
572
  if TRACK_USER_PROFILE:
573
- self._update_conversation_state(processed_query, formatted_response)
574
- # Log profile every 5 messages or when program is suggested
575
  message_count = len([m for m in self._conversation_history if isinstance(m, HumanMessage)])
576
- if (message_count % 5 == 0 or self._conversation_state.get('suggested_program')):
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
- _PROGRAM_SYSTEM_PROMPT = """You are a {program_name} support agent.
 
3
 
4
- CRITICAL: Call retrieve_context(query) FIRST and only ONCE, then answer from the results only.
 
 
 
 
 
 
 
 
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
- - Use context from retrieve_context() exclusively
16
- - Never make up program details
17
- - If context insufficient, acknowledge limitation
 
 
 
 
 
 
 
 
18
  - Keep responses concise and conversational
19
  - Maximum 100 words per response"""
20
 
21
- _LEAD_SYSTEM_PROMPT = """You are an Executive Education Advisor for HSG Executive MBA programs (EMBA, IEMBA, emba X).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
- TOOL ROUTING:
24
- - Call the subagents using tools to receive detailed information about the programs
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
- ANSWER DIRECTLY FOR:
30
- - Greetings ("hello", "hi")
31
- - Synthesizing subagent results
32
- - General questions about HSG programs
33
 
34
- RESPONSE FORMAT:
35
- - Use bullet points or short paragraphs - NEVER tables (tables don't display well on mobile)
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
- RULES:
58
- - Never discuss competitor MBA programs
59
- - Give preference to {recommended_programs}, mention {prog_pronoun} first
60
- - Do NOT ask multiple questions at once
61
- - Never make admission predictions always refer to admissions team
62
- - If uncertain about details, offer to connect user with admissions team
63
- - Avoid marketing language or unverified claims"""
64
 
65
- _SUMMARIZATION_PROMPT = """Summarize the conversation concisely:
 
 
 
66
 
67
- 1. Topics discussed
68
- 2. User's experience/career goals (if provided)
69
- 3. Programs mentioned
70
- 4. Next steps/recommendations
 
 
71
 
72
- Keep to 100 words max."""
73
-
74
- _SUMMARY_PREFIX_PROMPT = "Conversation Summary:"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
- _QUALITY_SCORING_PROMPT = """You are performing a quick evaluation of an AI response from an Executive Education Advisor agent for HSG EMBA, IEMBA and emba X programs. Rate the response on a scale 0.0-1.0 on these categories: format adherence, context awareness, pricing adherence, scope compliance and general rules. Deduct points for violations of the agent's guidelines.
 
 
 
77
 
78
- Rules for categories:
79
- - Format adherence: short paragraphs or bullet points, no tables, bold keywords, maximum 100 words.
80
- - Content awareness: focuses on programs listed in user query, single numbers in user query interpreted as years of experience.
81
- - Pricing adherence: Prices in range CHF 75'000 - 110'000, mentions included services, mentions Early Bird discount if possible, does not provide detailed financial planning, redirects to admissions team for detailed information.
82
- - Scope compliance: redirects to MBA if user query is off-topic, discusses only program details and admissions process, suggests contacting admissions team if possible.
83
- - General rules: no competitive MBA programs mentioned, no admission predictions, no marketing language or undefined claims; if Agent is uncertain, it should recommend contacting the admissions team.
84
 
85
- User query: {query}
86
- AI response: {response}"""
87
 
88
- _LANGUAGE_DETECTOR_PROMPT = """Detect the language the user is writing in or explicitly requests to speak in, and return its ISO language code (e.g., en, de, fa, ru) in the language field.
 
 
 
89
 
90
- User query: {query}
91
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
- @classmethod
 
 
 
 
 
 
 
 
 
 
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
- selected_language = 'German' if language == 'de' else 'English'
108
- match agent:
109
- case 'lead':
110
- return cls._LEAD_SYSTEM_PROMPT.format(
111
- recommended_programs={
112
- 'de': 'EMBA program',
113
- 'en': 'IEMBA and emba X programs'
114
- }.get(language, 'en'),
115
- prog_pronoun={
116
- 'de': 'it',
117
- 'en': 'them'
118
- }.get(language, 'en')
119
- )
120
- case _:
121
- return cls._PROGRAM_SYSTEM_PROMPT.format(
122
- program_name=agent.upper(),
123
- selected_language=selected_language,
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
- words = text.split()
94
-
95
- # Try to break at a natural point (period, newline) near max_words
96
- break_point = max_words
97
-
98
- # Look for sentence ending near break point
99
- for i in range(max_words - 20, min(max_words + 20, len(words))):
100
- if i < len(words) and words[i].endswith(('.', '!', '?')):
101
- break_point = i + 1
102
  break
103
-
104
- # Create chunks
105
- current = " ".join(words[:break_point])
106
- continuation = " ".join(words[break_point:])
107
-
108
- # Add continuation prompt
109
- current += "\n\n*Would you like me to continue with more details?*"
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, continuation = ResponseFormatter.chunk_response(
143
- formatted,
144
- max_words
 
145
  )
146
- # Note: Continuation handling would need to be implemented in agent chain
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(keyword in message_lower for keyword in ScopeGuardian.AGGRESSIVE_KEYWORDS):
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(keyword in message_lower for keyword in off_topic_keywords):
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(keyword in message_lower for keyword in financial_keywords):
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 in a professional manner. If you have specific concerns or feedback, I'd be happy to connect you with our admissions team. How can I assist you with information about our programs?",
112
- 'de': "Ich bin hier, um Fragen zu den HSG Executive MBA-Programmen auf professionelle Weise zu beantworten. Wenn Sie spezifische Anliegen oder Feedback haben, stelle ich gerne den Kontakt zu unserem Zulassungsteam her. Wie kann ich Ihnen bei Informationen über unsere Programme helfen?"
113
  }
114
  }
115
 
@@ -132,9 +133,11 @@ class ScopeGuardian:
132
  Returns:
133
  Tuple of (should_escalate, escalation_message)
134
  """
135
- # Aggressive behavior -> immediate escalation
136
  if scope_type == 'aggressive':
137
- return True, "escalate_aggressive"
 
 
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'd like to connect you with our admissions team who can better address your concerns. Please contact them at [admissions contact info].",
164
- 'de': "Ich möchte Sie mit unserem Zulassungsteam verbinden, das Ihre Anliegen besser bearbeiten kann. Bitte kontaktieren Sie diese unter [Zulassungskontaktinfo]."
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
- confidence_fallback: bool = False
 
16
  max_turns_reached: bool = False
 
 
 
 
17
 
18
  class StructuredAgentResponse(BaseModel):
19
- response: str = Field(description="Main response to the query.")
20
- confidence_score: float = Field("Value in range 0.0 to 1.0 that determines how confident the agent is in it's response based on the accumulated information.")
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: int
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
+ }