Spaces:
Running
Running
Pygmales commited on
Commit ·
0a372e8
1
Parent(s): daccb57
inital commit
Browse files- config.py +164 -0
- main.py +130 -0
- requirements.txt +30 -0
- src/__init__.py +0 -0
- src/apps/__init__.py +0 -0
- src/apps/chat/.gradio/flagged/dataset1.csv +3 -0
- src/apps/chat/.gradio/flagged/dataset1.csv:Zone.Identifier +0 -0
- src/apps/chat/__init__.py +0 -0
- src/apps/chat/app.py +57 -0
- src/apps/panel/__init__.py +0 -0
- src/apps/panel/app.py +55 -0
- src/database/__init__.py +0 -0
- src/database/docker-compose.yml +29 -0
- src/database/weavservice.py +341 -0
- src/pipeline/__init__.py +0 -0
- src/pipeline/pipeline.py +209 -0
- src/processing/__init__.py +0 -0
- src/processing/processor.py +268 -0
- src/processing/scraping.py +382 -0
- src/rag/__init__.py +0 -0
- src/rag/agent_chain.py +197 -0
- src/rag/middleware.py +99 -0
- src/rag/models.py +123 -0
- src/rag/prompts.py +105 -0
- src/rag/utilclasses.py +20 -0
- src/scraper/__init__.py +0 -0
- src/scraper/__pycache__/__init__.cpython-312.pyc +0 -0
- src/scraper/__pycache__/__init__.cpython-312.pyc:Zone.Identifier +0 -0
- src/scraper/__pycache__/parser.cpython-312.pyc +0 -0
- src/scraper/__pycache__/parser.cpython-312.pyc:Zone.Identifier +0 -0
- src/scraper/__pycache__/scraper.cpython-312.pyc +0 -0
- src/scraper/__pycache__/scraper.cpython-312.pyc:Zone.Identifier +0 -0
- src/scraper/parser.py +304 -0
- src/scraper/scraper.py +292 -0
- src/ui/__init__.py +0 -0
- src/ui/cli.py +154 -0
- src/utils/__init__.py +3 -0
- src/utils/lang.py +14 -0
- src/utils/logging.py +284 -0
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()
|