Spaces:
Running
Running
Sync from GitHub 2dc8883ce0037ee296214ef58a463a871005286f
Browse files- src/const/agent_response_constants.py +11 -0
- src/rag/agent_chain.py +17 -1
- src/rag/input_handler.py +13 -0
- src/rag/language_detection.py +132 -32
- src/rag/scope_guardian.py +32 -5
- src/utils/lang.py +28 -4
- tests/test_input_handler_gibberish.py +14 -0
- tests/test_language_handling.py +135 -35
- tests/test_scope_guardian_offtopic.py +32 -0
- tests/test_uat_llm_judge.py +4 -0
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
|
| 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 |
-
#
|
| 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
|
| 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 ->
|
| 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 >
|
| 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 >
|
| 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 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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', '
|
| 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
|
| 55 |
for keyword in keywords:
|
| 56 |
-
|
|
|
|
| 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 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
| 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
|
| 13 |
queries = {
|
| 14 |
"en": "Hello, im interested in the EMBA Program",
|
| 15 |
-
"de": "Guten Tag, ich interessiere mich
|
| 16 |
-
|
| 17 |
-
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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."
|