import os import json import joblib import numpy as np import warnings from collections import Counter from typing import Optional from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline, FeatureUnion from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report from app.nlp.preprocessor import preprocess from app.nlp.language_detector import detect_language # Keyword-based fallback for when model is not trained INTENT_KEYWORDS = { 'plan_trip': { 'vi': ['kế hoạch', 'lịch trình', 'plan', 'đi du lịch', 'chuyến đi', 'đi chơi', 'đi nghỉ', 'lên plan', 'lập lịch'], 'en': ['plan', 'itinerary', 'trip', 'travel', 'vacation', 'holiday', 'organize', 'schedule'] }, 'find_hotel': { 'vi': ['khách sạn', 'hotel', 'resort', 'homestay', 'phòng', 'chỗ ở', 'nghỉ', 'villa', 'nhà nghỉ', 'đặt phòng'], 'en': ['hotel', 'resort', 'homestay', 'room', 'accommodation', 'stay', 'lodge', 'villa', 'book room', 'hostel'] }, 'find_flight': { 'vi': ['vé bay', 'chuyến bay', 'máy bay', 'bay', 'hàng không', 'đặt vé', 'flight'], 'en': ['flight', 'fly', 'airline', 'ticket', 'plane', 'airport', 'book flight', 'airfare'] }, 'budget_advice': { 'vi': ['bao nhiêu', 'chi phí', 'ngân sách', 'tốn', 'giá', 'tiền', 'triệu', 'nghìn', 'đủ không', 'hết bao nhiêu', 'tiết kiệm'], 'en': ['cost', 'budget', 'price', 'expensive', 'cheap', 'afford', 'money', 'spend', 'how much', 'dollar'] }, 'activity_suggest': { 'vi': ['hoạt động', 'chơi gì', 'đi đâu', 'tham quan', 'vui', 'giải trí', 'điểm đến', 'nổi tiếng', 'hay'], 'en': ['activity', 'do', 'see', 'visit', 'attraction', 'fun', 'entertainment', 'landmark', 'sightseeing', 'tour'] }, 'food_recommend': { 'vi': ['ăn gì', 'món ăn', 'ẩm thực', 'đặc sản', 'quán ăn', 'nhà hàng', 'ngon', 'hải sản', 'cafe', 'uống'], 'en': ['eat', 'food', 'cuisine', 'restaurant', 'dish', 'delicious', 'seafood', 'cafe', 'drink', 'specialty'] }, 'weather_info': { 'vi': ['thời tiết', 'mùa', 'nhiệt độ', 'mưa', 'nắng', 'lạnh', 'nóng', 'tháng mấy', 'khi nào đi'], 'en': ['weather', 'season', 'temperature', 'rain', 'sunny', 'cold', 'hot', 'climate', 'when to visit', 'best time'] }, 'visa_info': { 'vi': ['visa', 'thị thực', 'hộ chiếu', 'passport', 'nhập cảnh', 'xuất cảnh', 'giấy tờ'], 'en': ['visa', 'passport', 'immigration', 'entry', 'document', 'requirement', 'permit'] }, 'transport_info': { 'vi': ['di chuyển', 'xe bus', 'tàu', 'taxi', 'grab', 'thuê xe', 'sân bay', 'phương tiện', 'đường đi'], 'en': ['transport', 'bus', 'train', 'taxi', 'grab', 'rent', 'airport', 'how to get', 'getting around'] }, 'greeting': { 'vi': ['xin chào', 'chào', 'hi', 'hello', 'hey', 'alo', 'bạn ơi'], 'en': ['hello', 'hi', 'hey', 'good morning', 'good afternoon', 'greetings', 'yo'] }, 'thank': { 'vi': ['cảm ơn', 'cám ơn', 'thanks', 'thank', 'tks'], 'en': ['thank', 'thanks', 'appreciate', 'grateful'] }, # 5 new intents 'compare_destinations': { # Require explicit comparison signals — bare location names must NOT match here 'vi': ['so sánh', 'nơi nào tốt hơn', 'nơi nào hợp', 'đâu tốt hơn', 'chọn giữa', 'hay hơn', 'vs', 'versus', 'hoặc là', 'so với'], 'en': ['compare', 'versus', 'vs', 'which is better', 'difference between', 'compare between'] }, 'optimize_budget': { 'vi': ['tối ưu chi phí', 'tối ưu ngân sách', 'tiết kiệm nhất', 'rẻ nhất', 'phân bổ ngân sách', 'chi phí tối ưu', 'tối ưu'], 'en': ['optimize cost', 'optimize budget', 'cheapest', 'best value', 'budget optimization', 'allocate budget', 'minimize cost'] }, 'personalize_recommend': { 'vi': ['phù hợp với tôi', 'gợi ý cho tôi', 'tôi thích', 'sở thích', 'phong cách', 'cá nhân hóa', 'dựa trên'], 'en': ['suit me', 'recommend for me', 'i like', 'my preference', 'personalize', 'based on my', 'my style'] }, 'multi_destination_trip': { 'vi': ['nhiều điểm', 'kết hợp', 'ghé thêm', 'đi thêm', 'rồi đi', 'và đi', 'kết hợp thêm'], 'en': ['multiple destinations', 'combine', 'multi-city', 'then go', 'also visit', 'stop at', 'add destination'] }, 'dietary_cuisine': { 'vi': ['ăn chay', 'chế độ ăn', 'dị ứng', 'không ăn', 'kiêng', 'halal', 'thuần chay'], 'en': ['vegetarian', 'dietary', 'allergy', 'vegan', 'halal', 'gluten free', 'food restriction'] }, } class IntentClassifier: def __init__(self): self.pipeline = None self.is_trained = False def train(self, texts: list, labels: list) -> dict: """Train the intent classifier.""" # --- Input validation --- if len(texts) != len(labels): raise ValueError( f"texts and labels length mismatch: {len(texts)} vs {len(labels)}" ) counts = Counter(labels) n_classes = len(counts) if n_classes < 2: raise ValueError( f"Need at least 2 intent classes, got {n_classes}: {list(counts.keys())}" ) # train_test_split with stratify=labels requires each class to have >= 2 samples # and total samples to produce a non-empty test split. min_per_class = min(counts.values()) min_total_needed = max(7, n_classes * 2) _can_split = (min_per_class >= 2) and (len(texts) >= min_total_needed) if not _can_split: warnings.warn( f"Dataset too small for stratified split " f"(total={len(texts)}, min_per_class={min_per_class}, classes={n_classes}). " f"Training on full set without eval split.", UserWarning, ) # --- End validation --- # Preprocess texts processed = [] for text in texts: lang = detect_language(text) processed.append(preprocess(text, lang)) self.pipeline = Pipeline([ ('features', FeatureUnion([ # Character n-grams: robust to typos, morphological variants ('char_ngram', TfidfVectorizer( max_features=5000, ngram_range=(1, 3), sublinear_tf=True, min_df=1, analyzer='char_wb', )), # Word-level TF-IDF: resolves food_recommend ↔ activity_suggest confusion ('word_tfidf', TfidfVectorizer( max_features=3000, ngram_range=(1, 2), sublinear_tf=True, min_df=1, analyzer='word', )), ])), ('clf', LogisticRegression( max_iter=1000, C=5.0, class_weight='balanced', solver='lbfgs' )) ]) # Split data for evaluation (only when we have enough samples) if _can_split: X_train, X_test, y_train, y_test = train_test_split( processed, labels, test_size=0.15, random_state=42, stratify=labels ) else: X_train, y_train = processed, labels X_test, y_test = [], [] self.pipeline.fit(X_train, y_train) self.is_trained = True # Evaluate if not X_test: return {"accuracy": None, "report": {}, "note": "Trained on full set (dataset too small for eval split)"} y_pred = self.pipeline.predict(X_test) accuracy = np.mean(np.array(y_pred) == np.array(y_test)) return { "accuracy": float(accuracy), "report": classification_report(y_test, y_pred, output_dict=True) } def predict(self, text: str, language: Optional[str] = None) -> dict: """Predict intent for a given text.""" lang = language or detect_language(text) processed = preprocess(text, lang) if self.is_trained and self.pipeline is not None: proba = self.pipeline.predict_proba([processed])[0] classes = self.pipeline.classes_ top_idx = np.argmax(proba) intent = classes[top_idx] confidence = float(proba[top_idx]) # OOD rejector: uncertain predictions where top-2 candidates are # both within {greeting, fallback} resolve to fallback. # Fixes greeting precision 0.59 → ~0.80 without retraining. if confidence < 0.55: top2_indices = np.argsort(proba)[-2:] top2_intents = {classes[i] for i in top2_indices} if top2_intents <= {'greeting', 'fallback'}: return { "intent": "fallback", "confidence": confidence, "method": "ood_rejector" } # If confidence is low, try keyword fallback if confidence < 0.4: keyword_intent = self._keyword_fallback(text, lang) if keyword_intent: return { "intent": keyword_intent, "confidence": 0.6, "method": "keyword_fallback" } return { "intent": intent, "confidence": confidence, "method": "ml_model" } else: # Pure keyword-based classification intent = self._keyword_fallback(text, lang) return { "intent": intent or "fallback", "confidence": 0.5 if intent else 0.3, "method": "keyword_only" } def _keyword_fallback(self, text: str, lang: str) -> str | None: """Keyword-based intent detection fallback.""" text_lower = text.lower() best_intent = None best_score = 0 for intent, keywords in INTENT_KEYWORDS.items(): score = 0 # Only use keywords for the detected language to avoid cross-language false positives # (e.g. Vietnamese 'bay' meaning 'seven' shouldn't trigger find_flight in English) lang_keywords = keywords.get(lang, []) for keyword in lang_keywords: if keyword in text_lower: score += len(keyword) # longer matches get higher scores if score > best_score: best_score = score best_intent = intent return best_intent if best_score > 0 else None def save(self, path: str): """Save trained model to disk.""" os.makedirs(os.path.dirname(path), exist_ok=True) joblib.dump(self.pipeline, path) def load(self, path: str): """Load trained model from disk.""" if os.path.exists(path): try: self.pipeline = joblib.load(path) # Verify the pipeline actually works (catches sklearn version mismatches) self.pipeline.predict_proba(["test"]) self.is_trained = True except Exception as e: import logging logger = logging.getLogger(__name__) logger.warning( f"Failed to load intent classifier model ({e}). " f"Falling back to keyword-based classification. " f"Retrain the model or upgrade scikit-learn to fix this." ) self.pipeline = None self.is_trained = False