Spaces:
Sleeping
Sleeping
| """ | |
| Unit tests for IntentClassifier. | |
| Covers: | |
| - train() with adequate data → returns accuracy float | |
| - train() with small dataset (< min_per_class threshold) → trains on full set, | |
| returns {"accuracy": None, ...} | |
| - train() with only 1 class → raises ValueError | |
| - train() with mismatched texts/labels length → raises ValueError | |
| - predict() on untrained classifier → returns keyword_only fallback dict | |
| - predict() on trained classifier → returns dict with intent/confidence/method keys | |
| """ | |
| import warnings | |
| import pytest | |
| from app.models.intent_classifier.model import IntentClassifier | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def _make_dataset(n_per_class: int, classes=("find_hotel", "find_flight")): | |
| """Generate a synthetic balanced dataset.""" | |
| texts, labels = [], [] | |
| class_texts = { | |
| "find_hotel": [ | |
| "I need a hotel in Hanoi", | |
| "book a room for me", | |
| "looking for accommodation", | |
| "need a place to stay tonight", | |
| "where can I find a hostel", | |
| "cheap resort near the beach", | |
| "hotel with breakfast included", | |
| "book homestay", | |
| "rent a villa please", | |
| "want to book a lodge", | |
| ], | |
| "find_flight": [ | |
| "I want to book a flight", | |
| "find me cheap airfare", | |
| "flights from Hanoi to HCMC", | |
| "looking for airline tickets", | |
| "when is the next plane", | |
| "book a ticket to Paris", | |
| "search for flights", | |
| "best airline for this route", | |
| "I need to fly tomorrow", | |
| "airfare prices to Tokyo", | |
| ], | |
| "plan_trip": [ | |
| "help me plan a trip", | |
| "create an itinerary for 7 days", | |
| "plan vacation to Europe", | |
| "I want to organize my holiday", | |
| "can you make a travel plan", | |
| "schedule a trip for me", | |
| "arrange my travel", | |
| "I need a week-long travel plan", | |
| "draft a tour plan", | |
| "plan a 3-day trip to Da Nang", | |
| ], | |
| } | |
| for cls in classes: | |
| pool = class_texts.get(cls, [f"sample {cls} {i}" for i in range(20)]) | |
| for i in range(n_per_class): | |
| texts.append(pool[i % len(pool)]) | |
| labels.append(cls) | |
| return texts, labels | |
| # --------------------------------------------------------------------------- | |
| # train() — normal path | |
| # --------------------------------------------------------------------------- | |
| class TestIntentClassifierTrain: | |
| def test_train_adequate_dataset_returns_accuracy_float(self): | |
| """Normal sized dataset: train_test_split fires, accuracy is a float.""" | |
| clf = IntentClassifier() | |
| texts, labels = _make_dataset(n_per_class=8) | |
| result = clf.train(texts, labels) | |
| assert clf.is_trained is True | |
| assert "accuracy" in result | |
| assert isinstance(result["accuracy"], float) | |
| assert 0.0 <= result["accuracy"] <= 1.0 | |
| def test_train_returns_report_dict(self): | |
| """Result dict should also contain a 'report' key.""" | |
| clf = IntentClassifier() | |
| texts, labels = _make_dataset(n_per_class=8) | |
| result = clf.train(texts, labels) | |
| assert "report" in result | |
| # ---- _can_split guard ---- | |
| def test_train_small_dataset_no_split_accuracy_is_none(self): | |
| """Dataset too small for stratified split → trains on full set, | |
| accuracy == None with a UserWarning.""" | |
| clf = IntentClassifier() | |
| # Only 1 sample per class → can_split=False | |
| texts, labels = _make_dataset(n_per_class=1) | |
| with warnings.catch_warnings(record=True) as caught: | |
| warnings.simplefilter("always") | |
| result = clf.train(texts, labels) | |
| assert clf.is_trained is True | |
| assert result["accuracy"] is None | |
| warning_msgs = [str(w.message) for w in caught if issubclass(w.category, UserWarning)] | |
| assert any("full set" in m.lower() or "too small" in m.lower() for m in warning_msgs), \ | |
| f"Expected UserWarning about dataset size, got: {warning_msgs}" | |
| def test_train_exactly_threshold_triggers_split(self): | |
| """When dataset just meets the minimum threshold, split should be attempted.""" | |
| clf = IntentClassifier() | |
| # 2 classes, min_total_needed = max(7, 2*2) = 7 → 8 samples with 4 each = can_split | |
| texts, labels = _make_dataset(n_per_class=4) | |
| result = clf.train(texts, labels) | |
| # Either accuracy is a float (split succeeded) or None (edge case allowed) | |
| assert result["accuracy"] is None or isinstance(result["accuracy"], float) | |
| # ---- validation errors ---- | |
| def test_train_single_class_raises_value_error(self): | |
| """Only 1 class → ValueError (need at least 2).""" | |
| clf = IntentClassifier() | |
| texts = ["book a hotel"] * 5 | |
| labels = ["find_hotel"] * 5 | |
| with pytest.raises(ValueError, match="at least 2 intent classes"): | |
| clf.train(texts, labels) | |
| def test_train_length_mismatch_raises_value_error(self): | |
| """texts and labels length mismatch → ValueError.""" | |
| clf = IntentClassifier() | |
| with pytest.raises(ValueError, match="length mismatch"): | |
| clf.train(["text1", "text2"], ["label1"]) | |
| # --------------------------------------------------------------------------- | |
| # predict() — untrained classifier | |
| # --------------------------------------------------------------------------- | |
| class TestIntentClassifierPredict: | |
| def test_predict_untrained_returns_keyword_only(self): | |
| """Untrained classifier falls back to keyword_only method.""" | |
| clf = IntentClassifier() | |
| result = clf.predict("I want to book a hotel") | |
| assert "intent" in result | |
| assert "confidence" in result | |
| assert "method" in result | |
| assert result["method"] == "keyword_only" | |
| def test_predict_result_has_required_keys(self): | |
| """predict() always returns a dict with intent / confidence / method.""" | |
| clf = IntentClassifier() | |
| # Train minimally so the ML path is exercised | |
| texts, labels = _make_dataset(n_per_class=8) | |
| clf.train(texts, labels) | |
| result = clf.predict("I need to find a flight to Paris") | |
| assert set(result.keys()) >= {"intent", "confidence", "method"} | |
| def test_predict_confidence_between_0_and_1(self): | |
| """confidence is always in [0, 1].""" | |
| clf = IntentClassifier() | |
| texts, labels = _make_dataset(n_per_class=8) | |
| clf.train(texts, labels) | |
| result = clf.predict("book a room please") | |
| assert 0.0 <= result["confidence"] <= 1.0 | |
| def test_predict_intent_is_string(self): | |
| """intent field is always a non-empty string.""" | |
| clf = IntentClassifier() | |
| result = clf.predict("hello") | |
| assert isinstance(result["intent"], str) | |
| assert len(result["intent"]) > 0 | |
| def test_predict_with_language_hint(self): | |
| """Passing language hint doesn't crash the classifier.""" | |
| clf = IntentClassifier() | |
| result = clf.predict("khách sạn Hà Nội", language="vi") | |
| assert "intent" in result | |
| def test_predict_empty_string_returns_fallback(self): | |
| """Empty text should return a fallback dict without raising.""" | |
| clf = IntentClassifier() | |
| result = clf.predict("") | |
| assert "intent" in result | |
| # Should gracefully produce some result (keyword_only or fallback) | |
| assert result["method"] in ("keyword_only", "ml_model", "keyword_fallback", "ood_rejector") | |