Pygmales commited on
Commit
c42dbe0
·
verified ·
1 Parent(s): 25bf5a4

Sync from GitHub 2dc8883ce0037ee296214ef58a463a871005286f

Browse files
src/const/agent_response_constants.py CHANGED
@@ -65,6 +65,17 @@ LANGUAGE_FALLBACK_MESSAGE = {
65
  ),
66
  }
67
 
 
 
 
 
 
 
 
 
 
 
 
68
  CONVERSATION_END_MESSAGE = {
69
  "en": (
70
  "This conversation has reached its maximum length. "
 
65
  ),
66
  }
67
 
68
+ LANGUAGE_CLARIFICATION_MESSAGE = {
69
+ "en": (
70
+ "Your message mixes multiple languages. "
71
+ "Would you like to continue in English or German?"
72
+ ),
73
+ "de": (
74
+ "Ihre Nachricht mischt mehrere Sprachen. "
75
+ "M\u00f6chten Sie auf Deutsch oder Englisch fortfahren?"
76
+ ),
77
+ }
78
+
79
  CONVERSATION_END_MESSAGE = {
80
  "en": (
81
  "This conversation has reached its maximum length. "
src/rag/agent_chain.py CHANGED
@@ -563,11 +563,27 @@ class ExecutiveAgentChain:
563
  chain_logger.info(f"Language locked to '{self._stored_language}' (after {user_message_count} messages)")
564
  current_language = self._stored_language
565
  else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  detected_language = self._language_detector.detect_language(processed_query)
567
  self._conversation_state['user_language'] = detected_language
568
 
569
  # Language validation
570
- if detected_language in ['de', 'en']:
571
  self._stored_language = detected_language
572
  current_language = detected_language
573
  else:
 
563
  chain_logger.info(f"Language locked to '{self._stored_language}' (after {user_message_count} messages)")
564
  current_language = self._stored_language
565
  else:
566
+ if self._language_detector.needs_language_clarification(processed_query):
567
+ clarification_language = "en"
568
+ clarification_msg = LANGUAGE_CLARIFICATION_MESSAGE[clarification_language]
569
+ self._conversation_state['user_language'] = "ambiguous"
570
+ self._stored_language = clarification_language
571
+ self._conversation_history.append(HumanMessage(processed_query))
572
+ self._conversation_history.append(AIMessage(clarification_msg))
573
+
574
+ return LeadAgentQueryResponse(
575
+ response=clarification_msg,
576
+ language=clarification_language,
577
+ processed_query=processed_query,
578
+ appointment_requested=False,
579
+ show_booking_widget=False,
580
+ )
581
+
582
  detected_language = self._language_detector.detect_language(processed_query)
583
  self._conversation_state['user_language'] = detected_language
584
 
585
  # Language validation
586
+ if detected_language in config.get("AVAILABLE_LANGUAGES", ["en", "de"]):
587
  self._stored_language = detected_language
588
  current_language = detected_language
589
  else:
src/rag/input_handler.py CHANGED
@@ -3,6 +3,7 @@ Input handler for processing and validating user messages.
3
  Handles numeric inputs, validation, and interpretation.
4
  """
5
  import re
 
6
  from src.utils.logging import get_logger
7
 
8
  logger = get_logger("input_handler")
@@ -88,6 +89,15 @@ class InputHandler:
88
  run_length = 0
89
  return False
90
 
 
 
 
 
 
 
 
 
 
91
  @staticmethod
92
  def _looks_like_nonsense_word(word: str) -> bool:
93
  word_lower = word.casefold()
@@ -180,6 +190,9 @@ class InputHandler:
180
  return False
181
  if allowlisted in InputHandler.PROGRAMME_REFERENCES:
182
  return False
 
 
 
183
 
184
  if not re.search(r"[A-Za-zÄÖÜäöü0-9]", normalized):
185
  return True
 
3
  Handles numeric inputs, validation, and interpretation.
4
  """
5
  import re
6
+ import unicodedata
7
  from src.utils.logging import get_logger
8
 
9
  logger = get_logger("input_handler")
 
89
  run_length = 0
90
  return False
91
 
92
+ @staticmethod
93
+ def _contains_unsupported_script_letter(message: str) -> bool:
94
+ for char in message:
95
+ if not char.isalpha():
96
+ continue
97
+ if 'LATIN' not in unicodedata.name(char, ''):
98
+ return True
99
+ return False
100
+
101
  @staticmethod
102
  def _looks_like_nonsense_word(word: str) -> bool:
103
  word_lower = word.casefold()
 
190
  return False
191
  if allowlisted in InputHandler.PROGRAMME_REFERENCES:
192
  return False
193
+ if InputHandler._contains_unsupported_script_letter(normalized):
194
+ # Let the language layer explain the supported English/German scope.
195
+ return False
196
 
197
  if not re.search(r"[A-Za-zÄÖÜäöü0-9]", normalized):
198
  return True
src/rag/language_detection.py CHANGED
@@ -1,10 +1,8 @@
1
- from pydantic import BaseModel, Field
2
- from langchain_core.messages import HumanMessage
3
- from src.rag.models import ModelConfigurator as modconf
4
- from src.rag.prompts import PromptConfigurator as promptconf
5
  import re
6
  import unicodedata
7
 
 
 
8
  from src.utils.logging import get_logger
9
 
10
  logger = get_logger('lang_detector')
@@ -38,6 +36,7 @@ STOPWORDS_DE = SHORT_WORDS_DE | {
38
  'zum', 'zur', 'als', 'wie', 'um', 'an', 'im', 'am', 'beim', 'durch',
39
  'mein', 'meine', 'dein', 'ihre', 'ihren', 'unser', 'euch', 'uns',
40
  'jahre', 'jahren', 'erfahrung', 'studium', 'kosten', 'dauer', 'beginn',
 
41
  }
42
  STOPWORDS_EN = SHORT_WORDS_EN | {
43
  'the', 'a', 'an', 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'my',
@@ -49,6 +48,16 @@ STOPWORDS_EN = SHORT_WORDS_EN | {
49
  'years', 'experience', 'study', 'cost', 'costs', 'duration', 'start',
50
  }
51
 
 
 
 
 
 
 
 
 
 
 
52
  # Characters that only occur in German (among the two supported languages)
53
  GERMAN_CHARS = set('äöüß')
54
 
@@ -83,27 +92,27 @@ LANGUAGE_NEUTRAL_PROGRAM_PATTERNS = [
83
  ]
84
 
85
 
86
- class LanguageDetectionResult(BaseModel):
87
- language_code: str = Field(description="ISO language code (e.g., en, de, fa, ru) of the language in which the message is written")
88
-
89
-
90
  class LanguageDetector:
91
  def __init__(self) -> None:
92
- # Lazy init: the LLM is only needed for the rare ambiguous case
93
  self._model = None
94
 
95
- def _get_model(self):
96
- if self._model is None:
97
- model = modconf.get_language_detector_model()
98
- self._model = model.with_structured_output(LanguageDetectionResult)
99
- return self._model
100
-
101
  def detect_explicit_switch_request(self, query: str) -> str | None:
102
  """
103
  Detect if user explicitly requests a language switch.
104
  Returns 'en', 'de', or None if no explicit switch requested.
105
  """
106
  query_lower = query.lower()
 
 
 
 
 
 
 
 
 
 
107
 
108
  for pattern in SWITCH_TO_EN_PATTERNS:
109
  if pattern in query_lower:
@@ -145,16 +154,52 @@ class LanguageDetector:
145
  normalized = re.sub(r"\s+", " ", normalized).strip()
146
  return normalized in LANGUAGE_NEUTRAL_PROGRAM_PATTERNS
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  def _heuristic_detect(self, query: str) -> str | None:
149
  """
150
  Full-text heuristic detection for de/en. Returns None when ambiguous
151
- or when the text is written in a non-Latin script (so the LLM
152
- fallback can return the correct ISO code and the chain can reject
153
- unsupported languages).
154
  """
155
  text = query.lower()
156
 
157
- # Non-Latin script -> not decidable as de/en, let the LLM classify it
158
  if NON_LATIN_SCRIPT_RE.search(text):
159
  return None
160
 
@@ -174,18 +219,63 @@ class LanguageDetector:
174
 
175
  de_hits = sum(1 for w in words if w in STOPWORDS_DE)
176
  en_hits = sum(1 for w in words if w in STOPWORDS_EN)
 
177
 
178
  # Require a clear signal: at least one stopword hit and a strict
179
  # majority. Ties and zero-hit inputs stay ambiguous.
180
- if de_hits > en_hits and de_hits > 0:
181
  logger.info(f"Heuristic detection: de ({de_hits} vs {en_hits} stopword hits)")
182
  return 'de'
183
- if en_hits > de_hits and en_hits > 0:
184
  logger.info(f"Heuristic detection: en ({en_hits} vs {de_hits} stopword hits)")
185
  return 'en'
186
 
187
  return None
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  @staticmethod
190
  def _has_non_german_latin_diacritic(text: str) -> bool:
191
  for ch in text:
@@ -201,20 +291,30 @@ class LanguageDetector:
201
  if quick_result:
202
  return quick_result
203
 
 
 
 
 
204
  # 2. Full-text stopword heuristic (latency fix: resolves the vast
205
  # majority of inputs locally in <1 ms instead of an LLM round-trip)
206
  heuristic_result = self._heuristic_detect(query)
207
  if heuristic_result:
208
  return heuristic_result
209
 
210
- # 3. LLM fallback only for genuinely ambiguous inputs
211
- logger.info("Heuristic detection ambiguous, falling back to LLM")
212
- prompt = promptconf.get_language_detector_prompt(query)
213
- messages = [HumanMessage(prompt)]
214
-
215
- try:
216
- result = self._get_model().invoke(messages)
217
- return result.language_code
218
- except Exception as e:
219
- logger.error(f"Failed to detect language: {e}")
220
- return ""
 
 
 
 
 
 
 
 
 
 
 
1
  import re
2
  import unicodedata
3
 
4
+ from src.config import config
5
+ from src.utils.lang import detect_language_profile
6
  from src.utils.logging import get_logger
7
 
8
  logger = get_logger('lang_detector')
 
36
  'zum', 'zur', 'als', 'wie', 'um', 'an', 'im', 'am', 'beim', 'durch',
37
  'mein', 'meine', 'dein', 'ihre', 'ihren', 'unser', 'euch', 'uns',
38
  'jahre', 'jahren', 'erfahrung', 'studium', 'kosten', 'dauer', 'beginn',
39
+ 'kostet', 'welche', 'welcher', 'welches', 'heute',
40
  }
41
  STOPWORDS_EN = SHORT_WORDS_EN | {
42
  'the', 'a', 'an', 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'my',
 
48
  'years', 'experience', 'study', 'cost', 'costs', 'duration', 'start',
49
  }
50
 
51
+ MIXED_LANGUAGE_AMBIGUOUS_TOKENS = {
52
+ # German "im" is also a common ASCII typo for English "I'm".
53
+ 'im',
54
+ # Common in German questions/text but also English stopwords.
55
+ 'in',
56
+ 'was',
57
+ }
58
+
59
+ LANGDETECT_MIN_PROBABILITY = 0.75
60
+
61
  # Characters that only occur in German (among the two supported languages)
62
  GERMAN_CHARS = set('äöüß')
63
 
 
92
  ]
93
 
94
 
 
 
 
 
95
  class LanguageDetector:
96
  def __init__(self) -> None:
97
+ # Kept for tests that assert local heuristics never initialize a model.
98
  self._model = None
99
 
 
 
 
 
 
 
100
  def detect_explicit_switch_request(self, query: str) -> str | None:
101
  """
102
  Detect if user explicitly requests a language switch.
103
  Returns 'en', 'de', or None if no explicit switch requested.
104
  """
105
  query_lower = query.lower()
106
+ normalized = re.sub(r"[^\w\s]", " ", query_lower)
107
+ normalized = re.sub(r"\s+", " ", normalized).strip()
108
+
109
+ if normalized in {'english', 'englisch'}:
110
+ logger.info("Explicit language switch request detected: -> English")
111
+ return 'en'
112
+
113
+ if normalized in {'deutsch', 'german'}:
114
+ logger.info("Explicit language switch request detected: -> German")
115
+ return 'de'
116
 
117
  for pattern in SWITCH_TO_EN_PATTERNS:
118
  if pattern in query_lower:
 
154
  normalized = re.sub(r"\s+", " ", normalized).strip()
155
  return normalized in LANGUAGE_NEUTRAL_PROGRAM_PATTERNS
156
 
157
+ @staticmethod
158
+ def _supported_languages() -> set[str]:
159
+ return set(config.get("AVAILABLE_LANGUAGES", ["en", "de"]))
160
+
161
+ @staticmethod
162
+ def _profile_detect(query: str) -> tuple[str, float] | None:
163
+ profile = detect_language_profile(query)
164
+ if profile:
165
+ profile_lang, probability = profile
166
+ logger.info(f"Profile detection: {profile_lang} ({probability:.2f})")
167
+ return profile
168
+
169
+ @staticmethod
170
+ def _mixed_language_signal_counts(text: str) -> tuple[int, int]:
171
+ words = re.findall(r"[a-z']+", text)
172
+ shared_de_en = STOPWORDS_DE & STOPWORDS_EN
173
+ de_hits = sum(
174
+ 1
175
+ for word in words
176
+ if (
177
+ word in STOPWORDS_DE
178
+ and word not in shared_de_en
179
+ and word not in MIXED_LANGUAGE_AMBIGUOUS_TOKENS
180
+ )
181
+ )
182
+ en_hits = sum(
183
+ 1
184
+ for word in words
185
+ if (
186
+ word in STOPWORDS_EN
187
+ and word not in shared_de_en
188
+ and word not in MIXED_LANGUAGE_AMBIGUOUS_TOKENS
189
+ )
190
+ )
191
+ if any(ch in GERMAN_CHARS for ch in text):
192
+ de_hits += 1
193
+ return de_hits, en_hits
194
+
195
  def _heuristic_detect(self, query: str) -> str | None:
196
  """
197
  Full-text heuristic detection for de/en. Returns None when ambiguous
198
+ or unsupported.
 
 
199
  """
200
  text = query.lower()
201
 
202
+ # Non-Latin script -> unsupported by the de/en-only local detector.
203
  if NON_LATIN_SCRIPT_RE.search(text):
204
  return None
205
 
 
219
 
220
  de_hits = sum(1 for w in words if w in STOPWORDS_DE)
221
  en_hits = sum(1 for w in words if w in STOPWORDS_EN)
222
+ min_hits = 1 if len(words) <= 3 else 2
223
 
224
  # Require a clear signal: at least one stopword hit and a strict
225
  # majority. Ties and zero-hit inputs stay ambiguous.
226
+ if de_hits > en_hits and de_hits >= min_hits:
227
  logger.info(f"Heuristic detection: de ({de_hits} vs {en_hits} stopword hits)")
228
  return 'de'
229
+ if en_hits > de_hits and en_hits >= min_hits:
230
  logger.info(f"Heuristic detection: en ({en_hits} vs {de_hits} stopword hits)")
231
  return 'en'
232
 
233
  return None
234
 
235
+ def needs_language_clarification(self, query: str) -> bool:
236
+ """
237
+ Return True when a message blends language signals and answering in one
238
+ supported language would be a guess.
239
+ """
240
+ text = query.lower()
241
+
242
+ words = re.findall(r"[a-z']+", text)
243
+ de_hits, en_hits = self._mixed_language_signal_counts(text)
244
+ supported_hits = de_hits + en_hits
245
+ has_unsupported_script_signal = (
246
+ NON_LATIN_SCRIPT_RE.search(text)
247
+ or self._has_non_german_latin_diacritic(text)
248
+ )
249
+
250
+ if len(words) >= 4 and de_hits > 0 and en_hits > 0:
251
+ logger.info(
252
+ "Mixed-language input detected "
253
+ f"(de={de_hits}, en={en_hits})"
254
+ )
255
+ return True
256
+
257
+ if supported_hits > 0 and has_unsupported_script_signal:
258
+ logger.info("Mixed supported/unsupported language input detected.")
259
+ return True
260
+
261
+ if self._heuristic_detect(query):
262
+ return False
263
+
264
+ profile_result = self._profile_detect(query) if supported_hits > 0 else None
265
+ if profile_result:
266
+ profile_lang, profile_probability = profile_result
267
+ if (
268
+ profile_lang not in self._supported_languages()
269
+ and profile_probability >= LANGDETECT_MIN_PROBABILITY
270
+ ):
271
+ logger.info(
272
+ "Mixed supported/unsupported language input detected "
273
+ f"(profile={profile_lang}, p={profile_probability:.2f})"
274
+ )
275
+ return True
276
+
277
+ return False
278
+
279
  @staticmethod
280
  def _has_non_german_latin_diacritic(text: str) -> bool:
281
  for ch in text:
 
291
  if quick_result:
292
  return quick_result
293
 
294
+ if self.needs_language_clarification(query):
295
+ logger.info("Local language detection found mixed supported-language signals.")
296
+ return ""
297
+
298
  # 2. Full-text stopword heuristic (latency fix: resolves the vast
299
  # majority of inputs locally in <1 ms instead of an LLM round-trip)
300
  heuristic_result = self._heuristic_detect(query)
301
  if heuristic_result:
302
  return heuristic_result
303
 
304
+ profile_result = self._profile_detect(query)
305
+ if profile_result:
306
+ profile_lang, profile_probability = profile_result
307
+ if (
308
+ profile_lang in self._supported_languages()
309
+ and profile_probability >= LANGDETECT_MIN_PROBABILITY
310
+ ):
311
+ logger.info(
312
+ "Profile detection accepted: "
313
+ f"{profile_lang} ({profile_probability:.2f})"
314
+ )
315
+ return profile_lang
316
+
317
+ # 3. Ambiguous or unsupported input. Do not call an LLM here: the chain
318
+ # handles this through the supported-language fallback.
319
+ logger.info("Local language detection ambiguous or unsupported.")
320
+ return ""
src/rag/scope_guardian.py CHANGED
@@ -2,6 +2,8 @@
2
  Scope guardian for handling out-of-scope queries and providing appropriate redirections.
3
  Ensures the chatbot stays within its defined boundaries.
4
  """
 
 
5
  from src.const.agent_response_constants import get_admissions_contact_text
6
  from src.utils.logging import get_logger
7
 
@@ -17,12 +19,12 @@ class ScopeGuardian:
17
  OFF_TOPIC_KEYWORDS = {
18
  'en': [
19
  'weather', 'sports', 'politics', 'vacation', 'travel',
20
- 'restaurant', 'movie', 'entertainment', 'news', 'dating',
21
- 'recipe', 'cooking'
22
  ],
23
  'de': [
24
  'wetter', 'sport', 'politik', 'urlaub', 'reise',
25
- 'restaurant', 'film', 'unterhaltung', 'nachrichten',
26
  'rezept', 'kochen'
27
  ]
28
  }
@@ -51,12 +53,27 @@ class ScopeGuardian:
51
 
52
  @staticmethod
53
  def _matches_any(message_lower: str, keywords: list[str]) -> bool:
54
- """Match each keyword as a substring against the lowercased message."""
55
  for keyword in keywords:
56
- if keyword.lower() in message_lower:
 
57
  return True
58
  return False
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  @staticmethod
61
  def check_scope(message: str, language: str = 'en') -> str:
62
  """
@@ -75,6 +92,9 @@ class ScopeGuardian:
75
  logger.warning("Detected aggressive language in message")
76
  return 'aggressive'
77
 
 
 
 
78
  off_topic_keywords = (
79
  ScopeGuardian.OFF_TOPIC_KEYWORDS.get('en', [])
80
  + ScopeGuardian.OFF_TOPIC_KEYWORDS.get('de', [])
@@ -105,6 +125,13 @@ class ScopeGuardian:
105
  Returns:
106
  Redirect message
107
  """
 
 
 
 
 
 
 
108
  messages = {
109
  'off_topic': {
110
  'en': "I am here to help with questions about HSG Executive MBA programmes (EMBA, IEMBA, and emba X). I would be happy to discuss programme details, admissions requirements, or help you identify the most suitable option for your goals. What would you like to know about our programmes?",
 
2
  Scope guardian for handling out-of-scope queries and providing appropriate redirections.
3
  Ensures the chatbot stays within its defined boundaries.
4
  """
5
+ import re
6
+
7
  from src.const.agent_response_constants import get_admissions_contact_text
8
  from src.utils.logging import get_logger
9
 
 
19
  OFF_TOPIC_KEYWORDS = {
20
  'en': [
21
  'weather', 'sports', 'politics', 'vacation', 'travel',
22
+ 'restaurant', 'restaurants', 'movie', 'movies', 'entertainment',
23
+ 'news', 'dating', 'recipe', 'recipes', 'cooking'
24
  ],
25
  'de': [
26
  'wetter', 'sport', 'politik', 'urlaub', 'reise',
27
+ 'restaurant', 'restaurants', 'film', 'filme', 'unterhaltung', 'nachrichten',
28
  'rezept', 'kochen'
29
  ]
30
  }
 
53
 
54
  @staticmethod
55
  def _matches_any(message_lower: str, keywords: list[str]) -> bool:
56
+ """Match keywords on word boundaries to avoid substring false positives."""
57
  for keyword in keywords:
58
+ pattern = rf"(?<!\w){re.escape(keyword.lower())}(?!\w)"
59
+ if re.search(pattern, message_lower):
60
  return True
61
  return False
62
 
63
+ @staticmethod
64
+ def _is_programme_travel_context(message_lower: str) -> bool:
65
+ travel_terms = [
66
+ 'travel', 'transport', 'reise', 'reisen', 'anreise',
67
+ ]
68
+ programme_context_terms = [
69
+ 'emba', 'iemba', 'programme', 'program', 'module', 'modules',
70
+ 'modul', 'modulen', 'campus',
71
+ ]
72
+ return (
73
+ ScopeGuardian._matches_any(message_lower, travel_terms)
74
+ and ScopeGuardian._matches_any(message_lower, programme_context_terms)
75
+ )
76
+
77
  @staticmethod
78
  def check_scope(message: str, language: str = 'en') -> str:
79
  """
 
92
  logger.warning("Detected aggressive language in message")
93
  return 'aggressive'
94
 
95
+ if ScopeGuardian._is_programme_travel_context(message_lower):
96
+ return 'on_topic'
97
+
98
  off_topic_keywords = (
99
  ScopeGuardian.OFF_TOPIC_KEYWORDS.get('en', [])
100
  + ScopeGuardian.OFF_TOPIC_KEYWORDS.get('de', [])
 
125
  Returns:
126
  Redirect message
127
  """
128
+ if scope_type == 'off_topic':
129
+ messages = {
130
+ 'en': "I am specialized in HSG Executive MBA programmes, so I cannot advise on general topics such as restaurants, travel, or local recommendations. I would be happy to discuss programme details, admissions requirements, or help you identify the most suitable option for your goals. What would you like to know about our programmes?",
131
+ 'de': "Ich bin auf HSG Executive MBA-Programme spezialisiert. Zu allgemeinen Themen wie Restaurants, Reisen oder lokalen Empfehlungen kann ich leider nicht beraten. Gerne helfe ich Ihnen bei Programmdetails, Zulassungsvoraussetzungen oder dabei, das richtige Programm f\u00fcr Ihre Ziele zu finden. Was m\u00f6chten Sie \u00fcber unsere Programme wissen?",
132
+ }
133
+ return messages.get(language, messages['en'])
134
+
135
  messages = {
136
  'off_topic': {
137
  'en': "I am here to help with questions about HSG Executive MBA programmes (EMBA, IEMBA, and emba X). I would be happy to discuss programme details, admissions requirements, or help you identify the most suitable option for your goals. What would you like to know about our programmes?",
src/utils/lang.py CHANGED
@@ -1,4 +1,5 @@
1
  from langdetect import DetectorFactory, detect_langs
 
2
  from src.utils.logging import get_logger
3
 
4
  from src.config import config
@@ -6,6 +7,27 @@ from src.config import config
6
  logger = get_logger('lang_utils')
7
  DetectorFactory.seed = 0
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  def detect_language(text: str):
10
  """
11
  Detects if the provided text is written in German or in some other language.
@@ -17,10 +39,12 @@ def detect_language(text: str):
17
  Returns:
18
  str: 'de' if the detection certanty is more than 0.6, else 'en'.
19
  """
20
- found_langs = detect_langs(text)
21
- top_lang = found_langs[0]
22
- logger.debug(f'Found following languages in the text: {", ".join(f"{lang.lang}-{lang.prob:1.2f}" for lang in found_langs)}')
23
- return 'de' if top_lang.lang == 'de' and top_lang.prob >= config.processing.LANG_AMBIGUITY_THRESHOLD else 'en'
 
 
24
 
25
 
26
  def get_language_name(code: str):
 
1
  from langdetect import DetectorFactory, detect_langs
2
+ from langdetect.lang_detect_exception import LangDetectException
3
  from src.utils.logging import get_logger
4
 
5
  from src.config import config
 
7
  logger = get_logger('lang_utils')
8
  DetectorFactory.seed = 0
9
 
10
+
11
+ def detect_language_profile(text: str) -> tuple[str, float] | None:
12
+ """
13
+ Return langdetect's top language candidate and probability.
14
+ """
15
+ try:
16
+ found_langs = detect_langs(text)
17
+ except LangDetectException:
18
+ return None
19
+
20
+ if not found_langs:
21
+ return None
22
+
23
+ top_lang = found_langs[0]
24
+ logger.debug(
25
+ 'Found following languages in the text: '
26
+ + ', '.join(f'{lang.lang}-{lang.prob:1.2f}' for lang in found_langs)
27
+ )
28
+ return top_lang.lang, top_lang.prob
29
+
30
+
31
  def detect_language(text: str):
32
  """
33
  Detects if the provided text is written in German or in some other language.
 
39
  Returns:
40
  str: 'de' if the detection certanty is more than 0.6, else 'en'.
41
  """
42
+ profile = detect_language_profile(text)
43
+ if not profile:
44
+ return 'en'
45
+
46
+ top_lang, probability = profile
47
+ return 'de' if top_lang == 'de' and probability >= config.processing.LANG_AMBIGUITY_THRESHOLD else 'en'
48
 
49
 
50
  def get_language_name(code: str):
tests/test_input_handler_gibberish.py CHANGED
@@ -59,6 +59,20 @@ def test_process_input_accepts_normal_inputs(message):
59
  assert processed == message.strip()
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  @pytest.mark.parametrize(
63
  "message",
64
  [
 
59
  assert processed == message.strip()
60
 
61
 
62
+ @pytest.mark.parametrize(
63
+ "message",
64
+ [
65
+ "\u0414\u043e\u0431\u0440\u044b\u0439 \u0434\u0435\u043d\u044c, \u0445\u043e\u0447\u0443 \u0443\u0437\u043d\u0430\u0442\u044c \u0431\u043e\u043b\u044c\u0448\u0435 \u043e \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u0435 EMBA",
66
+ "\u0645\u0633\u0627\u0621 \u0627\u0644\u062e\u064a\u0631\u060c \u0623\u0631\u064a\u062f \u0645\u0639\u0631\u0641\u0629 \u0627\u0644\u0645\u0632\u064a\u062f \u0639\u0646 EMBA",
67
+ ],
68
+ )
69
+ def test_process_input_accepts_unsupported_languages_for_language_fallback(message):
70
+ processed, is_valid = InputHandler.process_input(message, [])
71
+
72
+ assert is_valid
73
+ assert processed == message.strip()
74
+
75
+
76
  @pytest.mark.parametrize(
77
  "message",
78
  [
tests/test_language_handling.py CHANGED
@@ -1,6 +1,45 @@
1
- import pytest
 
2
 
 
 
 
 
 
 
3
  from src.rag.language_detection import LanguageDetector
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  class TestQueryLanguageDetection:
6
  def test_program_names_are_treated_as_language_neutral(self):
@@ -9,44 +48,105 @@ class TestQueryLanguageDetection:
9
  for query in ("EMBA", "IEMBA", "emba X", "EMBA HSG", "IEMBA HSG", "embax"):
10
  assert detector.is_language_neutral_program_reference(query)
11
 
12
- def test_basic_language_detection(self):
13
  queries = {
14
  "en": "Hello, im interested in the EMBA Program",
15
- "de": "Guten Tag, ich interessiere mich für das EMBA Programm",
16
- "ru": "Добрый день, хочу узнать больше о программе EMBA",
17
- "uk": "Доброго дня, хочу дізнатися більше про програму ЄМБА",
18
- "be": "Добры дзень, хачу даведацца больш аб праграме ЕМБА",
19
- "lt": "Laba diena, norėčiau sužinoti daugiau apie EMBA programą.",
20
- "lv": "Labdien, es vēlētos uzzināt vairāk par EMBA programmu.",
21
- "fr": "Bonjour, je souhaiterais en savoir plus sur le programme EMBA.",
22
- "it": "Buongiorno, vorrei avere maggiori informazioni sul programma EMBA.",
23
- "es": "Buenas tardes, me gustaría saber más sobre el programa EMBA.",
24
- "pl": "Dzień dobry, chciałbym dowiedzieć się więcej na temat programu EMBA.",
25
- "el": "Καλησπέρα, θα ήθελα να μάθω περισσότερα για το πρόγραμμα EMBA.",
26
- "ko": "안녕하세요, EMBA 프로그램에 대해 더 자세히 알고 싶습니다.",
27
- "zh": "下午好,我想了解更多關於EMBA課程的資訊。",
28
- "ja": "こんにちは。EMBA プログラムについて詳しく知りたいのですが。",
29
- "yi": "גוטן נאכמיטאג, איך וואלט געוואלט וויסן מער וועגן דעם EMBA פראגראם.",
30
- "fa": "عصر بخیر، مایلم درباره برنامه EMBA بیشتر بدانم.",
31
- "ar": "مساء الخير، أود معرفة المزيد عن برنامج ماجستير إدارة الأعمال التنفيذية.",
32
- "tr": "İyi günler, EMBA programı hakkında daha fazla bilgi edinmek istiyorum.",
33
- "hu": "Jó napot kívánok, szeretnék többet megtudni az EMBA programról.",
34
- "id": "Selamat siang, saya ingin mengetahui lebih lanjut tentang program EMBA.",
35
- "ro": "Bună ziua, aș dori să aflu mai multe despre programul EMBA.",
36
- "nl": "Goedemiddag, ik wil graag meer weten over het EMBA-programma.",
37
- }
38
- correct_detections = 0
39
-
40
  detector = LanguageDetector()
41
  for language, query in queries.items():
42
- detected_language = detector.detect_language(query)
43
- if detected_language in [language, 'en']:
44
- correct_detections += 1
45
- else:
46
- print(f"Detected: {detected_language}, should be {language}")
47
-
48
- overall_score = correct_detections / len(queries)
49
- assert overall_score >= 0.95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
 
52
  if __name__ == "__main__":
 
1
+ import pytest
2
+ from langchain_core.messages import AIMessage, HumanMessage
3
 
4
+ from src.const.agent_response_constants import (
5
+ LANGUAGE_CLARIFICATION_MESSAGE,
6
+ LANGUAGE_FALLBACK_MESSAGE,
7
+ )
8
+ from src.rag.agent_chain import ExecutiveAgentChain
9
+ from src.rag.input_handler import InputHandler
10
  from src.rag.language_detection import LanguageDetector
11
+ from src.rag.scope_guardian import ScopeGuardian
12
+
13
+
14
+ def _agent_for_language_preprocessing(language: str = "en") -> ExecutiveAgentChain:
15
+ agent = object.__new__(ExecutiveAgentChain)
16
+ agent._stored_language = language
17
+ agent._conversation_history = []
18
+ agent._pending_continuation = None
19
+ agent._input_handler = InputHandler()
20
+ agent._scope_guardian = ScopeGuardian()
21
+ agent._language_detector = LanguageDetector()
22
+ agent._scope_violation_counts = {}
23
+ agent._aggressive_violation_count = 0
24
+ agent._invalid_input_count = 0
25
+ agent._conversation_state = {
26
+ "session_id": "session-1",
27
+ "user_id": "session-1",
28
+ "user_language": None,
29
+ "user_name": None,
30
+ "experience_years": None,
31
+ "leadership_years": None,
32
+ "field": None,
33
+ "interest": None,
34
+ "qualification_level": None,
35
+ "program_interest": [],
36
+ "suggested_program": None,
37
+ "handover_requested": None,
38
+ "topics_discussed": [],
39
+ "preferences_known": False,
40
+ }
41
+ return agent
42
+
43
 
44
  class TestQueryLanguageDetection:
45
  def test_program_names_are_treated_as_language_neutral(self):
 
48
  for query in ("EMBA", "IEMBA", "emba X", "EMBA HSG", "IEMBA HSG", "embax"):
49
  assert detector.is_language_neutral_program_reference(query)
50
 
51
+ def test_supported_language_detection_is_local(self):
52
  queries = {
53
  "en": "Hello, im interested in the EMBA Program",
54
+ "de": "Guten Tag, ich interessiere mich fuer das EMBA Programm",
55
+ }
56
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  detector = LanguageDetector()
58
  for language, query in queries.items():
59
+ assert detector.detect_language(query) == language
60
+ assert detector._model is None
61
+
62
+ def test_unsupported_language_returns_empty_without_llm(self):
63
+ detector = LanguageDetector()
64
+
65
+ assert detector.detect_language("Buenas tardes, quiero saber sobre el programa EMBA") == ""
66
+ assert detector.detect_language("Bonjour, je souhaite en savoir plus sur le programme EMBA") == ""
67
+ assert detector._model is None
68
+
69
+ def test_mixed_language_input_needs_clarification(self):
70
+ detector = LanguageDetector()
71
+
72
+ assert detector.needs_language_clarification("Ich want to know sobre los programs")
73
+ assert detector.detect_language("Ich want to know sobre los programs") == ""
74
+ assert detector.needs_language_clarification("Hello quiero saber sobre programs")
75
+ assert detector.detect_language("Hello quiero saber sobre programs") == ""
76
+ assert not detector.needs_language_clarification("I want to know about the programs")
77
+ assert not detector.needs_language_clarification("I want to know about the Executive MBA")
78
+ assert not detector.needs_language_clarification("Ich interessiere mich fuer die Programme")
79
+ assert not detector.needs_language_clarification("Was sind die besten Restaurants in St. Gallen?")
80
+ assert detector.detect_language("Welche Filme laufen heute?") == "de"
81
+ assert detector.detect_language("Was kostet emba X?") == "de"
82
+ assert not detector.needs_language_clarification(
83
+ "Buenas tardes, quiero saber sobre el programa EMBA"
84
+ )
85
+
86
+ def test_standalone_language_choice_is_explicit_switch(self):
87
+ detector = LanguageDetector()
88
+
89
+ assert detector.detect_explicit_switch_request("English") == "en"
90
+ assert detector.detect_explicit_switch_request("Englisch") == "en"
91
+ assert detector.detect_explicit_switch_request("Deutsch") == "de"
92
+ assert detector.detect_explicit_switch_request("German") == "de"
93
+
94
+
95
+ def test_mixed_language_query_asks_user_to_choose_language():
96
+ agent = _agent_for_language_preprocessing(language="en")
97
+
98
+ response = agent.query("Ich want to know sobre los programs")
99
+
100
+ assert response.response == LANGUAGE_CLARIFICATION_MESSAGE["en"]
101
+ assert response.language == "en"
102
+ assert agent._conversation_state["user_language"] == "ambiguous"
103
+ assert "Would you like to continue in English or German?" in response.response
104
+ assert not response.response.startswith("Hello.")
105
+ assert response.appointment_requested is False
106
+ assert response.show_booking_widget is False
107
+
108
+
109
+ def test_mid_conversation_language_clarification_does_not_greet_again():
110
+ agent = _agent_for_language_preprocessing(language="en")
111
+ agent._conversation_history = [
112
+ HumanMessage("How much does the EMBA cost?"),
113
+ AIMessage("The EMBA tuition is CHF 77,500."),
114
+ ]
115
+
116
+ response = agent.query("Ich want to know sobre los programs")
117
+
118
+ assert response.response == LANGUAGE_CLARIFICATION_MESSAGE["en"]
119
+ assert response.language == "en"
120
+ assert not response.response.startswith("Hello.")
121
+ assert agent._conversation_state["user_language"] == "ambiguous"
122
+ assert response.appointment_requested is False
123
+ assert response.show_booking_widget is False
124
+
125
+
126
+ def test_unsupported_language_query_uses_supported_language_fallback():
127
+ agent = _agent_for_language_preprocessing(language="en")
128
+
129
+ response = agent.query("Buenas tardes, quiero saber sobre el programa EMBA")
130
+
131
+ assert response.response == LANGUAGE_FALLBACK_MESSAGE["en"]
132
+ assert response.language == "en"
133
+ assert response.appointment_requested is False
134
+ assert response.show_booking_widget is False
135
+
136
+
137
+ def test_unsupported_non_latin_query_uses_supported_language_fallback():
138
+ agent = _agent_for_language_preprocessing(language="en")
139
+
140
+ response = agent.query(
141
+ "\u0414\u043e\u0431\u0440\u044b\u0439 \u0434\u0435\u043d\u044c, "
142
+ "\u0445\u043e\u0447\u0443 \u0443\u0437\u043d\u0430\u0442\u044c "
143
+ "\u0431\u043e\u043b\u044c\u0448\u0435 \u043e EMBA"
144
+ )
145
+
146
+ assert response.response == LANGUAGE_FALLBACK_MESSAGE["en"]
147
+ assert response.language == "en"
148
+ assert response.appointment_requested is False
149
+ assert response.show_booking_widget is False
150
 
151
 
152
  if __name__ == "__main__":
tests/test_scope_guardian_offtopic.py CHANGED
@@ -11,12 +11,32 @@ from src.rag.scope_guardian import ScopeGuardian
11
  "Welche Restaurants können Sie empfehlen?",
12
  "What is the weather today?",
13
  "Who won the sports match?",
 
14
  ],
15
  )
16
  def test_off_topic_queries_are_detected(message):
17
  assert ScopeGuardian.check_scope(message) == "off_topic"
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  @pytest.mark.parametrize(
21
  "message",
22
  [
@@ -29,6 +49,18 @@ def test_on_topic_queries_remain_allowed(message):
29
  assert ScopeGuardian.check_scope(message) == "on_topic"
30
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def test_financial_planning_queries_keep_their_classification():
33
  assert ScopeGuardian.check_scope("Can you create a payment plan?") == "financial_planning"
34
 
 
11
  "Welche Restaurants können Sie empfehlen?",
12
  "What is the weather today?",
13
  "Who won the sports match?",
14
+ "Where should I travel between locations in Switzerland?",
15
  ],
16
  )
17
  def test_off_topic_queries_are_detected(message):
18
  assert ScopeGuardian.check_scope(message) == "off_topic"
19
 
20
 
21
+ def test_german_off_topic_redirect_acknowledges_restaurant_subject():
22
+ message = ScopeGuardian.get_redirect_message("off_topic", "de")
23
+
24
+ assert "Restaurants" in message
25
+ assert "kann ich leider nicht beraten" in message
26
+ assert "HSG Executive MBA" in message
27
+
28
+
29
+ @pytest.mark.parametrize(
30
+ "message",
31
+ [
32
+ "Any good movies near St. Gallen?",
33
+ "Welche Filme laufen heute?",
34
+ ],
35
+ )
36
+ def test_movie_queries_are_detected_as_off_topic(message):
37
+ assert ScopeGuardian.check_scope(message) == "off_topic"
38
+
39
+
40
  @pytest.mark.parametrize(
41
  "message",
42
  [
 
49
  assert ScopeGuardian.check_scope(message) == "on_topic"
50
 
51
 
52
+ @pytest.mark.parametrize(
53
+ "message",
54
+ [
55
+ "Is public transport included between modules?",
56
+ "How do participants travel between module locations?",
57
+ "Gibt es Transport zwischen den Modulen?",
58
+ ],
59
+ )
60
+ def test_programme_travel_context_remains_allowed(message):
61
+ assert ScopeGuardian.check_scope(message) == "on_topic"
62
+
63
+
64
  def test_financial_planning_queries_keep_their_classification():
65
  assert ScopeGuardian.check_scope("Can you create a payment plan?") == "financial_planning"
66
 
tests/test_uat_llm_judge.py CHANGED
@@ -328,6 +328,10 @@ def _judge_case(case: UATCase, transcript: list[dict[str, Any]]) -> dict[str, An
328
  "booking section at the bottom of the page or following the correct advisor/contact "
329
  "path. Do not fail a case because a hidden widget flag is absent or false. "
330
  "Do not require exact phrasing. Reward helpful, grounded, concise advisory answers. "
 
 
 
 
331
  "Penalize wrong programme facts, wrong language, unsupported promises, hidden/internal "
332
  "architecture talk, missed booking or handover behavior, and rude tone. "
333
  "Return only valid JSON."
 
328
  "booking section at the bottom of the page or following the correct advisor/contact "
329
  "path. Do not fail a case because a hidden widget flag is absent or false. "
330
  "Do not require exact phrasing. Reward helpful, grounded, concise advisory answers. "
331
+ "For mixed-language clarification scenarios, accept a concise English clarification "
332
+ "that asks whether to continue in English or German. Do not require the clarification "
333
+ "itself to be in German, and do not require a greeting: the chat UI already shows a "
334
+ "separate prewritten greeting before the assistant response. "
335
  "Penalize wrong programme facts, wrong language, unsupported promises, hidden/internal "
336
  "architecture talk, missed booking or handover behavior, and rude tone. "
337
  "Return only valid JSON."