Pygmales commited on
Commit
0a372e8
·
1 Parent(s): daccb57

inital commit

Browse files
config.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration settings for the Executive Education RAG Chatbot.
3
+ """
4
+ import os
5
+ from dotenv import load_dotenv
6
+
7
+ # Load environment variables from .env file
8
+ load_dotenv()
9
+
10
+ class LLMProvider:
11
+ def __init__(self, base: str, sub: str | None = None) -> None:
12
+ self.base = base
13
+ self.sub = sub
14
+ self.name = f"{base}:{sub}" if sub else base
15
+
16
+
17
+ def with_sub(self, sub: str | None = None) -> str:
18
+ return LLMProvider(self.base, sub)
19
+
20
+
21
+ class LLMProviderConfiguration:
22
+ AVAIABLE_PROVIDERS = [
23
+ 'groq',
24
+ 'ollama',
25
+ 'openai',
26
+ 'open_router',
27
+ ]
28
+ AVAILABLE_SUBPROVIDERS = {
29
+ 'groq': [],
30
+ 'open_router': [
31
+ 'openai',
32
+ 'deepseek',
33
+ 'meituan'
34
+ 'alibaba' # For tongyi models
35
+ 'nvidia',
36
+ ],
37
+ }
38
+
39
+ # DEFINE YOUR MAIN MODEL PROVIDER HERE
40
+ # Some unified interfaces such as Groq or Open Router provide access to other providers
41
+ # such as OpenAI or Deepseek. When using interfaces define the provider you want to gain access to.
42
+ LLM_PROVIDER = LLMProvider('openai')
43
+
44
+ # -------------------- Some predefined models for available providers ----------------------
45
+
46
+ # Groq settings
47
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
48
+ GROQ_MODEL = "mixtral-8x7b-32768"
49
+
50
+ # Open Router settings
51
+ OPEN_ROUTER_API_KEY = os.getenv("OPEN_ROUTER_API_KEY")
52
+ OPEN_ROUTER_MODEL="meituan/longcat-flash-chat:free"
53
+ OPEN_ROUTER_BASE_URL="https://openrouter.ai/api/v1"
54
+
55
+ # OpenAI settings
56
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
57
+ OPENAI_MODEL = "gpt-5"
58
+
59
+ # The gpt-oss:20b model is preferable but takes much more space
60
+ # Set to False if you only have the llama3.2 installed
61
+ GPT_OSS_ENABLED=False
62
+ # Local/Ollama settings
63
+ OLLAMA_BASE_URL = "http://localhost:11434"
64
+ OLLAMA_MODEL = "gpt-oss:20b" if GPT_OSS_ENABLED else "llama3.2"
65
+
66
+ # ----------------------------------------------------------------------------------------
67
+
68
+ @classmethod
69
+ def get_fallback_models(cls, provider: LLMProvider | None = None) -> list[str]:
70
+ provider = provider or cls.LLM_PROVIDER
71
+ match provider.base:
72
+ case 'openai':
73
+ return {
74
+ provider: fallback_model
75
+ for fallback_model in [
76
+ 'gpt-5-mini',
77
+ 'gpt-5-nano',
78
+ ]
79
+ }
80
+ case 'open_router':
81
+ return {
82
+ provider.with_sub('openai'): "gpt-oss-20b",
83
+ provider.with_sub('openai'): "gpt-oss-120b",
84
+ provider.with_sub('alibaba'): "alibaba/tongyi-deepresearch-30b-a3b:free",
85
+ provider: "openrouter/polaris-alpha",
86
+ # Currently unusable because has no tool support
87
+ #provider.with_sub('deepseek'): "deepseek/deepseek-chat-v3.1:free",
88
+ }
89
+ case _:
90
+ return {}
91
+
92
+ @classmethod
93
+ def get_reasoning_support(cls, provider: LLMProvider | None = None) -> bool:
94
+ provider = provider or cls.LLM_PROVIDER
95
+ return {
96
+ "groq": True,
97
+ "openai": True,
98
+ "open_router": True,
99
+ }.get(provider.base, False)
100
+
101
+
102
+ @classmethod
103
+ def get_default_model(cls, provider: LLMProvider | None = None) -> str:
104
+ provider = provider or cls.LLM_PROVIDER
105
+ return {
106
+ "groq": cls.GROQ_MODEL,
107
+ "openai": cls.OPENAI_MODEL,
108
+ "ollama": cls.OLLAMA_MODEL,
109
+ "open_router": cls.OPEN_ROUTER_MODEL,
110
+ }.get(provider.base)
111
+
112
+
113
+ @classmethod
114
+ def get_api_key(cls, provider: LLMProvider | None = None) -> str:
115
+ provider = provider or cls.LLM_PROVIDER
116
+ return {
117
+ "groq": cls.GROQ_API_KEY,
118
+ "openai": cls.OPENAI_API_KEY,
119
+ "open_router": cls.OPEN_ROUTER_API_KEY,
120
+ }.get(provider.base)
121
+
122
+
123
+ # Weaviate database settings
124
+ class WeaviateConfiguration:
125
+ LOCAL_DATABASE = False
126
+ WEAVIATE_BACKUP_BACKEND = ''
127
+ WEAVIATE_COLLECTION_BASENAME = 'hsg_rag_content'
128
+
129
+ # Weaviate Cloud settings
130
+ CLUSTER_URL = "r2vd9fuvrcjvx7idsvta.c0.europe-west3.gcp.weaviate.cloud"
131
+ WEAVIATE_API_KEY = os.getenv('WEAVIATE_API_KEY')
132
+ HUGGING_FACE_API_KEY = os.getenv('HUGGING_FACE_API_KEY')
133
+
134
+ @classmethod
135
+ def is_local(cls) -> bool:
136
+ return cls.LOCAL_DATABASE
137
+
138
+ # Data paths
139
+ DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
140
+ RAW_DATA_PATH = os.path.join(DATA_DIR, "raw_data.json")
141
+ PROCESSED_DATA_PATH = os.path.join(DATA_DIR, "processed_data.json")
142
+ VECTORDB_PATH = os.path.join(DATA_DIR, "vectordb")
143
+
144
+ # Vector database settings
145
+ CHUNK_SIZE = 1000
146
+ CHUNK_OVERLAP = 200
147
+
148
+ # Agent Chain settings
149
+ MAX_MODEL_RETRIES = 3
150
+
151
+ # RAG settings
152
+ TOP_K_RETRIEVAL = 8 # Number of documents to retrieve for each query
153
+
154
+ # UI settings
155
+ MAX_HISTORY = 10 # Maximum number of conversation turns to keep in history
156
+
157
+ # Data processing pipeline settings
158
+ CHUNK_MAX_TOKENS = 8191
159
+ AVAILABLE_LANGUAGES = ['en', 'de']
160
+ HASH_FILE_PATH = os.path.join(DATA_DIR, 'hashtables.json')
161
+ DOCUMENTS_PATH = os.path.join(DATA_DIR, 'documents')
162
+
163
+ # Base URL for scraping
164
+ BASE_URL = "https://emba.unisg.ch/programm"
main.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main entry point for the Executive Education RAG Chatbot.
3
+ """
4
+ import argparse
5
+ from src.utils.logging import init_logging, get_logger
6
+ from config import AVAILABLE_LANGUAGES
7
+
8
+ # Initialize logging
9
+ def logging_startup():
10
+ init_logging(interactive_mode=False)
11
+ return get_logger('main_module')
12
+
13
+
14
+ def run_scraper() -> None:
15
+ """
16
+ Run the scraper to collect program data.
17
+
18
+ Args:
19
+ use_selenium: Whether to use Selenium for scraping.
20
+ """
21
+ from src.pipeline.pipeline import ImportPipeline
22
+ logger = logging_startup()
23
+
24
+ logger.info("Running scraper...")
25
+ ImportPipeline().scrape_website()
26
+ logger.info("Scraping completed.")
27
+
28
+
29
+ def run_importer(sources: list[str]) -> None:
30
+ """Run the data import pipeline."""
31
+ from src.pipeline.pipeline import ImportPipeline
32
+ logger = logging_startup()
33
+
34
+ logger.info("Running data import pipeline...")
35
+ ImportPipeline().import_many_documents(sources)
36
+ logger.info("Data processing completed.")
37
+
38
+
39
+ def run_weaviate_command(command: str, backup_id: str = None):
40
+ """Run commands to manipulate the database contents."""
41
+ from src.database.weavservice import WeaviateService
42
+ logger = logging_startup()
43
+
44
+ logger.info(f"Running database command {command}")
45
+ if command == 'restore' and not backup_id:
46
+ logger.error("Backup ID is required to initalize the restore process.")
47
+
48
+ service = WeaviateService()
49
+ if command == 'backup':
50
+ service._create_backup()
51
+
52
+ if command == 'restore':
53
+ service._restore_backup(backup_id)
54
+
55
+ if command == 'delete' or command == 'redo':
56
+ service._delete_collections()
57
+
58
+ if command == 'init' or command == 'redo':
59
+ service._create_collections()
60
+
61
+ if command == 'checkhealth' or command == 'init' or command == 'redo':
62
+ service._checkhealth()
63
+
64
+
65
+ def run_application(lang: str) -> None:
66
+ """Run the chatbot web application."""
67
+ from src.apps.chat.app import ChatbotApplication
68
+ logger = logging_startup()
69
+
70
+ logger.info("Starting chatbot web application...")
71
+ app = ChatbotApplication(language=lang)
72
+ app.run()
73
+
74
+
75
+ def run_cli() -> None:
76
+ """Run the chatbot CLI."""
77
+ from src.ui.cli import ChatbotCLI
78
+ logger = logging_startup()
79
+
80
+ logger.info("Starting chatbot...")
81
+ cli = ChatbotCLI()
82
+ cli.run()
83
+
84
+
85
+ def parse_args():
86
+ """Parse command-line arguments."""
87
+ parser = argparse.ArgumentParser(description="University of St. Gallen Executive Education RAG Chatbot")
88
+
89
+ # Add arguments
90
+ parser.add_argument("--scrape", action="store_true", help="Scrapes the data from the HSG website and imports it into the database")
91
+ parser.add_argument("--imports", nargs="+", help="Runs the data importing pipeline for the provided files")
92
+
93
+ parser.add_argument("--weaviate", type=str, choices=['init', 'delete', 'redo', 'checkhealth', 'backup', 'restore'], help="Runs different database actions")
94
+ parser.add_argument("--backup-id", type=str, help="Required when calling the --weaviate restore command!")
95
+
96
+ parser.add_argument("--cli", action="store_true", help="Run the chatbot CLI")
97
+ parser.add_argument("--app", type=str, choices=AVAILABLE_LANGUAGES, help="Run the chatbot web application")
98
+
99
+ return parser.parse_args()
100
+
101
+
102
+ def main():
103
+ """Main entry point for the application."""
104
+ args = parse_args()
105
+
106
+ # Check if any argument is provided
107
+ if not any([args.scrape, args.imports, args.weaviate, args.cli, args.app]):
108
+ # If no argument is provided, run the chatbot by default
109
+ run_app()
110
+ return
111
+
112
+ # Run the specified components
113
+ if args.scrape:
114
+ run_scraper()
115
+
116
+ if args.imports:
117
+ run_importer(args.imports)
118
+
119
+ if args.weaviate:
120
+ run_weaviate_command(command=args.weaviate, backup_id=args.backup_id)
121
+
122
+ if args.cli:
123
+ run_cli()
124
+
125
+ if args.app:
126
+ run_application(args.app)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()
requirements.txt ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core dependencies
2
+ langchain>=1.0.2
3
+ langchain-community>=0.3.29
4
+ langchain-core>=1.0.1
5
+ langchain-deepseek>=1.0.0
6
+ langchain-groq>=1.0.0
7
+ langchain-ollama>=0.3.10
8
+ langchain-openai>=1.0.1
9
+
10
+ requests>=2.31.0
11
+ openai>=1.3.0
12
+ python-dotenv>=1.0.0
13
+ tiktoken>=0.5.1
14
+ colorama>=0.4.6
15
+
16
+ # Web applications
17
+ gradio>=5.49.1
18
+
19
+ # Data processing
20
+ numpy>=1.24.3
21
+ pandas>=2.0.3
22
+
23
+ # UI
24
+ rich>=13.5.2
25
+
26
+ # Document processing pipeline
27
+ docling>=2.55.0
28
+
29
+ # Weaviate Vector DB
30
+ weaviate-client>=4.16.9
src/__init__.py ADDED
File without changes
src/apps/__init__.py ADDED
File without changes
src/apps/chat/.gradio/flagged/dataset1.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ name,intensity,output,timestamp
2
+ weqwe,30,Hello weqwe!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!,2025-10-22 18:26:18.763129
3
+ ,0,,2025-10-22 18:26:22.517154
src/apps/chat/.gradio/flagged/dataset1.csv:Zone.Identifier ADDED
Binary file (25 Bytes). View file
 
src/apps/chat/__init__.py ADDED
File without changes
src/apps/chat/app.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from src.rag.agent_chain import ExecutiveAgentChain
3
+ from src.utils.logging import get_logger, cached_log_handler
4
+
5
+ logger = get_logger("chatbot_app")
6
+
7
+ class ChatbotApplication:
8
+ def __init__(self, language: str = 'en') -> None:
9
+ self._language: str = language
10
+ self._agent_chain: ExecutiveAgentChain = ExecutiveAgentChain(language=self._language)
11
+ self._app = gr.Blocks()
12
+
13
+ chatbot: gr.Chatbot = gr.Chatbot(
14
+ value=[
15
+ gr.ChatMessage(
16
+ role='assistant',
17
+ content=self._agent_chain.generate_greeting(),
18
+ )
19
+ ],
20
+ type='messages'
21
+ )
22
+
23
+ with self._app:
24
+ with gr.Row():
25
+ with gr.Row(scale=3):
26
+ gr.ChatInterface(
27
+ chatbot=chatbot,
28
+ fn=self._chat,
29
+ title="Executive Education Adviser",
30
+ type='messages',
31
+ )
32
+ with gr.Row(scale=1):
33
+ log_window = gr.Code(
34
+ label='Console Log',
35
+ lines=30,
36
+ max_lines=30,
37
+ show_line_numbers=False,
38
+ )
39
+
40
+ log_timer = gr.Timer(value=0.5, active=True)
41
+ log_timer.tick(fn=lambda: cached_log_handler.get_logs(), outputs=log_window)
42
+
43
+
44
+ def _chat(self, message: str, history: list[dict]):
45
+ answers = []
46
+ try:
47
+ response = self._agent_chain.query(query=message)
48
+ logger.info("Recieved response from the agent, diplaying answer in the application")
49
+ answers.append(response)
50
+ except Exception as e:
51
+ logger.error(f"Error processing query: {e}")
52
+ answers.append("")
53
+ return answers
54
+
55
+
56
+ def run(self):
57
+ self._app.launch(share=True)
src/apps/panel/__init__.py ADDED
File without changes
src/apps/panel/app.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import os
3
+ import time
4
+
5
+ # Simulated processor
6
+ def process_documents(files, options):
7
+ results = []
8
+ for f, opt in zip(files, options):
9
+ results.append(f"{os.path.basename(f.name)} processed with {opt}")
10
+ time.sleep(0.5)
11
+ return results
12
+
13
+ # Toggle a button state
14
+ def toggle_option(option):
15
+ return not option
16
+
17
+ # Build a file row with buttons
18
+ def build_file_rows(files, states):
19
+ rows = []
20
+ with gr.Column():
21
+ for i, f in enumerate(files):
22
+ with gr.Row():
23
+ gr.Markdown(f"**{os.path.basename(f.name)}**")
24
+ chunk_btn = gr.Button("Chunk ✅" if states[i]["chunk"] else "Chunk ❌")
25
+ summary_btn = gr.Button("Summary ✅" if states[i]["summary"] else "Summary ❌")
26
+ embed_btn = gr.Button("Embed ✅" if states[i]["embed"] else "Embed ❌")
27
+ # Bind click events to toggle
28
+ chunk_btn.click(toggle_option, inputs=[states[i]["chunk"]], outputs=[states[i]["chunk"]])
29
+ summary_btn.click(toggle_option, inputs=[states[i]["summary"]], outputs=[states[i]["summary"]])
30
+ embed_btn.click(toggle_option, inputs=[states[i]["embed"]], outputs=[states[i]["embed"]])
31
+ rows.append(states[i])
32
+ return rows
33
+
34
+ # Initialize UI
35
+ with gr.Blocks() as demo:
36
+ uploaded_files = gr.File(file_count="multiple", label="Upload Files")
37
+ file_states = gr.State([]) # track toggle states per file
38
+ files_section = gr.Group(visible=False)
39
+ status_box = gr.Textbox(label="Status", interactive=False)
40
+
41
+ def display_files(files):
42
+ if not files:
43
+ return gr.update(visible=False), []
44
+ states = [{"chunk": True, "summary": False, "embed": True} for _ in files]
45
+ rows = build_file_rows(files, states)
46
+ return gr.update(visible=True), rows
47
+
48
+ uploaded_files.upload(
49
+ display_files,
50
+ inputs=[uploaded_files],
51
+ outputs=[files_section, file_states]
52
+ )
53
+
54
+ demo.launch()
55
+
src/database/__init__.py ADDED
File without changes
src/database/docker-compose.yml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.4'
2
+
3
+ services:
4
+ weaviate:
5
+ image: semitechnologies/weaviate:1.33.0
6
+ restart: on-failure:0
7
+ ports:
8
+ - "8080:8080"
9
+ - "50051:50051"
10
+ environment:
11
+ QUERY_DEFAULTS_LIMIT: 25
12
+ AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true'
13
+ PERSISTENCE_DATA_PATH: '/var/lib/weaviate'
14
+ ENABLE_API_BASED_MODULES: 'true'
15
+ ENABLE_MODULES: 'text2vec-transformers'
16
+ TRANSFORMERS_INFERENCE_API: 'http://t2v-transformers:8080'
17
+ CLUSTER_HOSTNAME: 'node1'
18
+ volumes:
19
+ - weaviate_data:/var/lib/weaviate
20
+
21
+ t2v-transformers:
22
+ image: semitechnologies/transformers-inference:sentence-transformers-all-MiniLM-L6-v2
23
+ restart: on-failure:0
24
+ ports:
25
+ - "8081:8080"
26
+
27
+ volumes:
28
+ weaviate_data:
29
+
src/database/weavservice.py ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import weaviate as wvt
2
+ import argparse, datetime, os
3
+
4
+ from time import perf_counter, sleep
5
+ from weaviate.classes.config import Configure, Property, DataType
6
+ from weaviate.collections.classes.grpc import MetadataQuery
7
+ from weaviate.collections.collection import Collection
8
+
9
+ from src.utils.logging import get_logger
10
+ from config import WeaviateConfiguration as wvtconf, AVAILABLE_LANGUAGES, HASH_FILE_PATH
11
+
12
+ logger = get_logger("weaviate_service")
13
+
14
+ _get_collection_name = lambda lang: f'{wvtconf.WEAVIATE_COLLECTION_BASENAME}_{lang}'
15
+ _collection_names = [_get_collection_name(lang) for lang in AVAILABLE_LANGUAGES]
16
+
17
+
18
+ class WeaviateService:
19
+ """
20
+ Provides an interface for interacting with the Weaviate vector database.
21
+ Handles initialization, data import, and hybrid queries.
22
+ """
23
+ def __init__(self) -> None:
24
+ """
25
+ Initialize the Weaviate service and establish a connection to the database.
26
+
27
+ Raises:
28
+ weaviate.exceptions.WeaviateConnectionError: If the connection fails.
29
+ """
30
+ self._connection_type = 'local' if wvtconf.is_local() else 'cloud'
31
+ self._client: wvt.WeaviateClient = self._connect_to_database()
32
+
33
+
34
+ def _connect_to_database(self):
35
+ retries = 0
36
+ client: wvt.WeaviateClient
37
+ while retries < 3:
38
+ try:
39
+ if wvtconf.is_local():
40
+ client = wvt.connect_to_local()
41
+ else:
42
+ client = wvt.connect_to_weaviate_cloud(
43
+ cluster_url=wvtconf.CLUSTER_URL,
44
+ auth_credentials=wvtconf.WEAVIATE_API_KEY,
45
+ headers={
46
+ "X-HuggingFace-Api-Key": wvtconf.HUGGING_FACE_API_KEY,
47
+ },
48
+ )
49
+ break
50
+ except Exception as e:
51
+ logger.error(f"Failed to establish a conneciton with the {self._connection_type} weaviate database: {e}")
52
+ retries += 1
53
+ sleep(1)
54
+
55
+ if retries == 3:
56
+ logger.error(f"Failed to establish a connection with the {self._connection_type} weaviate after 3 retries, terminating the application")
57
+ exit()
58
+
59
+ logger.info(f"Successully connected to the {self._connection_type} weaviate database")
60
+ return client
61
+
62
+
63
+ def _with_connection(func):
64
+ def wrapper(self, *args, **kwargs):
65
+ try:
66
+ if not self._client.is_connected():
67
+ self._client.connect()
68
+ logger.info(f"Created a connection with the {self._connection_type} weaviate database")
69
+ result = func(self, *args, **kwargs)
70
+ self._client.close()
71
+ logger.info(f"Closed the connection with the {self._connection_type} weaviate database")
72
+ return result
73
+ except Exception as e:
74
+ logger.exception(f"Client failed to connect to the {self._connection_type} weaviate database: {e}")
75
+
76
+ return wrapper
77
+
78
+
79
+ def _select_collection(self, lang: str) -> tuple[Collection, str]:
80
+ """
81
+ Select a language-specific collection as the active working collection.
82
+
83
+ Args:
84
+ lang (str): Acceptable language code.
85
+
86
+ Raises:
87
+ weaviate.exceptions.WeaviateConnectionError: If the specified language collection does not exist.
88
+ """
89
+ if lang not in AVAILABLE_LANGUAGES:
90
+ logger.error(f"No collection for language '{lang}' was found in the database")
91
+ return None, ''
92
+
93
+ collection_name = _get_collection_name(lang)
94
+ logger.info(f"Using collection {collection_name}")
95
+ return self._client.collections.use(collection_name), collection_name
96
+
97
+ @_with_connection
98
+ def batch_import(self, data_rows: list, lang: str) -> list:
99
+ """
100
+ Perform a batch import of multiple objects into the current collection.
101
+
102
+ Args:
103
+ data_rows (list): List of dictionaries representing the data rows to import.
104
+ lang (str, optional): Language collection to use. If not provided, uses the current one.
105
+
106
+ Returns:
107
+ list[dict]: List of failed imports with error details, if any.
108
+
109
+ Raises:
110
+ weaviate.exceptions.WeaviateConnectionError: If no active collection is available.
111
+ """
112
+ collection, collection_name = self._select_collection(lang)
113
+ if collection is None:
114
+ logger.error("No working collection selected upon starting batch import!")
115
+ return []
116
+
117
+ import_errors = []
118
+ logger.info(f"Initiating batch import for {len(data_rows)} data rows into collection {collection_name}")
119
+ with collection.batch.fixed_size(batch_size=100, concurrent_requests=2) as batch:
120
+ for idx, data_row in enumerate(data_rows):
121
+ try:
122
+ batch.add_object(properties=data_row)
123
+ except Exception as e:
124
+ import_errors.append({'index': idx, 'chunk_id': data_row['chunk_id'], 'error': str(e)})
125
+ continue
126
+
127
+ # Periodical checks for failed imports
128
+ if idx % 20 == 0 and idx > 0:
129
+ if batch.number_errors > 0:
130
+ logger.info(f"Amount of failed imports at index {idx}: {batch.number_errors}")
131
+ last_failed_object = self._current_collection.batch.failed_objects[-1]
132
+ logger.info(f"Last failure: {last_failed_object.message}")
133
+
134
+ logger.info(f"Batch import finished for {collection_name}")
135
+ logger.info("Total import errors: {len(import_errors)}" if import_errors else "No errors catched during importing!")
136
+
137
+ return import_errors
138
+
139
+
140
+ @_with_connection
141
+ def query(self, query: str, lang: str, query_properties: list[str] = None, limit: int = 5) -> dict:
142
+ """
143
+ Execute a hybrid semantic and keyword query against the active collection.
144
+
145
+ Args:
146
+ query (str): The query string.
147
+ query_properties (list[str], optional): List of properties to query against.
148
+ limit (int, optional): Maximum number of results to return. Defaults to 5.
149
+ distance (float, optional): Distance threshold for the query. Defaults to 0.25.
150
+ lang (str, optional): Language collection to use. If not provided, uses the current one.
151
+
152
+ Returns:
153
+ tuple: A tuple containing the query response and elapsed time.
154
+
155
+ Raises:
156
+ weaviate.exceptions.WeaviateConnectionError: If no active collection is available.
157
+ """
158
+ collection, collection_name = self._select_collection(lang)
159
+ if collection is None:
160
+ logger.error("No working collection selected upon starting of the querying!")
161
+ return [], 0
162
+
163
+ logger.info(f"Querying collection {collection_name}")
164
+ query_start_time = perf_counter()
165
+ resp = collection.query.hybrid(
166
+ query=query,
167
+ query_properties=query_properties,
168
+ limit=limit,
169
+ return_metadata=MetadataQuery.full()
170
+ )
171
+ elapsed = perf_counter() - query_start_time
172
+ logger.info(f"Querying retrieved {len(resp.objects)} objects in {elapsed:3.2f} seconds")
173
+
174
+ return (resp, elapsed)
175
+
176
+
177
+ @_with_connection
178
+ def _create_collections(self):
179
+ """
180
+ Create and initialize language-specific collections in the Weaviate database.
181
+
182
+ Each collection includes vector and generative configurations.
183
+
184
+ Raises:
185
+ weaviate.exceptions.WeaviateConnectionError: If the database connection fails.
186
+ """
187
+ logger.info('Attempting collections creation for the database...')
188
+
189
+ vector_config = (Configure.Vectors.text2vec_transformers() if wvtconf.is_local()
190
+ else Configure.Vectors.text2vec_huggingface(
191
+ name='hsg_rag_embeddings',
192
+ source_properties=['body'],
193
+ model="sentence-transformers/all-MiniLM-L6-v2",
194
+ ))
195
+ successful_creations = 0
196
+ for collection_name in _collection_names:
197
+ try:
198
+ self._client.collections.create(
199
+ name=collection_name,
200
+ properties=[
201
+ Property(name='body', data_type=DataType.TEXT),
202
+ Property(name='chunk_id', data_type=DataType.TEXT),
203
+ Property(name='document_id', data_type=DataType.TEXT),
204
+ Property(name='programs', data_type=DataType.TEXT_ARRAY),
205
+ Property(name='source', data_type=DataType.TEXT),
206
+ Property(name='date', data_type=DataType.DATE)
207
+ ],
208
+ vector_config=vector_config)
209
+ logger.info(f"Created collection {collection_name}")
210
+ successful_creations += 1
211
+ except Exception as e:
212
+ logger.error(f"Failed to initialize collection '{collection_name}': {e}")
213
+ if successful_creations == len(_collection_names):
214
+ logger.info('All collections were successfully instantiated in the database')
215
+
216
+
217
+ @_with_connection
218
+ def _delete_collections(self):
219
+ """
220
+ Delete all existing collections from the Weaviate database.
221
+
222
+ Raises:
223
+ weaviate.exceptions.WeaviateConnectionError: If the connection to the database fails.
224
+ """
225
+ logger.info("Initiating the deletion of stored collections.")
226
+ for collection_name in _collection_names:
227
+ if self._client.collections.exists(collection_name):
228
+ self._client.collections.delete(collection_name)
229
+ logger.info(f"Deleted collection {collection_name}")
230
+ else:
231
+ logger.warning(f"Cannot delete collection {collection_name}: collection does not exist!")
232
+
233
+ logger.info("Finished the deletion of stored colections")
234
+
235
+ if os.path.exists(HASH_FILE_PATH):
236
+ logger.info(f"Hashtable found on path {HASH_FILE_PATH}; deleting hash file...")
237
+ os.remove(HASH_FILE_PATH)
238
+ logger.info("Hash file deleted successfully")
239
+
240
+ @_with_connection
241
+ def _create_backup(self) -> None:
242
+ """
243
+ Creates a backup instance from current weaviate database state and uploads it to selected backend storage service.
244
+ """
245
+ backup_id = datetime.datetime.now().strftime("%Y%m%d%H%M%S%f")
246
+ logger.info(f"Initiating backup creation for the {self._connection_type} weaviate database")
247
+ try:
248
+ result = self._client.backup.create(
249
+ backup_id=f"hsg_wvtdb_backup_{backup_id}",
250
+ backend=wvtconf.WEAVIATE_BACKUP_BACKEND,
251
+ include_collections=_collection_names,
252
+ wait_for_completion=True
253
+ )
254
+ logger.info(f"Creation of backup '{backup_id}' finished with response: {result}")
255
+ except Exception as e:
256
+ logger.error(f"Failed to create a backup due to an unexpected error: {e.message}")
257
+ raise e
258
+
259
+
260
+ @_with_connection
261
+ def _restore_backup(self, backup_id: str):
262
+ """
263
+ Restores the state of the database from the provided backup.
264
+
265
+ Args:
266
+ backup_id(str): ID of the backup from which the database state should be restored.
267
+ """
268
+ logger.info("Initiating restoration process from the backup '{backup_id}' for the {self._connection_type} weaviate database")
269
+ try:
270
+ result = self._client.backup.restore(
271
+ backup_id=backup_id,
272
+ backend=wvtconf.WEAVIATE_BACKUP_BACKEND,
273
+ include_collections=_collection_names,
274
+ wait_for_completion=True,
275
+ )
276
+ logger.info(f"Restored backup '{backup_id}' with response: {result}")
277
+ except Exception as e:
278
+ logger.error(f"Failed to restore a backup due to an unexpected error: {e.message}")
279
+ raise e
280
+
281
+
282
+ @_with_connection
283
+ def _checkhealth(self):
284
+ """
285
+ Check the connectivity and health status of the Weaviate database and its collections.
286
+
287
+ Prints the connection status and verifies the existence of collections for each supported language.
288
+ """
289
+ connection_exists = self._client.is_connected()
290
+ logger.info(f"Checking the connection to the {self._connection_type} weaviate database: {'OK!' if connection_exists else 'ERROR'}")
291
+ if not connection_exists: return
292
+
293
+ metainfo = self._client.get_meta()
294
+ modules_str = ', '.join(metainfo['modules'])
295
+ modules_str = modules_str[:30] + '...' if len(modules_str) > 30 else modules_str
296
+ if wvtconf.is_local():
297
+ logger.info(f"Cluster metadata: HOSTNAME={metainfo['hostname']}, VERSION={metainfo['version']}, MODULES={modules_str}")
298
+
299
+ for collection_name in _collection_names:
300
+ logger.info(f"Checking the existence of collection {collection_name}: "
301
+ f"{'OK!' if self._client.collections.exists(collection_name) else 'ERROR' }")
302
+
303
+
304
+ def parse_arguments():
305
+ """
306
+ Parse command-line arguments for managing Weaviate collections.
307
+
308
+ Returns:
309
+ argparse.Namespace: Parsed command-line arguments.
310
+ """
311
+ parser = argparse.ArgumentParser()
312
+ group = parser.add_mutually_exclusive_group()
313
+ group.add_argument('-dc', "--delete_collections", action='store_true', help='deletes all collections from the database')
314
+ group.add_argument('-cc', "--create_collections", action='store_true', help='initializes the collections for different language contents separately')
315
+ group.add_argument('-rc', "--redo_collections", action='store_true', help='deletes and creates the collections anew')
316
+
317
+ group.add_argument('-ch', "--checkhealth", action='store_true', help='checks the connection to the database, existense of content collections...')
318
+ group.add_argument('-cb', "--create_backup", action='store_true', help='creates a backup of the current state of the database')
319
+ group.add_argument('-rb', "--restore_backup", type=str, help='restores the state of the database from the provided backup_id')
320
+ return parser.parse_args()
321
+
322
+
323
+ if __name__ == "__main__":
324
+ args = parse_arguments()
325
+ service = WeaviateService()
326
+
327
+ if args.create_backup:
328
+ service._create_backup()
329
+
330
+ if args.restore_backup:
331
+ service._restore_backup(args.restore_backup)
332
+
333
+ if any([args.delete_collections, args.redo_collections]):
334
+ service._delete_collections()
335
+
336
+ if any([args.create_collections, args.redo_collections]):
337
+ service._create_collections()
338
+
339
+ if any([args.checkhealth, args.create_collections, args.redo_collections]):
340
+ service._checkhealth()
341
+
src/pipeline/__init__.py ADDED
File without changes
src/pipeline/pipeline.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ """
75
+ Scrape program pages from the website, process and deduplicate them,
76
+ and import unique chunks into the database.
77
+ """
78
+ unique_chunks = {lang: [] for lang in AVAILABLE_LANGUAGES}
79
+ for result in self._webprocessor.process():
80
+ chunks = self._deduplicate(result)
81
+ unique_chunks[result.language].extend(chunks)
82
+
83
+ if not unique_chunks:
84
+ implogger.warning("Information provided by the HSG website does not contain any unique information. Terminating the pipeline without importing")
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]):
92
+ """
93
+ Import multiple documents by processing, deduplicating, and inserting
94
+ unique chunks into the database.
95
+
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)
102
+
103
+ if not sources:
104
+ implogger.warning("Import list does not contain any sources, aborting the import pipeline!")
105
+ return
106
+
107
+ if len(sources) > 1:
108
+ implogger.info(f"Initiating the import pipeline for multiple sources: {', '.join(sources)}")
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):
125
+ """
126
+ Import a single document into the database.
127
+
128
+ Args:
129
+ source (Path | str): Path to the document to process and import.
130
+ """
131
+ self.import_many_documents([source])
132
+
133
+
134
+ def _import_to_database(self, unique_chunks):
135
+ """
136
+ Import the processed unique chunks into the Weaviate database.
137
+
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
+ """
154
+ Process a single document source, deduplicate its chunks, and
155
+ determine its language.
156
+
157
+ Args:
158
+ source (Path | str): Path to the document to process.
159
+
160
+ Returns:
161
+ tuple[list, str]: List of unique chunks and detected language.
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()
src/processing/__init__.py ADDED
File without changes
src/processing/processor.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, re, hashlib, time
2
+
3
+ from enum import Enum
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from dataclasses import dataclass
7
+ from transformers import AutoTokenizer
8
+ from docling_core.transforms.chunker.tokenizer.huggingface import HuggingFaceTokenizer
9
+ from docling.document_converter import DocumentConverter
10
+ from docling.chunking import HybridChunker
11
+ from docling_core.types.doc.document import DoclingDocument
12
+
13
+ from src.utils.lang import detect_language
14
+ from src.utils.logging import get_logger
15
+ from config import BASE_URL, CHUNK_MAX_TOKENS
16
+
17
+ weblogger = get_logger("website_processor")
18
+ datalogger = get_logger("data_processor")
19
+
20
+ _TRANSFORMERS_TOKENIZER = AutoTokenizer.from_pretrained("sentence-transformers/all-MiniLM-L6-v2")
21
+ _EN_URL_PATTERN = r'\[EN\]\((https://emba\.unisg\.ch/en/[^\s)]+)\)'
22
+ _PROGRAM_URL_PATTERN = r'https://emba\.unisg\.ch/(?:programm[^\s)]+|en/embax)'
23
+
24
+
25
+ def _get_hash(text: str) -> str:
26
+ """Generate an MD5 hash for the given text."""
27
+ return hashlib.md5(text.strip().encode("utf-8")).hexdigest()
28
+
29
+
30
+ def _get_en_version(text: str):
31
+ """Extract the English version URL from the given text, if available."""
32
+ result = re.search(_EN_URL_PATTERN, text)
33
+ if result:
34
+ return result.group(1)
35
+ return ""
36
+
37
+
38
+ def _get_program_urls(text: str):
39
+ """Find all program URLs in the given text."""
40
+ return re.findall(_PROGRAM_URL_PATTERN, text)
41
+
42
+
43
+ def _detect_programs(text: str):
44
+ """
45
+ Identify MBA program names mentioned in the given text.
46
+
47
+ Args:
48
+ text (str): The text to search for program mentions.
49
+
50
+ Returns:
51
+ list[str]: List of detected program identifiers.
52
+ """
53
+ programms = []
54
+ lc_text = text.lower()
55
+ found = lambda txt: txt in lc_text
56
+
57
+ if found('emba') or found('executive mba'):
58
+ programms.append('emba')
59
+
60
+ if found('iemba') or found('international emba') or found('international executive mba'):
61
+ programms.append('iemba')
62
+
63
+ if found('emba x'):
64
+ programms.append('emba_x')
65
+
66
+ return programms
67
+
68
+
69
+ class ProcessingStatus(Enum):
70
+ NOT_FOUND = 1
71
+ SUCCESS = 2
72
+ FAILURE = 3
73
+ INCORRECT_FORMAT = 5
74
+
75
+
76
+ @dataclass
77
+ class _ChunkMetadata:
78
+ programs: str
79
+ date: str
80
+ document_id: str
81
+ language: str
82
+ source: str = None
83
+
84
+
85
+ @dataclass
86
+ class ProcessingResult:
87
+ status: ProcessingStatus = ProcessingStatus.SUCCESS
88
+ chunks: list = None
89
+ document_id: str = None
90
+ language: str = None
91
+
92
+
93
+ class _ProcessorBase:
94
+ def __init__(self):
95
+ """
96
+ Initialize the Processor with converter, chunker, and hashtable.
97
+
98
+ Args:
99
+ config (dict, optional): Configuration dictionary for processing options.
100
+ """
101
+ self._converter = DocumentConverter()
102
+ self._chunker = HybridChunker(
103
+ tokenizer=HuggingFaceTokenizer(
104
+ tokenizer=_TRANSFORMERS_TOKENIZER,
105
+ max_tokens=CHUNK_MAX_TOKENS
106
+ ),
107
+ max_tokens=CHUNK_MAX_TOKENS,
108
+ merge_peers=True
109
+ )
110
+
111
+ def process(self):
112
+ """Abstract method to be implemented by subclasses."""
113
+ raise NotImplementedError("This method is not implemented in ProcessorBase")
114
+
115
+
116
+ def _collect_metadata(self, text: str) -> _ChunkMetadata:
117
+ """Collect metadata such as programs, date, language, and document hash."""
118
+ return _ChunkMetadata(
119
+ programs=_detect_programs(text),
120
+ date=datetime.now().replace(tzinfo=timezone.utc),
121
+ language=detect_language(text),
122
+ document_id=_get_hash(text))
123
+
124
+
125
+ def _collect_chunks(self, document: DoclingDocument, metadata: _ChunkMetadata) -> list:
126
+ """
127
+ Collect text chunks from a document and prepare them with metadata.
128
+
129
+ Args:
130
+ document (DoclingDocument): The converted document object.
131
+ metadata (_ChunkMetadata): Metadata containing program, source, and date information.
132
+
133
+ Returns:
134
+ list[dict]: List of chunk dictionaries containing text and metadata.
135
+ """
136
+ chunks = [self._chunker.contextualize(chunk=c) for c in self._chunker.chunk(document)]
137
+ prepared_chunks = []
138
+ for chunk in chunks:
139
+ c_hash = _get_hash(chunk)
140
+ prepared_chunks.append({
141
+ 'body': chunk,
142
+ 'chunk_id': c_hash,
143
+ 'document_id': metadata.document_id,
144
+ 'programs': metadata.programs,
145
+ 'date': metadata.date,
146
+ 'source': metadata.source
147
+ })
148
+ return prepared_chunks
149
+
150
+
151
+ class WebsiteProcessor(_ProcessorBase):
152
+ def __init__(self):
153
+ """Initialize the WebsiteProcessor with base processing capabilities."""
154
+ super().__init__()
155
+
156
+
157
+ def process(self) -> list[ProcessingResult]:
158
+ """
159
+ Scrape and process program pages from the HSG website.
160
+
161
+ Returns:
162
+ list[ProcessingResult]: A list of processing results for each processed URL.
163
+ """
164
+ weblogger.info("Initiating scraping and processing of the HSG program pages.")
165
+ urls = [BASE_URL]
166
+ results = []
167
+ while urls:
168
+ url = urls.pop()
169
+ result, text = self._process_url(url)
170
+
171
+ if result.status != ProcessingStatus.SUCCESS:
172
+ weblogger.warning(f"Failed to process URLs {url}.")
173
+ continue
174
+
175
+ if url == BASE_URL:
176
+ program_urls = _get_program_urls(text)
177
+ urls.extend(program_urls)
178
+ weblogger.info(f"Found following program URLs: {', '.join(program_urls)}.")
179
+
180
+ if '/en/' not in url:
181
+ en_url = _get_en_version(text)
182
+ urls.append(en_url)
183
+ weblogger.info(f"Added an english version of the URL {en_url} to the processing list")
184
+
185
+ results.append(result)
186
+ time.sleep(2)
187
+
188
+ weblogger.info(f"Successfully processed {len(results)} URLs.")
189
+ return results
190
+
191
+
192
+ def _process_url(self, url: str) -> tuple[ProcessingResult, str]:
193
+ """
194
+ Process the content of a single URL, converting it into chunks with metadata.
195
+
196
+ Args:
197
+ url (str): The URL of the webpage to process.
198
+
199
+ Returns:
200
+ tuple[ProcessingResult, str]: The processing result and the extracted text.
201
+ """
202
+ weblogger.info(f"Initiating processing pipeline for url {url}")
203
+ try:
204
+ document = self._converter.convert(url).document
205
+ except Exception as e:
206
+ weblogger.error(f"Failed to load the contents of the url page {url}: {e}")
207
+ return ProcessingResult(status=ProcessingStatus.FAILURE)
208
+
209
+ text = document.export_to_markdown()
210
+ metadata = self._collect_metadata(text)
211
+ metadata.source = url
212
+ collected_chunks = self._collect_chunks(document, metadata)
213
+ del collected_chunks[0]
214
+ weblogger.info(f"Successfully collected {len(collected_chunks)} chunks from {url}")
215
+
216
+ return ProcessingResult(chunks=collected_chunks, language=metadata.language, document_id=metadata.document_id), text
217
+
218
+
219
+ class DataProcessor(_ProcessorBase):
220
+ """
221
+ Handles document processing, including conversion, chunking, language detection,
222
+ and hash-based deduplication.
223
+ """
224
+
225
+ def process_many_documents(self, sources: list[Path | str]) -> list[ProcessingResult]:
226
+ """
227
+ Process a list of document sources sequentially.
228
+
229
+ Args:
230
+ sources (list[Path | str]): List of file paths or URLs to process.
231
+
232
+ Returns:
233
+ list[ProcessingResult]: List of results for each processed document.
234
+ """
235
+ return [self.process(source) for source in sources]
236
+
237
+
238
+ def process(self, source: Path | str) -> ProcessingResult:
239
+ """
240
+ Process a single document source, converting it to text, chunking, and hashing.
241
+
242
+ Args:
243
+ source (Path | str): Path to the document to process.
244
+
245
+ Returns:
246
+ ProcessingResult: The result of the processing operation, including chunks and language.
247
+ """
248
+ if not os.path.exists(source) or not os.path.isfile(source):
249
+ datalogger.error(f"Failed to initiate processing pipeline for source {source}: file does not exist")
250
+ return ProcessingResult(status=ProcessingStatus.NOT_FOUND)
251
+
252
+ datalogger.info(f"Initiating processing pipeline for source {source}")
253
+ document = self._converter.convert(source).document
254
+ metadata = self._collect_metadata(document.export_to_markdown())
255
+ metadata.source = os.path.basename(source)
256
+ collected_chunks = self._collect_chunks(document, metadata)
257
+ datalogger.info(f"Successfully collected {len(collected_chunks)} chunks from {source}")
258
+
259
+ return ProcessingResult(chunks=collected_chunks, language=metadata.language, document_id=metadata.document_id)
260
+
261
+
262
+ if __name__ == "__main__":
263
+ processor = WebsiteProcessor()
264
+ results = processor.process()
265
+
266
+ for result in results:
267
+ for chunk in result.chunks:
268
+ print(chunk['body'], end='\n\n')
src/processing/scraping.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data processor for cleaning and structuring scraped program data.
3
+ """
4
+ import json
5
+ import logging
6
+ import os
7
+ from typing import Dict, List, Any
8
+
9
+ import pandas as pd
10
+
11
+ from config import RAW_DATA_PATH, PROCESSED_DATA_PATH
12
+
13
+ # Configure logging
14
+ logging.basicConfig(
15
+ level=logging.INFO,
16
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
17
+ )
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ScrapingProcessor:
22
+ """Processor for cleaning and structuring scraped program data."""
23
+
24
+ def __init__(self, input_path: str = RAW_DATA_PATH, output_path: str = PROCESSED_DATA_PATH, manual_data_path: str = None):
25
+ """
26
+ Initialize the data processor.
27
+
28
+ Args:
29
+ input_path: Path to the raw data file.
30
+ output_path: Path to save the processed data.
31
+ manual_data_path: Path to the manual data file (optional).
32
+ """
33
+ self.input_path = input_path
34
+ self.output_path = output_path
35
+ self.manual_data_path = manual_data_path or os.path.join(os.path.dirname(input_path), "manual_data.json")
36
+
37
+ def load_data(self) -> List[Dict[str, Any]]:
38
+ """
39
+ Load raw data from the input file.
40
+
41
+ Returns:
42
+ A list of dictionaries containing program data.
43
+ """
44
+ try:
45
+ with open(self.input_path, "r", encoding="utf-8") as f:
46
+ data = json.load(f)
47
+
48
+ # If raw data is empty, try to load manual data
49
+ if not data and os.path.exists(self.manual_data_path):
50
+ logger.info(f"Raw data is empty, loading manual data from {self.manual_data_path}")
51
+ with open(self.manual_data_path, "r", encoding="utf-8") as f:
52
+ data = json.load(f)
53
+ logger.info(f"Loaded {len(data)} programs from {self.manual_data_path}")
54
+ else:
55
+ logger.info(f"Loaded {len(data)} programs from {self.input_path}")
56
+
57
+ return data
58
+ except Exception as e:
59
+ logger.error(f"Error loading data: {e}")
60
+
61
+ # If there was an error, try to load manual data as a fallback
62
+ try:
63
+ if os.path.exists(self.manual_data_path):
64
+ logger.info(f"Attempting to load manual data from {self.manual_data_path}")
65
+ with open(self.manual_data_path, "r", encoding="utf-8") as f:
66
+ data = json.load(f)
67
+ logger.info(f"Loaded {len(data)} programs from {self.manual_data_path}")
68
+ return data
69
+ except Exception as e2:
70
+ logger.error(f"Error loading manual data: {e2}")
71
+
72
+ return []
73
+
74
+ def save_data(self, data: List[Dict[str, Any]]) -> None:
75
+ """
76
+ Save processed data to the output file.
77
+
78
+ Args:
79
+ data: The processed data to save.
80
+ """
81
+ try:
82
+ # Ensure the directory exists
83
+ os.makedirs(os.path.dirname(self.output_path), exist_ok=True)
84
+
85
+ with open(self.output_path, "w", encoding="utf-8") as f:
86
+ json.dump(data, f, ensure_ascii=False, indent=2)
87
+
88
+ logger.info(f"Saved {len(data)} processed programs to {self.output_path}")
89
+ except Exception as e:
90
+ logger.error(f"Error saving data to {self.output_path}: {e}")
91
+
92
+ def clean_text(self, text: str) -> str:
93
+ """
94
+ Clean text by removing extra whitespace and normalizing.
95
+
96
+ Args:
97
+ text: The text to clean.
98
+
99
+ Returns:
100
+ The cleaned text.
101
+ """
102
+ if not text or not isinstance(text, str):
103
+ return ""
104
+
105
+ # Replace multiple whitespace with a single space
106
+ cleaned = " ".join(text.split())
107
+ return cleaned.strip()
108
+
109
+ def clean_list(self, items: List[str]) -> List[str]:
110
+ """
111
+ Clean a list of text items.
112
+
113
+ Args:
114
+ items: The list of text items to clean.
115
+
116
+ Returns:
117
+ The cleaned list of text items.
118
+ """
119
+ if not items or not isinstance(items, list):
120
+ return []
121
+
122
+ cleaned_items = [self.clean_text(item) for item in items if item]
123
+ return [item for item in cleaned_items if item] # Remove empty items
124
+
125
+ def normalize_costs(self, cost_text: str) -> Dict[str, Any]:
126
+ """
127
+ Normalize cost information.
128
+
129
+ Args:
130
+ cost_text: The cost text to normalize.
131
+
132
+ Returns:
133
+ A dictionary with normalized cost information.
134
+ """
135
+ if not cost_text or cost_text == "Not specified":
136
+ return {"amount": None, "currency": None, "original_text": cost_text}
137
+
138
+ # Try to extract currency and amount
139
+ import re
140
+
141
+ # Look for common currency patterns
142
+ currency_patterns = {
143
+ "CHF": r"CHF\s*([\d\',\.]+)",
144
+ "EUR": r"(?:€|EUR)\s*([\d\',\.]+)",
145
+ "USD": r"(?:\$|USD)\s*([\d\',\.]+)",
146
+ }
147
+
148
+ for currency, pattern in currency_patterns.items():
149
+ match = re.search(pattern, cost_text, re.IGNORECASE)
150
+ if match:
151
+ # Extract the amount and clean it
152
+ amount_str = match.group(1)
153
+ # Remove thousands separators and convert to float
154
+ amount_str = amount_str.replace(",", "").replace("'", "")
155
+ try:
156
+ amount = float(amount_str)
157
+ return {
158
+ "amount": amount,
159
+ "currency": currency,
160
+ "original_text": cost_text,
161
+ }
162
+ except ValueError:
163
+ pass
164
+
165
+ # If no pattern matched, return the original text
166
+ return {"amount": None, "currency": None, "original_text": cost_text}
167
+
168
+ def normalize_duration(self, duration_text: str) -> Dict[str, Any]:
169
+ """
170
+ Normalize duration information.
171
+
172
+ Args:
173
+ duration_text: The duration text to normalize.
174
+
175
+ Returns:
176
+ A dictionary with normalized duration information.
177
+ """
178
+ if not duration_text or duration_text == "Not specified":
179
+ return {"months": None, "original_text": duration_text}
180
+
181
+ # Try to extract duration in months
182
+ import re
183
+
184
+ # Look for common duration patterns
185
+ month_patterns = [
186
+ r"(\d+)\s*months?",
187
+ r"(\d+)\s*month program",
188
+ ]
189
+
190
+ year_patterns = [
191
+ r"(\d+)\s*years?",
192
+ r"(\d+)\s*year program",
193
+ r"(\d+)\s*-\s*year",
194
+ ]
195
+
196
+ # Check for months first
197
+ for pattern in month_patterns:
198
+ match = re.search(pattern, duration_text, re.IGNORECASE)
199
+ if match:
200
+ try:
201
+ months = int(match.group(1))
202
+ return {
203
+ "months": months,
204
+ "original_text": duration_text,
205
+ }
206
+ except ValueError:
207
+ pass
208
+
209
+ # Check for years and convert to months
210
+ for pattern in year_patterns:
211
+ match = re.search(pattern, duration_text, re.IGNORECASE)
212
+ if match:
213
+ try:
214
+ years = int(match.group(1))
215
+ months = years * 12
216
+ return {
217
+ "months": months,
218
+ "original_text": duration_text,
219
+ }
220
+ except ValueError:
221
+ pass
222
+
223
+ # If no pattern matched, return the original text
224
+ return {"months": None, "original_text": duration_text}
225
+
226
+ def process_program(self, program: Dict[str, Any]) -> Dict[str, Any]:
227
+ """
228
+ Process a single program's data.
229
+
230
+ Args:
231
+ program: The program data to process.
232
+
233
+ Returns:
234
+ The processed program data.
235
+ """
236
+ processed = {}
237
+
238
+ # Copy basic fields
239
+ processed["url"] = program.get("url", "")
240
+ processed["name"] = self.clean_text(program.get("name", "Unknown Program"))
241
+ processed["description"] = self.clean_text(program.get("description", ""))
242
+
243
+ # Process duration
244
+ processed["duration"] = self.normalize_duration(program.get("duration", "Not specified"))
245
+
246
+ # Process costs
247
+ processed["costs"] = self.normalize_costs(program.get("costs", "Not specified"))
248
+
249
+ # Process lists
250
+ processed["curriculum"] = self.clean_list(program.get("curriculum", []))
251
+ processed["admission_requirements"] = self.clean_list(program.get("admission_requirements", []))
252
+
253
+ # Process faculty
254
+ faculty = program.get("faculty", [])
255
+ processed_faculty = []
256
+ for member in faculty:
257
+ if isinstance(member, dict):
258
+ processed_faculty.append({
259
+ "name": self.clean_text(member.get("name", "")),
260
+ "title": self.clean_text(member.get("title", "")),
261
+ })
262
+ processed["faculty"] = processed_faculty
263
+
264
+ # Process other fields
265
+ processed["schedules"] = self.clean_text(program.get("schedules", "Not specified"))
266
+ processed["deadlines"] = self.clean_text(program.get("deadlines", "Not specified"))
267
+ processed["language"] = self.clean_text(program.get("language", "Not specified"))
268
+ processed["location"] = self.clean_text(program.get("location", "Not specified"))
269
+
270
+ # Add metadata
271
+ processed["program_id"] = f"prog_{hash(processed['url']) % 10000:04d}"
272
+
273
+ return processed
274
+
275
+ def process_data(self) -> List[Dict[str, Any]]:
276
+ """
277
+ Process all program data.
278
+
279
+ Returns:
280
+ A list of processed program data.
281
+ """
282
+ raw_data = self.load_data()
283
+ processed_data = []
284
+
285
+ for program in raw_data:
286
+ try:
287
+ processed_program = self.process_program(program)
288
+ processed_data.append(processed_program)
289
+ except Exception as e:
290
+ logger.error(f"Error processing program {program.get('name', 'Unknown')}: {e}")
291
+
292
+ logger.info(f"Processed {len(processed_data)} programs")
293
+ return processed_data
294
+
295
+ def run(self) -> None:
296
+ """Run the data processor."""
297
+ logger.info("Starting data processing...")
298
+ processed_data = self.process_data()
299
+ self.save_data(processed_data)
300
+ logger.info("Data processing completed")
301
+
302
+ def generate_stats(self) -> Dict[str, Any]:
303
+ """
304
+ Generate statistics about the processed data.
305
+
306
+ Returns:
307
+ A dictionary containing statistics.
308
+ """
309
+ try:
310
+ processed_data = self.load_data()
311
+ if not processed_data:
312
+ return {"error": "No processed data available"}
313
+
314
+ # Convert to DataFrame for easier analysis
315
+ df = pd.DataFrame(processed_data)
316
+
317
+ # Basic stats
318
+ stats = {
319
+ "total_programs": len(df),
320
+ "languages": {},
321
+ "locations": {},
322
+ "duration_months": {
323
+ "min": None,
324
+ "max": None,
325
+ "avg": None,
326
+ },
327
+ "costs": {
328
+ "min": {},
329
+ "max": {},
330
+ "avg": {},
331
+ },
332
+ }
333
+
334
+ # Language stats
335
+ if "language" in df.columns:
336
+ language_counts = df["language"].value_counts().to_dict()
337
+ stats["languages"] = language_counts
338
+
339
+ # Location stats
340
+ if "location" in df.columns:
341
+ location_counts = df["location"].value_counts().to_dict()
342
+ stats["locations"] = location_counts
343
+
344
+ # Duration stats
345
+ if "duration" in df.columns:
346
+ # Extract months from duration dictionaries
347
+ months = [d.get("months") for d in df["duration"] if d.get("months") is not None]
348
+ if months:
349
+ stats["duration_months"]["min"] = min(months)
350
+ stats["duration_months"]["max"] = max(months)
351
+ stats["duration_months"]["avg"] = sum(months) / len(months)
352
+
353
+ # Cost stats by currency
354
+ if "costs" in df.columns:
355
+ # Group costs by currency
356
+ currencies = {}
357
+ for cost in df["costs"]:
358
+ currency = cost.get("currency")
359
+ amount = cost.get("amount")
360
+ if currency and amount is not None:
361
+ if currency not in currencies:
362
+ currencies[currency] = []
363
+ currencies[currency].append(amount)
364
+
365
+ # Calculate stats for each currency
366
+ for currency, amounts in currencies.items():
367
+ if amounts:
368
+ stats["costs"]["min"][currency] = min(amounts)
369
+ stats["costs"]["max"][currency] = max(amounts)
370
+ stats["costs"]["avg"][currency] = sum(amounts) / len(amounts)
371
+
372
+ return stats
373
+ except Exception as e:
374
+ logger.error(f"Error generating stats: {e}")
375
+ return {"error": str(e)}
376
+
377
+
378
+ if __name__ == "__main__":
379
+ processor = DataProcessor()
380
+ processor.run()
381
+ stats = processor.generate_stats()
382
+ print(json.dumps(stats, indent=2))
src/rag/__init__.py ADDED
File without changes
src/rag/agent_chain.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain.tools import tool
2
+ from langchain.agents import create_agent
3
+ from langchain_core.messages import (
4
+ HumanMessage,
5
+ AIMessage,
6
+ SystemMessage,
7
+ )
8
+ from langchain.agents.middleware import (
9
+ SummarizationMiddleware,
10
+ ModelFallbackMiddleware,
11
+ )
12
+
13
+ from langgraph.checkpoint.memory import InMemorySaver
14
+
15
+ from src.database.weavservice import WeaviateService
16
+
17
+ from src.rag.utilclasses import *
18
+ from src.rag.middleware import AgentChainMiddleware as chainmdw
19
+ from src.rag.prompts import PromptConfigurator as promptconf
20
+ from src.rag.models import ModelConfigurator as modelconf
21
+
22
+ from src.utils.logging import get_logger
23
+ from config import TOP_K_RETRIEVAL
24
+
25
+ chain_logger = get_logger('agent_chain')
26
+
27
+ class ExecutiveAgentChain:
28
+ def __init__(self, language: str = 'en') -> None:
29
+ self._language = language
30
+ self._dbservice = WeaviateService()
31
+ self._agents, self._config = self._init_agents()
32
+ chain_logger.info(f"Initalized new Agent Chain for language '{language}'")
33
+
34
+
35
+ def _retrieve_context(self, query: str, language: str = None):
36
+ """
37
+ Send the query to the vector database to retrieve additional information about the program.
38
+
39
+ Args:
40
+ query: Keywords depicting information you want to retrieve in the primary language.
41
+ 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.
42
+ """
43
+ print('called retrieve_context')
44
+ lang = language or self._language
45
+ try:
46
+ response, _ = self._dbservice.query(
47
+ query=query,
48
+ lang=lang,
49
+ limit=TOP_K_RETRIEVAL,
50
+ )
51
+ serialized = '\n\n'.join(
52
+ ("Source: {source}\nPrograms: {programs}\nContent: {content}".format(
53
+ source=doc.properties.get('source', 'unknown'),
54
+ programs=', '.join(doc.properties.get('programs', 'unknown')),
55
+ content=doc.properties.get('body', '')))
56
+ for doc in response.objects
57
+ )
58
+ return serialized
59
+ except Exception as _:
60
+ return ''
61
+
62
+
63
+ def _call_emba_agent(self, query: str) -> str:
64
+ """
65
+ Invokes the EMBA support agent to retrieve more detailed information about the EMBA program.
66
+
67
+ Args:
68
+ query: Query to the EMBA support agent. Provide collected user data in the query if possible.
69
+ """
70
+ response = self._query(agent=self._agents['emba'], messages=[HumanMessage(query)])
71
+ return response
72
+
73
+
74
+ def _call_iemba_agent(self, query: str) -> str:
75
+ """
76
+ Invokes the IEMBA support agent to retrieve more detailed information about the IEMBA program.
77
+
78
+ Args:
79
+ query: Query to the IEMBA support agent. Provide collected user data in the query if possible.
80
+ """
81
+ response = self._query(agent=self._agents['iemba'], messages=[HumanMessage(query)])
82
+ return response
83
+
84
+
85
+ def _call_embax_agent(self, query: str) -> str:
86
+ """
87
+ Invokes the EMBA X support agent to retrieve more detailed information about the EMBA X program.
88
+
89
+ Args:
90
+ query: Query to the EMBA X support agent. Provide collected user data in the query if possible.
91
+ """
92
+ response = self._query(agent=self._agents['embax'], messages=[HumanMessage(query)])
93
+ return response
94
+
95
+
96
+ def _init_agents(self):
97
+ config: RunnableConfig = {
98
+ 'configurable': {'thread_id': 0}
99
+ }
100
+ checkpointer = InMemorySaver()
101
+ fallback_middleware = ModelFallbackMiddleware(
102
+ *modelconf.get_fallback_models()
103
+ )
104
+ summarization_middleware = SummarizationMiddleware(
105
+ model=modelconf.get_summarization_model(),
106
+ max_tokens_before_summary=1000,
107
+ messages_to_keep=5,
108
+ summary_prompt=promptconf.get_summarization_prompt(),
109
+ summary_prefix=promptconf.get_summary_prefix(),
110
+ )
111
+ tool_retrieve_context = tool(
112
+ name_or_callable='retrieve_context',
113
+ runnable=self._retrieve_context,
114
+ return_direct=False,
115
+ parse_docstring=True,
116
+ )
117
+ tools_agent_calling = [
118
+ tool(
119
+ name_or_callable='call_emba_agent',
120
+ runnable=self._call_emba_agent,
121
+ return_direct=False,
122
+ parse_docstring=True,
123
+ ),
124
+ tool(
125
+ name_or_callable='call_iemba_agent',
126
+ runnable=self._call_iemba_agent,
127
+ return_direct=False,
128
+ parse_docstring=True,
129
+ ),
130
+ tool(
131
+ name_or_callable='call_embax_agent',
132
+ runnable=self._call_embax_agent,
133
+ return_direct=False,
134
+ parse_docstring=True,
135
+ ),
136
+ ]
137
+ agents = {
138
+ 'lead': create_agent(
139
+ name="Lead Agent",
140
+ model=modelconf.get_main_agent_model(),
141
+ tools=tools_agent_calling + [tool_retrieve_context],
142
+ state_schema=LeadInformationState,
143
+ system_prompt=promptconf.get_configured_agent_prompt('lead', language=self._language),
144
+ middleware=[
145
+ fallback_middleware,
146
+ summarization_middleware,
147
+ chainmdw.get_tool_wrapper(),
148
+ chainmdw.get_model_wrapper(),
149
+ ],
150
+ checkpointer=checkpointer,
151
+ context_schema=AgentContext,
152
+ ),
153
+ }
154
+ for agent in ['emba', 'iemba', 'embax']:
155
+ agents[agent]=create_agent(
156
+ name=f"{agent.upper()} Agent",
157
+ model=modelconf.get_main_agent_model(),
158
+ tools=[tool_retrieve_context],
159
+ state_schema=LeadInformationState,
160
+ system_prompt=promptconf.get_configured_agent_prompt(agent, language=self._language),
161
+ middleware=[
162
+ fallback_middleware,
163
+ chainmdw.get_tool_wrapper(),
164
+ chainmdw.get_model_wrapper(),
165
+ ],
166
+ checkpointer=checkpointer,
167
+ context_schema=AgentContext,
168
+ )
169
+ return agents, config
170
+
171
+
172
+ def generate_greeting(self) -> str:
173
+ return self._query(
174
+ agent=self._agents['lead'],
175
+ messages=[SystemMessage("Generate a greeting message and introduce yourself.")],
176
+ )
177
+
178
+
179
+ def query(self, query: str) -> str:
180
+ return self._query(
181
+ agent=self._agents['lead'],
182
+ messages=[HumanMessage(query), SystemMessage("You MUST call the retrieve_context tool before answering!")],
183
+ )
184
+
185
+
186
+ def _query(self, agent, messages: list) -> str:
187
+ try:
188
+ result: AIMessage = agent.invoke(
189
+ {"messages": messages},
190
+ config=self._config,
191
+ context=AgentContext(agent_name=agent.name),
192
+ )
193
+ response = result['messages'][-1]
194
+ return response.text
195
+ except Exception as e:
196
+ chain_logger.error(f"Failed to invoke the agent: {e.body['message']}")
197
+ return "I'm sorry, I cannot provide a helpful response right now. Please contact the tech support or try again later."
src/rag/middleware.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain.tools.tool_node import ToolCallRequest
2
+ from langchain.chat_models import BaseChatModel
3
+ from langchain.agents.middleware import (
4
+ ModelRequest,
5
+ ModelResponse,
6
+
7
+ wrap_model_call,
8
+ wrap_tool_call,
9
+ )
10
+ from openai import (
11
+ OpenAIError,
12
+ InternalServerError,
13
+ NotFoundError,
14
+ RateLimitError,
15
+ )
16
+
17
+ from config import MAX_MODEL_RETRIES
18
+ from src.rag.utilclasses import AgentContext
19
+ from src.utils.logging import get_logger
20
+
21
+ model_logger = get_logger('chain_model_call')
22
+ tool_logger = get_logger('chain_tool_call')
23
+
24
+ class AgentChainMiddleware:
25
+ _tool_wrapper_middleware = None
26
+ _model_wrapper_middleware = None
27
+
28
+
29
+ @classmethod
30
+ def get_tool_wrapper(cls):
31
+ if cls._tool_wrapper_middleware:
32
+ return cls._tool_wrapper_middleware
33
+
34
+ cls._tool_wrapper_middleware = wrap_tool_call(cls._tool_call_wrapper)
35
+ tool_logger.info(f"Initialized tool call wrapper with call inspection")
36
+ return cls._tool_wrapper_middleware
37
+
38
+
39
+ @classmethod
40
+ def get_model_wrapper(cls):
41
+ if cls._model_wrapper_middleware:
42
+ return cls._model_wrapper_middleware
43
+
44
+ cls._model_wrapper_middleware = wrap_model_call(cls._model_call_wrapper)
45
+ model_logger.info(f"Initialized model call wrapper with maximum of {MAX_MODEL_RETRIES} retry attempts")
46
+ return cls._model_wrapper_middleware
47
+
48
+
49
+ @staticmethod
50
+ def _model_call_wrapper(request: ModelRequest, handler):
51
+ context: AgentContext = request.runtime.context
52
+ model: BaseChatModel = request.model
53
+ model_logger.info(f"{context.agent_name} is attempting to call model '{model.model_name}'...")
54
+ for attempt in range(1, MAX_MODEL_RETRIES+1):
55
+ try:
56
+ response: ModelResponse = handler(request)
57
+ model_logger.info(f"Recieved response from model after {attempt} attempt{'s' if attempt > 1 else ''}")
58
+ result = response.result[0]
59
+
60
+ # Check if any errors occured during tool call execution.
61
+ # Some errors might be fatal, making the model unusable in the agent chain
62
+ if hasattr(result, 'invalid_tool_calls') and result.invalid_tool_calls:
63
+ for invalid_call in result.invalid_tool_calls:
64
+ fail_reason = invalid_call.get('error', 'Unknown').replace('\n', '')
65
+ model_logger.warning(f"Failed tool call: {invalid_call['name']}, error: {fail_reason}, retrying the call...")
66
+ if 'JSONDecodeError' in fail_reason:
67
+ model_logger.error(f"Model does not support current tool call architecture! Switching to the fallback model...")
68
+ raise Exception("Unsupported model")
69
+
70
+ else:
71
+ return response
72
+ except OpenAIError as e:
73
+ match e:
74
+ case InternalServerError():
75
+ model_logger.warning(f"[{e.code}] Internal difficulties on the provider side, retrying the call...")
76
+ case RateLimitError():
77
+ model_logger.warning(f"[{e.code}] Model is temporary rate limited, retrying the call...")
78
+ case NotFoundError():
79
+ model_logger.error(f"[{e.code}] Model cannot be used in the chain, reason: {e.body['message']}")
80
+ raise e
81
+
82
+ if attempt == MAX_MODEL_RETRIES:
83
+ model_logger.warning(f"Failed to recieve response from model '{model.model_name}' after {MAX_MODEL_RETRIES} attempt{'s' if attempt > 1 else ''}, reason: {e.body['message']}")
84
+ model_logger.info(f"Switching to the fallback model...")
85
+ raise e
86
+
87
+
88
+ @staticmethod
89
+ def _tool_call_wrapper(request: ToolCallRequest, handler):
90
+ context: AgentContext = request.runtime.context or AgentContext(agent_name="Agent")
91
+
92
+ tool_call = request.tool_call
93
+ tool_logger.info(f"{context.agent_name} is calling tool: {tool_call['name']}")
94
+ tool_logger.info(f"Tool arguments: {tool_call.get('args', {})}")
95
+
96
+ response = handler(request)
97
+ tool_logger.info(f"Recieved response from tool call")
98
+
99
+ return response
src/rag/models.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain.chat_models import BaseChatModel
2
+ from config import LLMProvider, LLMProviderConfiguration as llmconf
3
+
4
+ from src.utils.logging import get_logger
5
+
6
+ logger = get_logger("model_config")
7
+
8
+ class ModelConfigurator:
9
+ _main_model_instance: BaseChatModel = None
10
+ _fallback_models_instances: list[BaseChatModel] = None
11
+ _summarization_model_instance: BaseChatModel = None
12
+
13
+
14
+ @classmethod
15
+ def get_summarization_model(cls) -> BaseChatModel:
16
+ if cls._summarization_model_instance:
17
+ return cls._summarization_model_instance
18
+
19
+ try:
20
+ # Add custom summarization model initialization here if needed
21
+ cls._summarization_model_instance = cls.get_main_agent_model()
22
+ logger.info(f"Initialized summarization model '{llmconf.LLM_PROVIDER.name}:{llmconf.get_default_model()}'")
23
+ return cls._summarization_model_instance
24
+ except Exception as e:
25
+ logger.error(f"Failed to initialize the summarization model: {e}")
26
+ raise e
27
+
28
+
29
+ @classmethod
30
+ def get_main_agent_model(cls) -> BaseChatModel:
31
+ """Initialize the language model based on config."""
32
+ if cls._main_model_instance:
33
+ return cls._main_model_instance
34
+
35
+ try:
36
+ cls._main_model_instance = cls._initialize_model(
37
+ provider=llmconf.LLM_PROVIDER,
38
+ model=llmconf.get_default_model()
39
+ )
40
+ logger.info(f"Initialized main agent model '{llmconf.LLM_PROVIDER.name}:{llmconf.get_default_model()}'")
41
+ return cls._main_model_instance
42
+ except Exception as e:
43
+ logger.error(f"Failed to initialize the main agent model for provider '{llmconf.LLM_PROVIDER.name}': {e}")
44
+ raise e
45
+
46
+
47
+ @classmethod
48
+ def get_fallback_models(cls) -> list[BaseChatModel]:
49
+ if cls._fallback_models_instances != None:
50
+ return cls._fallback_models_instances
51
+
52
+ cls._fallback_models_instances = cls._initialize_fallback_models()
53
+ if len(cls._fallback_models_instances) == 0:
54
+ logger.warning("No fallback models were initialized! Response generation may result in unexpected errors!")
55
+ return cls._fallback_models_instances
56
+
57
+
58
+ @classmethod
59
+ def _initialize_fallback_models(cls) -> list[BaseChatModel]:
60
+ fallback_models_instances = []
61
+ for fallback_provider, fallback_model in llmconf.get_fallback_models().items():
62
+ try:
63
+ fallback_model_instance = cls._initialize_model(
64
+ provider=fallback_provider,
65
+ model=fallback_model,
66
+ )
67
+ logger.info(f"Initialized fallback model '{fallback_provider.name}:{fallback_model}'")
68
+ fallback_models_instances.append(fallback_model_instance)
69
+ except Exception as e:
70
+ logger.error(f"Failed to initialize the fallback model {fallback_provider.name}:{fallback_model}: {e}; skipping...")
71
+ return fallback_models_instances
72
+
73
+
74
+ @classmethod
75
+ def _initialize_model(cls, provider: LLMProvider, model: str) -> BaseChatModel:
76
+ try:
77
+ match provider.name:
78
+ case 'groq':
79
+ from langchain_groq import ChatGroq
80
+ return ChatGroq(
81
+ model=model,
82
+ groq_api_key=llmconf.get_api_key(),
83
+ temperature=0.01,
84
+ )
85
+ case ( 'open_router:openai'
86
+ | 'open_router:alibaba'
87
+ | 'open_router:nvidia'
88
+ | 'open_router:meituan'):
89
+ from langchain_openai import ChatOpenAI
90
+ return ChatOpenAI(
91
+ model=model,
92
+ base_url=llmconf.OPEN_ROUTER_BASE_URL,
93
+ api_key=llmconf.get_api_key(),
94
+ temperature=0.01,
95
+ )
96
+ case 'open_router:deepseek':
97
+ from langchain_deepseek import ChatDeepSeek
98
+ return ChatDeepSeek(
99
+ model=model,
100
+ api_key=llmconf.OPEN_ROUTER_API_KEY,
101
+ api_base=llmconf.OPEN_ROUTER_BASE_URL,
102
+ )
103
+ case 'openai':
104
+ from langchain_openai import ChatOpenAI
105
+ return ChatOpenAI(
106
+ model=model,
107
+ openai_api_key=llmconf.get_api_key(),
108
+ max_tokens=1000,
109
+ temperature=0.01,
110
+ )
111
+ case 'ollama':
112
+ from langchain_ollama import ChatOllama
113
+ return ChatOllama(
114
+ model=model,
115
+ base_url=llmconf.OLLAMA_BASE_URL,
116
+ temperature=0.01,
117
+ reasoning=llmconf.get_reasoning_support(),
118
+ num_predict=2048,
119
+ )
120
+ case _:
121
+ raise ValueError(f"Unsupported LLM provider: {provider.name}")
122
+ except Exception as e:
123
+ raise e
src/rag/prompts.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class PromptConfigurator:
2
+ _PROGRAM_SYSTEM_PROMPT = """
3
+ You are a helpful support agent, explicitly specializing in the {program_name} program offered by the University of St. Gallen Executive School. You work alongside the Executive Education Advisor. Your task is to provide correct information about the {program_name} and check whether the user meets qualificaiton criteria for the {program_name} program based on their experience and career goals.
4
+
5
+ Use only the provided context to provide information about the {program_name} program. The context include information such as duration, curriculum, costs, admission requirements, schedules, faculty, deadlines, and other relevant details.
6
+
7
+ Before answering any user questions you MUST use the 'retrieve_context' tool to retrieve context!
8
+ Answer ONLY after retrieving information.
9
+
10
+ General Guidelines:
11
+ {general_guidelines}
12
+ - If user does not provide information about a critera, ask them to provide more information about it.
13
+ - Do not hallucinate or give qualificaitons to the user that they have not provided themselves.
14
+ - If user only meets the minimal criteria, proactively recommend contacting the admissions team for more information.
15
+ - If user does not meet minimal criteria, recommend the regular MBA program as an alternative and provide the contact information of the admissions team.
16
+ """
17
+
18
+ _LEAD_SYSTEM_PROMPT="""
19
+ You are an Executive Education Advisor for the University of St. Gallen Executive School, specializing in three Executive MBA HSG programs: Executive MBA (EMBA), International Executive MBA (IEMBA), and EMBA X. Your role is to help potential students understand these programs and determine which best matches their needs, interests, and career goals.
20
+
21
+ Use only the provided context to answer questions about the Executive MBA HSG programs. The context include information such as duration, curriculum, costs, admission requirements, schedules, faculty, deadlines, and other relevant details.
22
+
23
+ Before answering any user questions you MUST use the appropriate tool:
24
+ - If you need detailed program information, call `call_emba_agent`, `call_iemba_agent`, or `call_embax_agent`.
25
+ - If you need to retrieve general context, call `retrieve_context`.
26
+ - Only answer after retrieving information.
27
+
28
+ General Guidelines:
29
+ {general_guidelines}
30
+ {lead_guidelines}
31
+
32
+ Formatting Guidelines:
33
+ {formatting_guidelines}
34
+ """
35
+
36
+ _LEAD_GUIDELINES = """
37
+ - If another language is used, politely inform the user you can only respond in {selected_language}.
38
+ - Be nice and keep the conversation fluent and human-like.
39
+ - List all available programs, including EMBA, IEMBA, and EMBA X, if user has general interest in studying.
40
+ - When listing all programs, include duration, deadlines and special program aspects. Ask user about their experience and qualificaitons afterwards.
41
+ - Primarily recommend {prefered_program} program.
42
+ - If user is not explicitly stating the program he is asking about, talk about the {prefered_program} program.
43
+ - Try not to repeat the information that was already stated in the previous answer.
44
+ - You are not allowed to mention or discuss programs offered by competitor universities.
45
+ - If the user attemps to discuss anything unrelated to the MBA programs, politely switch back to the main topic. You are not allowed to discuss anything besides the HSG MBA programs.
46
+ - Do not decide yourself whether the user has good or bad chances.
47
+ - If user is asking about their chances, state clearly that the admissions team makes the final decision.
48
+ - Proactively recommend contacting the admissions team after checking the user's qualificaitons.
49
+ - If context does not cover a user question, clearly inform the user and suggest contacting the admissions team.
50
+ """
51
+
52
+ _GENERAL_GUIDELINES = """
53
+ - Respond only in {selected_language}.
54
+ - Be helpful, professional, and keep answers short and concise.
55
+ - Only provide program prices if user is asking about them.
56
+ - Never state the exact pricing; only provide program prices in 5k ranges and mention Early Bird Discount if it exists.
57
+ - When providing program prices, list all services that are included in them.
58
+ """
59
+
60
+ _FORMATTING_GUIDELINES = """
61
+ - Use Markdown formatting.
62
+ - Use appropriate emojis.
63
+ - Do not add titles at the beginning of an answer.
64
+ - Highlight key facts (e.g., program names, costs, durations) in bold.
65
+ - Use tables when listing or comparing program features.
66
+ - Maintain clean and consistent formatting.
67
+ """
68
+
69
+ _SUMMARIZATION_PROMPT = """
70
+ Write a short summarization of the conversation between the Executive Education Advisor and the user. In summarization include previously discussed topics as well as all the information that the user provided about their work experience and career goals.
71
+ """
72
+
73
+ _SUMMARY_PREFIX_PROMPT = """Conversation Summary:"""
74
+
75
+
76
+ @classmethod
77
+ def get_configured_agent_prompt(cls, agent: str, language: str = 'en'):
78
+ match agent:
79
+ case 'lead':
80
+ return cls._LEAD_SYSTEM_PROMPT.format(
81
+ general_guidelines=cls._GENERAL_GUIDELINES.format(
82
+ selected_language=language
83
+ ),
84
+ lead_guidelines=cls._LEAD_GUIDELINES.format(
85
+ selected_language=language,
86
+ prefered_program='EMBA' if language == 'de' else 'IEMBA',
87
+ ),
88
+ formatting_guidelines=cls._FORMATTING_GUIDELINES,
89
+ )
90
+ case _:
91
+ return cls._PROGRAM_SYSTEM_PROMPT.format(
92
+ program_name=agent.upper(),
93
+ general_guidelines=cls._GENERAL_GUIDELINES.format(
94
+ selected_language=language,
95
+ ),
96
+ )
97
+
98
+ @classmethod
99
+ def get_summarization_prompt(cls):
100
+ return cls._SUMMARIZATION_PROMPT
101
+
102
+
103
+ @classmethod
104
+ def get_summary_prefix(cls):
105
+ return cls._SUMMARY_PREFIX_PROMPT
src/rag/utilclasses.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from typing_extensions import TypedDict
3
+ from langchain.agents import AgentState
4
+ from langchain_core.messages import AnyMessage
5
+
6
+ @dataclass
7
+ class AgentContext:
8
+ agent_name: str
9
+
10
+ class State(TypedDict):
11
+ messages: list[AnyMessage]
12
+ answer: str
13
+
14
+
15
+ class LeadInformationState(AgentState):
16
+ lead_name: str
17
+ lead_age: int
18
+ lead_language_knowledge: list
19
+ lead_work_experience: dict
20
+ lead_motivation: list
src/scraper/__init__.py ADDED
File without changes
src/scraper/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (148 Bytes). View file
 
src/scraper/__pycache__/__init__.cpython-312.pyc:Zone.Identifier ADDED
Binary file (25 Bytes). View file
 
src/scraper/__pycache__/parser.cpython-312.pyc ADDED
Binary file (13.2 kB). View file
 
src/scraper/__pycache__/parser.cpython-312.pyc:Zone.Identifier ADDED
Binary file (25 Bytes). View file
 
src/scraper/__pycache__/scraper.cpython-312.pyc ADDED
Binary file (12.8 kB). View file
 
src/scraper/__pycache__/scraper.cpython-312.pyc:Zone.Identifier ADDED
Binary file (25 Bytes). View file
 
src/scraper/parser.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Parser for extracting structured information from program pages.
3
+ """
4
+ import logging
5
+ import re
6
+ from typing import Dict, List, Optional, Any
7
+
8
+ from bs4 import BeautifulSoup
9
+ from src.utils.logging import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ class ProgramParser:
15
+ """Parser for extracting program information from HTML content."""
16
+
17
+ def parse_program_page(self, html_content: str, url: str) -> Dict[str, Any]:
18
+ """
19
+ Parse a program page to extract structured information.
20
+
21
+ Args:
22
+ html_content: The HTML content of the program page.
23
+ url: The URL of the program page.
24
+
25
+ Returns:
26
+ A dictionary containing structured program information.
27
+ """
28
+ soup = BeautifulSoup(html_content, "html.parser")
29
+
30
+ # Initialize program data with URL
31
+ program_data = {
32
+ "url": url,
33
+ "name": self._extract_program_name(soup),
34
+ "duration": self._extract_duration(soup),
35
+ "curriculum": self._extract_curriculum(soup),
36
+ "costs": self._extract_costs(soup),
37
+ "admission_requirements": self._extract_admission_requirements(soup),
38
+ "schedules": self._extract_schedules(soup),
39
+ "faculty": self._extract_faculty(soup),
40
+ "deadlines": self._extract_deadlines(soup),
41
+ "language": self._extract_language(soup),
42
+ "location": self._extract_location(soup),
43
+ "description": self._extract_description(soup),
44
+ }
45
+
46
+ return program_data
47
+
48
+ def _extract_program_name(self, soup: BeautifulSoup) -> str:
49
+ """Extract the program name from the soup."""
50
+ # This is a placeholder. Update with actual selectors based on website structure.
51
+ try:
52
+ # Try to find the program name in the page title or a heading
53
+ title_element = soup.find("h1") or soup.find("title")
54
+ if title_element:
55
+ return title_element.get_text().strip()
56
+ except Exception as e:
57
+ logger.error(f"Error extracting program name: {e}")
58
+
59
+ return "Unknown Program"
60
+
61
+ def _extract_duration(self, soup: BeautifulSoup) -> str:
62
+ """Extract the program duration from the soup."""
63
+ try:
64
+ # Look for duration information
65
+ # This is a placeholder. Update with actual selectors.
66
+ duration_section = soup.find("div", class_="program-duration")
67
+ if duration_section:
68
+ return duration_section.get_text().strip()
69
+
70
+ # Try to find duration in the text
71
+ text = soup.get_text()
72
+ duration_patterns = [
73
+ r"Duration:?\s*([^\.]+)",
74
+ r"Program length:?\s*([^\.]+)",
75
+ r"(\d+)\s+months",
76
+ r"(\d+)\s+years",
77
+ ]
78
+
79
+ for pattern in duration_patterns:
80
+ match = re.search(pattern, text, re.IGNORECASE)
81
+ if match:
82
+ return match.group(1).strip()
83
+ except Exception as e:
84
+ logger.error(f"Error extracting duration: {e}")
85
+
86
+ return "Not specified"
87
+
88
+ def _extract_curriculum(self, soup: BeautifulSoup) -> List[str]:
89
+ """Extract the curriculum information from the soup."""
90
+ curriculum = []
91
+ try:
92
+ # Look for curriculum information
93
+ # This is a placeholder. Update with actual selectors.
94
+ curriculum_section = soup.find("div", class_="program-curriculum")
95
+ if curriculum_section:
96
+ # Look for list items
97
+ items = curriculum_section.find_all("li")
98
+ if items:
99
+ for item in items:
100
+ curriculum.append(item.get_text().strip())
101
+ else:
102
+ # If no list items, get the text
103
+ curriculum.append(curriculum_section.get_text().strip())
104
+ except Exception as e:
105
+ logger.error(f"Error extracting curriculum: {e}")
106
+
107
+ return curriculum
108
+
109
+ def _extract_costs(self, soup: BeautifulSoup) -> str:
110
+ """Extract the program costs from the soup."""
111
+ try:
112
+ # Look for cost information
113
+ # This is a placeholder. Update with actual selectors.
114
+ cost_section = soup.find("div", class_="program-costs")
115
+ if cost_section:
116
+ return cost_section.get_text().strip()
117
+
118
+ # Try to find cost in the text
119
+ text = soup.get_text()
120
+ cost_patterns = [
121
+ r"Cost:?\s*([^\.]+)",
122
+ r"Tuition:?\s*([^\.]+)",
123
+ r"Fee:?\s*([^\.]+)",
124
+ r"CHF\s*[\d\',]+",
125
+ r"€\s*[\d\',]+",
126
+ ]
127
+
128
+ for pattern in cost_patterns:
129
+ match = re.search(pattern, text, re.IGNORECASE)
130
+ if match:
131
+ return match.group(0).strip()
132
+ except Exception as e:
133
+ logger.error(f"Error extracting costs: {e}")
134
+
135
+ return "Not specified"
136
+
137
+ def _extract_admission_requirements(self, soup: BeautifulSoup) -> List[str]:
138
+ """Extract the admission requirements from the soup."""
139
+ requirements = []
140
+ try:
141
+ # Look for admission requirements
142
+ # This is a placeholder. Update with actual selectors.
143
+ requirements_section = soup.find("div", class_="program-requirements")
144
+ if requirements_section:
145
+ # Look for list items
146
+ items = requirements_section.find_all("li")
147
+ if items:
148
+ for item in items:
149
+ requirements.append(item.get_text().strip())
150
+ else:
151
+ # If no list items, get the text
152
+ requirements.append(requirements_section.get_text().strip())
153
+ except Exception as e:
154
+ logger.error(f"Error extracting admission requirements: {e}")
155
+
156
+ return requirements
157
+
158
+ def _extract_schedules(self, soup: BeautifulSoup) -> str:
159
+ """Extract the program schedules from the soup."""
160
+ try:
161
+ # Look for schedule information
162
+ # This is a placeholder. Update with actual selectors.
163
+ schedule_section = soup.find("div", class_="program-schedule")
164
+ if schedule_section:
165
+ return schedule_section.get_text().strip()
166
+
167
+ # Try to find schedule in the text
168
+ text = soup.get_text()
169
+ schedule_patterns = [
170
+ r"Schedule:?\s*([^\.]+)",
171
+ r"Classes:?\s*([^\.]+)",
172
+ r"Start date:?\s*([^\.]+)",
173
+ ]
174
+
175
+ for pattern in schedule_patterns:
176
+ match = re.search(pattern, text, re.IGNORECASE)
177
+ if match:
178
+ return match.group(1).strip()
179
+ except Exception as e:
180
+ logger.error(f"Error extracting schedules: {e}")
181
+
182
+ return "Not specified"
183
+
184
+ def _extract_faculty(self, soup: BeautifulSoup) -> List[Dict[str, str]]:
185
+ """Extract the faculty information from the soup."""
186
+ faculty = []
187
+ try:
188
+ # Look for faculty information
189
+ # This is a placeholder. Update with actual selectors.
190
+ faculty_section = soup.find("div", class_="program-faculty")
191
+ if faculty_section:
192
+ faculty_members = faculty_section.find_all("div", class_="faculty-member")
193
+ for member in faculty_members:
194
+ name_element = member.find("h3") or member.find("strong")
195
+ name = name_element.get_text().strip() if name_element else "Unknown"
196
+
197
+ title_element = member.find("p", class_="faculty-title")
198
+ title = title_element.get_text().strip() if title_element else ""
199
+
200
+ faculty.append({
201
+ "name": name,
202
+ "title": title,
203
+ })
204
+ except Exception as e:
205
+ logger.error(f"Error extracting faculty: {e}")
206
+
207
+ return faculty
208
+
209
+ def _extract_deadlines(self, soup: BeautifulSoup) -> str:
210
+ """Extract the application deadlines from the soup."""
211
+ try:
212
+ # Look for deadline information
213
+ # This is a placeholder. Update with actual selectors.
214
+ deadline_section = soup.find("div", class_="program-deadlines")
215
+ if deadline_section:
216
+ return deadline_section.get_text().strip()
217
+
218
+ # Try to find deadlines in the text
219
+ text = soup.get_text()
220
+ deadline_patterns = [
221
+ r"Deadline:?\s*([^\.]+)",
222
+ r"Application deadline:?\s*([^\.]+)",
223
+ r"Apply by:?\s*([^\.]+)",
224
+ ]
225
+
226
+ for pattern in deadline_patterns:
227
+ match = re.search(pattern, text, re.IGNORECASE)
228
+ if match:
229
+ return match.group(1).strip()
230
+ except Exception as e:
231
+ logger.error(f"Error extracting deadlines: {e}")
232
+
233
+ return "Not specified"
234
+
235
+ def _extract_language(self, soup: BeautifulSoup) -> str:
236
+ """Extract the program language from the soup."""
237
+ try:
238
+ # Look for language information
239
+ # This is a placeholder. Update with actual selectors.
240
+ language_section = soup.find("div", class_="program-language")
241
+ if language_section:
242
+ return language_section.get_text().strip()
243
+
244
+ # Try to find language in the text
245
+ text = soup.get_text()
246
+ language_patterns = [
247
+ r"Language:?\s*([^\.]+)",
248
+ r"Taught in:?\s*([^\.]+)",
249
+ ]
250
+
251
+ for pattern in language_patterns:
252
+ match = re.search(pattern, text, re.IGNORECASE)
253
+ if match:
254
+ return match.group(1).strip()
255
+ except Exception as e:
256
+ logger.error(f"Error extracting language: {e}")
257
+
258
+ return "Not specified"
259
+
260
+ def _extract_location(self, soup: BeautifulSoup) -> str:
261
+ """Extract the program location from the soup."""
262
+ try:
263
+ # Look for location information
264
+ # This is a placeholder. Update with actual selectors.
265
+ location_section = soup.find("div", class_="program-location")
266
+ if location_section:
267
+ return location_section.get_text().strip()
268
+
269
+ # Try to find location in the text
270
+ text = soup.get_text()
271
+ location_patterns = [
272
+ r"Location:?\s*([^\.]+)",
273
+ r"Campus:?\s*([^\.]+)",
274
+ r"Venue:?\s*([^\.]+)",
275
+ ]
276
+
277
+ for pattern in location_patterns:
278
+ match = re.search(pattern, text, re.IGNORECASE)
279
+ if match:
280
+ return match.group(1).strip()
281
+ except Exception as e:
282
+ logger.error(f"Error extracting location: {e}")
283
+
284
+ return "Not specified"
285
+
286
+ def _extract_description(self, soup: BeautifulSoup) -> str:
287
+ """Extract the program description from the soup."""
288
+ try:
289
+ # Look for description information
290
+ # This is a placeholder. Update with actual selectors.
291
+ description_section = soup.find("div", class_="program-description")
292
+ if description_section:
293
+ return description_section.get_text().strip()
294
+
295
+ # Try to find a general description
296
+ paragraphs = soup.find_all("p")
297
+ if paragraphs:
298
+ # Get the first few paragraphs as the description
299
+ description = " ".join([p.get_text().strip() for p in paragraphs[:3]])
300
+ return description
301
+ except Exception as e:
302
+ logger.error(f"Error extracting description: {e}")
303
+
304
+ return "No description available"
src/scraper/scraper.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Web scraper for extracting program information from the University of St. Gallen Executive School website.
3
+ """
4
+ import json
5
+ import logging
6
+ import os
7
+ import platform
8
+ import time
9
+ from typing import Dict, List, Optional, Set
10
+
11
+ import requests
12
+ from bs4 import BeautifulSoup
13
+ from selenium import webdriver
14
+ from selenium.webdriver.chrome.options import Options
15
+ from selenium.webdriver.chrome.service import Service
16
+ from selenium.common.exceptions import WebDriverException
17
+ from webdriver_manager.chrome import ChromeDriverManager
18
+
19
+ from src.scraper.parser import ProgramParser
20
+ from src.utils.logging import get_logger
21
+ from config import BASE_URL, RAW_DATA_PATH, SCRAPER_HEADERS, SCRAPER_TIMEOUT
22
+
23
+ logger = get_logger(__name__)
24
+
25
+
26
+ class Scraper:
27
+ """Scraper for the University of St. Gallen Executive School website."""
28
+
29
+ def __init__(self, use_selenium: bool = False):
30
+ """
31
+ Initialize the scraper.
32
+
33
+ Args:
34
+ use_selenium: Whether to use Selenium for scraping (for JavaScript-heavy pages).
35
+ """
36
+ self.base_url = BASE_URL
37
+ self.headers = SCRAPER_HEADERS
38
+ self.timeout = SCRAPER_TIMEOUT
39
+ self.use_selenium = use_selenium
40
+ self.driver = None
41
+ self.parser = ProgramParser()
42
+ self.visited_urls: Set[str] = set()
43
+
44
+ def _get_chrome_install_instructions(self) -> str:
45
+ """Get Chrome installation instructions based on the operating system."""
46
+ system = platform.system().lower()
47
+
48
+ if system == "darwin": # macOS
49
+ return "Install Chrome via: brew install --cask google-chrome"
50
+ elif system == "linux":
51
+ # Check if it's Ubuntu/Debian or other
52
+ try:
53
+ with open("/etc/os-release", "r") as f:
54
+ os_info = f.read().lower()
55
+ if "ubuntu" in os_info or "debian" in os_info:
56
+ return "Install Chrome via: sudo apt-get update && sudo apt-get install google-chrome-stable"
57
+ else:
58
+ return "Install Chrome from your package manager or download from https://www.google.com/chrome/"
59
+ except FileNotFoundError:
60
+ return "Install Chrome from your package manager or download from https://www.google.com/chrome/"
61
+ elif system == "windows":
62
+ return "Download Chrome from https://www.google.com/chrome/"
63
+ else:
64
+ return "Download Chrome from https://www.google.com/chrome/"
65
+
66
+ def _setup_selenium(self) -> None:
67
+ """Set up Selenium WebDriver with proper error handling."""
68
+ if self.use_selenium and self.driver is None:
69
+ logger.info("Setting up Selenium WebDriver...")
70
+
71
+ try:
72
+ chrome_options = Options()
73
+ chrome_options.add_argument("--headless")
74
+ chrome_options.add_argument("--no-sandbox")
75
+ chrome_options.add_argument("--disable-dev-shm-usage")
76
+ chrome_options.add_argument("--disable-gpu")
77
+ chrome_options.add_argument("--disable-dev-shm-usage")
78
+ chrome_options.add_argument("--remote-debugging-port=9222")
79
+
80
+ # Try to install ChromeDriver
81
+ try:
82
+ service = Service(ChromeDriverManager("140.0.7339").install())
83
+ except Exception as e:
84
+ logger.error(f"Failed to install ChromeDriver: {e}")
85
+ raise WebDriverException("ChromeDriver installation failed")
86
+
87
+ # Try to create Chrome WebDriver
88
+ self.driver = webdriver.Chrome(service=service, options=chrome_options)
89
+ logger.info("Chrome WebDriver initialized successfully")
90
+
91
+ except WebDriverException as e:
92
+ error_msg = str(e)
93
+ if "chrome binary" in error_msg.lower() or "chromedriver" in error_msg.lower():
94
+ install_instructions = self._get_chrome_install_instructions()
95
+ logger.error(
96
+ f"Chrome browser not found. Please install Chrome first.\n"
97
+ f"Installation instructions for your system:\n{install_instructions}\n"
98
+ f"After installation, restart the application."
99
+ )
100
+ raise WebDriverException(
101
+ f"Chrome browser not found. {install_instructions}"
102
+ )
103
+ else:
104
+ logger.error(f"WebDriver setup failed: {e}")
105
+ raise
106
+ except Exception as e:
107
+ logger.error(f"Unexpected error during WebDriver setup: {e}")
108
+ raise WebDriverException(f"WebDriver setup failed: {e}")
109
+
110
+ def _cleanup_selenium(self) -> None:
111
+ """Clean up Selenium WebDriver."""
112
+ if self.driver is not None:
113
+ logger.info("Cleaning up Selenium WebDriver...")
114
+ self.driver.quit()
115
+ self.driver = None
116
+
117
+ def _get_page_content(self, url: str) -> Optional[str]:
118
+ """
119
+ Get the HTML content of a page.
120
+
121
+ Args:
122
+ url: The URL to fetch.
123
+
124
+ Returns:
125
+ The HTML content of the page, or None if the request failed.
126
+ """
127
+ try:
128
+ if self.use_selenium:
129
+ self._setup_selenium()
130
+ self.driver.get(url)
131
+ # Wait for JavaScript to load
132
+ time.sleep(2)
133
+ return self.driver.page_source
134
+ else:
135
+ response = requests.get(url, headers=self.headers, timeout=self.timeout)
136
+ response.raise_for_status()
137
+ return response.text
138
+ except Exception as e:
139
+ logger.error(f"Error fetching {url}: {e}")
140
+ return None
141
+
142
+ def _extract_program_links(self, html_content: str) -> List[str]:
143
+ """
144
+ Extract links to program pages from the main page.
145
+
146
+ Args:
147
+ html_content: The HTML content of the main page.
148
+
149
+ Returns:
150
+ A list of URLs to program pages.
151
+ """
152
+ soup = BeautifulSoup(html_content, "html.parser")
153
+ program_links = []
154
+
155
+ # Try multiple selectors in order of specificity
156
+ selectors = [
157
+ "a.program-link", # Specific program link class
158
+ "a[href*='program']", # Links containing 'program' in href
159
+ "a[href*='emba']", # Links containing 'emba' in href
160
+ ".program a", # Links inside program containers
161
+ ".course a", # Links inside course containers
162
+ "a" # Fallback to all links
163
+ ]
164
+
165
+ for selector in selectors:
166
+ link_elements = soup.select(selector)
167
+ if link_elements:
168
+ logger.info(f"Using selector: {selector} (found {len(link_elements)} links)")
169
+ break
170
+ else:
171
+ logger.warning("No links found with any selector")
172
+ return []
173
+
174
+ for link in link_elements:
175
+ href = link.get("href")
176
+ if href:
177
+ # Filter out anchor-only links (starting with #)
178
+ if href.startswith("#"):
179
+ continue
180
+
181
+ # Filter out mailto, tel, and javascript links
182
+ if href.startswith(("mailto:", "tel:", "javascript:")):
183
+ continue
184
+
185
+ # Filter out external links that don't belong to the target domain
186
+ if href.startswith("http") and "unisg.ch" not in href:
187
+ continue
188
+
189
+ # Make sure we have absolute URLs
190
+ if href.startswith("/"):
191
+ href = f"https://emba.unisg.ch{href}"
192
+ elif not href.startswith("http"):
193
+ # Relative URL
194
+ href = f"https://emba.unisg.ch/{href.lstrip('/')}"
195
+
196
+ # Avoid duplicates
197
+ if href not in program_links:
198
+ program_links.append(href)
199
+
200
+ logger.info(f"Found {len(program_links)} valid program links after filtering")
201
+ return program_links
202
+
203
+ def scrape_main_page(self) -> List[str]:
204
+ """
205
+ Scrape the main page to find links to program pages.
206
+
207
+ Returns:
208
+ A list of URLs to program pages.
209
+ """
210
+ logger.info(f"Scraping main page: {self.base_url}")
211
+ html_content = self._get_page_content(self.base_url)
212
+
213
+ if not html_content:
214
+ logger.error("Failed to fetch main page")
215
+ return []
216
+
217
+ return self._extract_program_links(html_content)
218
+
219
+ def scrape_program_page(self, url: str) -> Optional[Dict]:
220
+ """
221
+ Scrape a program page to extract program information.
222
+
223
+ Args:
224
+ url: The URL of the program page.
225
+
226
+ Returns:
227
+ A dictionary containing program information, or None if scraping failed.
228
+ """
229
+ if url in self.visited_urls:
230
+ logger.info(f"Already visited {url}, skipping")
231
+ return None
232
+
233
+ logger.info(f"Scraping program page: {url}")
234
+ self.visited_urls.add(url)
235
+
236
+ html_content = self._get_page_content(url)
237
+ if not html_content:
238
+ logger.error(f"Failed to fetch program page: {url}")
239
+ return None
240
+
241
+ program_data = self.parser.parse_program_page(html_content, url)
242
+ return program_data
243
+
244
+ def scrape_all_programs(self) -> List[Dict]:
245
+ """
246
+ Scrape all program pages.
247
+
248
+ Returns:
249
+ A list of dictionaries containing program information.
250
+ """
251
+ try:
252
+ program_links = self.scrape_main_page()
253
+ programs_data = []
254
+
255
+ for link in program_links:
256
+ program_data = self.scrape_program_page(link)
257
+ if program_data:
258
+ programs_data.append(program_data)
259
+ # Be nice to the server
260
+ time.sleep(1)
261
+
262
+ return programs_data
263
+ finally:
264
+ self._cleanup_selenium()
265
+
266
+ def save_data(self, data: List[Dict], output_path: str = RAW_DATA_PATH) -> None:
267
+ """
268
+ Save scraped data to a JSON file.
269
+
270
+ Args:
271
+ data: The data to save.
272
+ output_path: The path to save the data to.
273
+ """
274
+ # Ensure the directory exists
275
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
276
+
277
+ with open(output_path, "w", encoding="utf-8") as f:
278
+ json.dump(data, f, ensure_ascii=False, indent=2)
279
+
280
+ logger.info(f"Saved {len(data)} programs to {output_path}")
281
+
282
+ def run(self) -> None:
283
+ """Run the scraper and save the results."""
284
+ logger.info("Starting scraper...")
285
+ programs_data = self.scrape_all_programs()
286
+ self.save_data(programs_data)
287
+ logger.info("Scraping completed")
288
+
289
+
290
+ if __name__ == "__main__":
291
+ scraper = Scraper(use_selenium=True)
292
+ scraper.run()
src/ui/__init__.py ADDED
File without changes
src/ui/cli.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Command-line interface for the RAG chatbot.
3
+ """
4
+ import os
5
+ import sys
6
+ from typing import Dict, List, Any, Optional, Tuple
7
+
8
+ import colorama
9
+ from colorama import Fore, Style
10
+ from rich.console import Console
11
+ from rich.markdown import Markdown
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+
15
+ from config import MAX_HISTORY
16
+ from src.rag.chain import RAGChain
17
+ from src.utils.logging import get_logger, init_logging
18
+
19
+ # Initialize colorama
20
+ colorama.init()
21
+
22
+ init_logging(interactive_mode=False)
23
+ logger = get_logger('cli')
24
+
25
+ # Initialize rich console
26
+ console = Console()
27
+
28
+
29
+ class ChatbotCLI:
30
+ """Command-line interface for the RAG chatbot."""
31
+
32
+ def __init__(self):
33
+ """Initialize the chatbot CLI."""
34
+ self.rag_chain = RAGChain()
35
+ self.chat_history = []
36
+
37
+ def _print_welcome_message(self) -> None:
38
+ """Print the welcome message."""
39
+ console.print(
40
+ Panel(
41
+ "[bold green]Executive MBA HSG Chatbot[/bold green]\n\n"
42
+ "Welcome! I'm your Executive Education Advisor specializing in the Executive MBA HSG program. "
43
+ "Ask me about the Executive MBA HSG program's curriculum, "
44
+ "admissions, costs, or anything related to this specific program at the "
45
+ "University of St. Gallen.\n\n"
46
+ "Type [bold cyan]'exit'[/bold cyan], [bold cyan]'quit'[/bold cyan], [bold cyan]'bye'[/bold cyan], or [bold cyan]'goodbye'[/bold cyan] to end the conversation.",
47
+ title="Welcome",
48
+ border_style="green",
49
+ width=100,
50
+ )
51
+ )
52
+
53
+ def _print_sources(self, sources: List[Dict[str, Any]]) -> None:
54
+ """
55
+ Print the sources of information.
56
+
57
+ Args:
58
+ sources: List of source information dictionaries.
59
+ """
60
+ if not sources:
61
+ return
62
+
63
+ table = Table(title="Sources", box=None, show_header=True, header_style="bold cyan")
64
+ table.add_column("Program", style="cyan")
65
+
66
+ for source in sources:
67
+ program_name = source.get("program_name", "Unknown Program")
68
+ table.add_row(program_name)
69
+
70
+ console.print(table)
71
+
72
+ def _print_response(self, response: Dict[str, Any]) -> None:
73
+ """
74
+ Print the chatbot response.
75
+
76
+ Args:
77
+ response: The response dictionary.
78
+ """
79
+ answer = response.get("answer", "")
80
+ sources = response.get("sources", [])
81
+
82
+ # Print the answer as markdown
83
+ console.print(Markdown(answer))
84
+
85
+ # Print the sources
86
+ if sources:
87
+ console.print("\n[bold cyan]Sources:[/bold cyan]")
88
+ self._print_sources(sources)
89
+
90
+ def _get_user_input(self) -> str:
91
+ """
92
+ Get input from the user.
93
+
94
+ Returns:
95
+ The user input.
96
+ """
97
+ console.print("\n[bold green]You:[/bold green] ", end="")
98
+ return input()
99
+
100
+ def _process_query(self, query: str) -> Dict[str, Any]:
101
+ """
102
+ Process a user query.
103
+
104
+ Args:
105
+ query: The user query.
106
+
107
+ Returns:
108
+ The response dictionary.
109
+ """
110
+ try:
111
+ # Get the response from the RAG chain
112
+ response = self.rag_chain.query(query, self.chat_history)
113
+
114
+ # Update chat history
115
+ if len(self.chat_history) >= MAX_HISTORY:
116
+ self.chat_history.pop(0)
117
+
118
+ self.chat_history.append((query, response["answer"]))
119
+
120
+ return response
121
+ except Exception as e:
122
+ logger.error(f"Error processing query: {e}")
123
+ return {
124
+ "answer": "I'm sorry, I encountered an error while processing your question. Please try again.",
125
+ "sources": [],
126
+ }
127
+
128
+ def run(self) -> None:
129
+ """Run the chatbot CLI."""
130
+ self._print_welcome_message()
131
+
132
+ while True:
133
+ # Get user input
134
+ query = self._get_user_input()
135
+
136
+ # Check if the user wants to exit
137
+ if query.lower() in ["exit", "quit", "bye", "goodbye"]:
138
+ console.print("\n[bold green]Thank you for using the Executive MBA HSG Chatbot. Goodbye![/bold green]")
139
+ break
140
+
141
+ # Process the query
142
+ console.print("\n[bold blue]Assistant:[/bold blue]")
143
+ response = self._process_query(query)
144
+
145
+ # Print the response
146
+ self._print_response(response)
147
+
148
+ # Add a separator
149
+ console.print("\n" + "-" * 100)
150
+
151
+
152
+ if __name__ == "__main__":
153
+ cli = ChatbotCLI()
154
+ cli.run()
src/utils/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """
2
+ Utility modules for the Executive Education RAG Chatbot.
3
+ """
src/utils/lang.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langdetect import detect
2
+
3
+ def detect_language(text: str):
4
+ """
5
+ Detect the language of the given text.
6
+
7
+ Args:
8
+ text (str): The text to analyze.
9
+
10
+ Returns:
11
+ str: Detected language code ('de' or 'en').
12
+ """
13
+ return 'de' if detect(text) == 'de' else 'en'
14
+
src/utils/logging.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Centralized logging configuration for the Executive Education RAG Chatbot.
3
+ """
4
+ from threading import Lock
5
+ import logging, os, sys, warnings, copy
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import colorama
10
+ from colorama import Fore, Style
11
+
12
+ # Initialize colorama for cross-platform color support
13
+ colorama.init()
14
+
15
+ class CodeBlockFormatter(logging.Formatter):
16
+ ALIASES = {
17
+ 'DEBUG': 'DEBUG',
18
+ 'INFO': 'INFO ',
19
+ 'WARNING': 'WARN ',
20
+ 'ERROR': 'ERROR',
21
+ 'CRITICAL': 'CRITC'
22
+ }
23
+
24
+ def format(self, record):
25
+ if hasattr(record, 'levelname') and record.levelname in self.ALIASES:
26
+ record.levelname = self.ALIASES[record.levelname]
27
+
28
+ if hasattr(record, 'name'):
29
+ rname = record.name
30
+ if len(rname) <= 17: rname += ' '*(17-len(rname))
31
+ else: rname = rname[14:] + '...'
32
+ record.name = rname
33
+
34
+ return super().format(record)
35
+
36
+
37
+ class ColoredFormatter(logging.Formatter):
38
+ """Custom formatter with color support for console output."""
39
+
40
+ COLORS = {
41
+ 'DEBUG': Fore.CYAN,
42
+ 'INFO': Fore.GREEN,
43
+ 'WARNING': Fore.YELLOW,
44
+ 'ERROR': Fore.RED,
45
+ 'CRITICAL': Fore.MAGENTA + Style.BRIGHT,
46
+ }
47
+ ALIASES = {
48
+ 'DEBUG': 'DEBUG',
49
+ 'INFO': 'INFO ',
50
+ 'WARNING': 'WARN ',
51
+ 'ERROR': 'ERROR',
52
+ 'CRITICAL': 'CRITC'
53
+ }
54
+
55
+ def format(self, record):
56
+ # Add color to the level name
57
+ if hasattr(record, 'levelname') and record.levelname in self.COLORS:
58
+ lname = record.levelname
59
+ if hasattr(record, 'message') and lname == 'ERROR':
60
+ record.message = f"{self.COLORS[lname]}{record.message}{Style.RESET_ALL}"
61
+
62
+ record.levelname = f"{self.COLORS[lname]}{self.ALIASES[lname]}{Style.RESET_ALL}"
63
+
64
+
65
+ if hasattr(record, 'name'):
66
+ rname = record.name if len(record.name) <= 17 else record.name[:14] + '...'
67
+ record.name = f"{Fore.CYAN}{rname}{Style.RESET_ALL}"
68
+
69
+ return super().format(record)
70
+
71
+
72
+ class CachedLogHandler(logging.Handler):
73
+ def __init__(self, level = 0) -> None:
74
+ super().__init__(level)
75
+ self._cache = []
76
+ self._max_lines = 50
77
+ self._lock = Lock()
78
+
79
+
80
+ def emit(self, record):
81
+ record_copy = copy.copy(record)
82
+ msg = self.format(record_copy)
83
+ with self._lock:
84
+ self._cache.append(msg)
85
+
86
+ def get_logs(self):
87
+ with self._lock:
88
+ return '\n'.join(self._cache)
89
+
90
+
91
+ cached_log_handler: CachedLogHandler = CachedLogHandler()
92
+
93
+ def setup_logging(
94
+ level: str = "INFO",
95
+ log_file: Optional[str] = None,
96
+ interactive_mode: bool = False,
97
+ module_name: Optional[str] = None
98
+ ) -> logging.Logger:
99
+ """
100
+ Set up centralized logging configuration.
101
+
102
+ Args:
103
+ level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
104
+ log_file: Optional path to log file. If None, uses default location.
105
+ interactive_mode: If True, logs only to file in interactive mode
106
+ module_name: Name of the module requesting the logger
107
+
108
+ Returns:
109
+ Configured logger instance
110
+ """
111
+ global cached_log_handler
112
+
113
+ # Convert string level to logging constant
114
+ numeric_level = getattr(logging, level.upper(), logging.INFO)
115
+
116
+ # Create logger
117
+ logger = logging.getLogger()
118
+
119
+ # Avoid duplicate handlers if logger already configured
120
+ if logger.handlers:
121
+ logger.handlers.clear()
122
+
123
+ logger.setLevel(numeric_level)
124
+
125
+ # Create formatters
126
+ detailed_formatter = logging.Formatter(
127
+ "(%(asctime)s) %(name)s\t %(levelname)s: %(message)s",
128
+ datefmt="%Y.%m.%d %H:%M:%S"
129
+ )
130
+
131
+ colored_formatter = ColoredFormatter(
132
+ "(%(asctime)s) %(name)s\t %(levelname)s: %(message)s",
133
+ datefmt="%Y.%m.%d %H:%M:%S"
134
+ )
135
+
136
+ codeblock_formatter = CodeBlockFormatter(
137
+ "%(levelname)s %(name)s\t : %(message)s",
138
+ )
139
+
140
+ # Set up file logging
141
+ if log_file or interactive_mode:
142
+ if not log_file:
143
+ # Default log file location
144
+ log_dir = Path("logs")
145
+ log_dir.mkdir(exist_ok=True)
146
+ log_file = log_dir / "rag_chatbot.log"
147
+
148
+ file_handler = logging.FileHandler(log_file, encoding='utf-8')
149
+ file_handler.setLevel(numeric_level)
150
+ file_handler.setFormatter(detailed_formatter)
151
+ logger.addHandler(file_handler)
152
+
153
+ # Set up the cached log handler for gradio Code block output
154
+ cached_log_handler.setLevel(numeric_level)
155
+ cached_log_handler.setFormatter(codeblock_formatter)
156
+
157
+ # Set up console logging (unless in interactive mode)
158
+ if not interactive_mode:
159
+ console_handler = logging.StreamHandler(sys.stdout)
160
+ console_handler.setLevel(numeric_level)
161
+
162
+ # Use colored formatter if terminal supports it
163
+ if _supports_color():
164
+ console_handler.setFormatter(colored_formatter)
165
+ else:
166
+ console_handler.setFormatter(detailed_formatter)
167
+
168
+ logger.addHandler(cached_log_handler)
169
+ logger.addHandler(console_handler)
170
+
171
+ return logger
172
+
173
+
174
+ def get_logger(module_name: str) -> logging.Logger:
175
+ """
176
+ Get a logger for a specific module.
177
+
178
+ Args:
179
+ module_name: Name of the module requesting the logger
180
+
181
+ Returns:
182
+ Logger instance
183
+ """
184
+ logger = logging.getLogger(module_name)
185
+ logger.propagate = True
186
+ return logger
187
+
188
+
189
+ def _supports_color() -> bool:
190
+ """
191
+ Check if the terminal supports color output.
192
+
193
+ Returns:
194
+ True if color is supported, False otherwise
195
+ """
196
+ # Check if we're in a terminal
197
+ if not hasattr(sys.stdout, 'isatty') or not sys.stdout.isatty():
198
+ return False
199
+
200
+ # Check environment variables
201
+ if os.getenv('NO_COLOR'):
202
+ return False
203
+
204
+ if os.getenv('FORCE_COLOR'):
205
+ return True
206
+
207
+ # Check terminal type
208
+ term = os.getenv('TERM', '').lower()
209
+ if 'color' in term or term in ['xterm', 'xterm-256color', 'screen']:
210
+ return True
211
+
212
+ return False
213
+
214
+
215
+ def detect_interactive_mode() -> bool:
216
+ """
217
+ Detect if the application is running in interactive mode.
218
+
219
+ Returns:
220
+ True if in interactive mode, False otherwise
221
+ """
222
+ # Check if no command line arguments were provided (except script name)
223
+ if len(sys.argv) == 1:
224
+ return True
225
+
226
+ # Check if only the script name and no other arguments
227
+ # This indicates the chatbot is running in default interactive mode
228
+ return False
229
+
230
+
231
+ def configure_external_loggers(level: str = "WARNING") -> None:
232
+ """
233
+ Configure logging for external libraries to reduce noise.
234
+
235
+ Args:
236
+ level: Logging level for external libraries
237
+ """
238
+ external_loggers = [
239
+ 'selenium',
240
+ 'urllib3',
241
+ 'requests',
242
+ 'chromadb',
243
+ 'docling',
244
+ 'weaviate',
245
+ 'langchain',
246
+ 'langgraph',
247
+ 'openai',
248
+ 'httpx'
249
+ ]
250
+
251
+ numeric_level = getattr(logging, level.upper(), logging.WARNING)
252
+
253
+ for logger_name in external_loggers:
254
+ logging.getLogger(logger_name).setLevel(numeric_level)
255
+
256
+
257
+ # Global configuration function
258
+ def init_logging(
259
+ level: str = "INFO",
260
+ log_file: Optional[str] = None,
261
+ interactive_mode: Optional[bool] = None
262
+ ) -> None:
263
+ """
264
+ Initialize the global logging configuration.
265
+
266
+ Args:
267
+ level: Logging level
268
+ log_file: Optional log file path
269
+ interactive_mode: If None, auto-detect interactive mode
270
+ """
271
+ if interactive_mode is None:
272
+ interactive_mode = detect_interactive_mode()
273
+
274
+ warnings.filterwarnings("ignore")
275
+
276
+ # Set up root logger
277
+ setup_logging(
278
+ level=level,
279
+ log_file=log_file,
280
+ interactive_mode=interactive_mode
281
+ )
282
+
283
+ # Configure external library loggers
284
+ configure_external_loggers()