frimeet-api-nlp / tests /test_place_chat_intent_parser.py
AlleksDev's picture
Fix: Categories
f10ff80 unverified
Raw
History Blame Contribute Delete
42.1 kB
import pytest
from app.modules.places.domain.chat_intent import (
ClarificationChoice,
ConversationState,
ExplicitTargetLocation,
PendingClarification,
PendingClarificationOption,
PlaceCategoryInference,
PlaceReference,
)
from app.modules.places.infrastructure.bert_intent_extractor import (
BertPlaceIntentExtractor,
)
from app.modules.places.domain.errors import ClarificationStateMismatchError
from app.modules.places.infrastructure.deterministic_intent_parser import (
DeterministicPlaceChatIntentParser,
PlaceChatTaxonomy,
PreferenceDefinition,
load_place_chat_taxonomy,
)
@pytest.mark.parametrize(
"message",
("ola", "hola", "holi", "buenas", "¿qué tal?"),
)
def test_standalone_greetings_return_a_friendly_non_search_response(
message: str,
) -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message=message,
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "no_match"
assert intent.unresolved == ("non_search_input",)
assert intent.response_message is not None
assert "hola" in intent.response_message.casefold()
assert "lugar o actividad" in intent.response_message.casefold()
assert intent.semantic_query == ""
assert intent.alternatives == ()
def test_greeting_with_a_place_request_remains_actionable() -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message="hola, busco una cafeteria tranquila",
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == "cafe"
assert "tranquilo" in intent.soft_preferences
assert "non_search_input" not in intent.unresolved
@pytest.mark.parametrize(
"message",
(
"quiero ir por baguettes",
"busco un lugar que venda baguete",
"donde venden baggets",
),
)
def test_baguette_variants_resolve_to_bakery_without_losing_product_text(
message: str,
) -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message=message,
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == "bakery"
assert intent.raw_category_phrase
assert any(
term in intent.semantic_query
for term in ("baguette", "baguete", "baggets")
)
def test_greeting_repeats_the_same_pending_clarification() -> None:
pending = PendingClarification(
clarification_id="pending-category-1",
kind="intent_category",
options=(
PendingClarificationOption(
option_id="cafe",
value="cafe",
label="Cafetería",
),
PendingClarificationOption(
option_id="restaurant",
value="restaurant",
label="Restaurante",
),
),
)
intent = DeterministicPlaceChatIntentParser().parse(
message="hola",
state=ConversationState(pending_clarification=pending),
has_user_location=True,
)
assert intent.action == "clarification"
assert intent.clarification is not None
assert intent.clarification.clarification_id == pending.clarification_id
assert [option.option_id for option in intent.clarification.options] == [
"cafe",
"restaurant",
]
repeated = intent.state_patch.pending_clarification
assert repeated is not None
assert repeated.clarification_id == pending.clarification_id
assert repeated.options == pending.options
assert intent.response_message is not None
assert intent.response_message.startswith("¡Hola!")
def test_transient_place_ids_are_removed_from_filters_and_state_patch() -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message="una cafeteria",
state=ConversationState(
hard_filters={
"city": "Puebla",
"place_ids": ("stale-nearby-id",),
},
),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.hard_filters == {"city": "Puebla"}
assert intent.state_patch.hard_filters == {"city": "Puebla"}
assert intent.state_patch.as_dict()["hard_filters"] == {"city": "Puebla"}
def test_category_is_not_polluted_by_the_location_anchor() -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message="Recomiendame alguna cafeteria cerca del Parque Central",
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == "cafe"
assert intent.category_values[:5] == (
"cafe",
"café",
"cafeteria",
"coffee_shop",
"coffee shop",
)
assert "juice_bar" in intent.category_values
assert intent.semantic_query == "cafe cafeteria"
assert intent.location.scope == "target_results"
assert intent.location.anchor_text == "parque central"
def test_reference_and_location_scope_ambiguity_requires_clarification() -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message="cafeterias como la de Hello Kitty cerca del Parque Central",
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "clarification"
assert intent.unresolved == ("location_scope",)
assert [alternative.key for alternative in intent.alternatives] == [
"target_results",
"reference_entity",
]
assert intent.semantic_query == ""
assert intent.state_patch.target_category == "cafe"
assert intent.state_patch.reference is not None
pending = intent.state_patch.pending_clarification
assert pending is not None
assert pending.kind == "location_scope"
assert pending.location_anchor_text == "parque central"
assert pending.clarification_id
assert [option.option_id for option in pending.options] == [
"target_results",
"reference_entity",
]
assert intent.clarification is not None
assert intent.clarification.clarification_id == pending.clarification_id
def test_continuation_inherits_category_and_adds_price_filter() -> None:
state = ConversationState(
target_category="cafe",
soft_preferences=("tematica",),
reference=PlaceReference(entity="hello kitty"),
)
intent = DeterministicPlaceChatIntentParser().parse(
message="mas barato y no tan lejos",
state=state,
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == "cafe"
assert intent.hard_filters["price_preference"] == "lower"
assert intent.reference == state.reference
assert intent.location.source == "user_current"
assert intent.state_patch.as_dict()["hard_filters"] == {
"price_preference": "lower"
}
def test_new_open_activity_does_not_inherit_previous_category() -> None:
state = ConversationState(
target_category="park",
hard_filters={"price_preference": "lower"},
soft_preferences=("naturaleza",),
exclusions=("ruido",),
reference=PlaceReference(entity="parque central"),
explicit_target_location=ExplicitTargetLocation(
anchor_text="parque central"
),
)
intent = DeterministicPlaceChatIntentParser().parse(
message="quiero ir a nadar",
state=state,
has_user_location=True,
)
patch = intent.state_patch.as_dict()
assert intent.action == "recommendations"
assert intent.target_category is None
assert intent.category_source == "unresolved"
assert "nadar" in intent.semantic_query
assert "park" not in intent.semantic_query
assert "parque" not in intent.semantic_query
assert "naturaleza" not in intent.soft_preferences
assert "ruido" not in intent.exclusions
assert intent.reference is None
assert intent.location.source == "conversation_state"
assert patch["target_category"] is None
assert patch["hard_filters"] == {}
assert patch["soft_preferences"] == list(intent.soft_preferences)
assert patch["exclusions"] == []
assert patch["reference"] is None
assert "explicit_target_location" not in patch
def test_new_category_clears_incompatible_reference_and_preferences() -> None:
state = ConversationState(
target_category="cafe",
soft_preferences=("hello_kitty", "tematica"),
reference=PlaceReference(entity="hello kitty"),
explicit_target_location=ExplicitTargetLocation(anchor_text="parque central"),
)
intent = DeterministicPlaceChatIntentParser().parse(
message="mejor recomiendame un parque",
state=state,
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == "park"
assert intent.reference is None
assert intent.soft_preferences == ()
assert intent.state_patch.clear_reference is True
assert intent.location.source == "conversation_state"
assert "explicit_target_location" not in intent.state_patch.as_dict()
def test_explicit_current_location_replaces_a_stored_anchor() -> None:
state = ConversationState(
target_category="cafe",
explicit_target_location=ExplicitTargetLocation(
anchor_text="parque central"
),
)
intent = DeterministicPlaceChatIntentParser().parse(
message="que sea mas barato cerca de mi",
state=state,
has_user_location=True,
)
assert intent.target_category == "cafe"
assert intent.location.source == "user_current"
assert intent.state_patch.as_dict()["explicit_target_location"] is None
def test_open_activity_then_explicit_category_drops_stale_activity_preference() -> None:
parser = DeterministicPlaceChatIntentParser()
swimming = parser.parse(
message="quiero ir a nadar",
state=ConversationState(),
has_user_location=True,
)
state = ConversationState(
target_category=swimming.target_category,
soft_preferences=swimming.soft_preferences,
)
cafe = parser.parse(
message="ahora quiero una cafeteria tranquila",
state=state,
has_user_location=True,
)
assert cafe.target_category == "cafe"
assert "tranquilo" in cafe.soft_preferences
assert "para_refrescarse" not in cafe.soft_preferences
assert "para_refrescarse" not in cafe.semantic_query
def test_new_search_for_same_category_clears_old_theme_and_reference() -> None:
state = ConversationState(
target_category="cafe",
soft_preferences=("hello_kitty", "tematica"),
reference=PlaceReference(entity="hello kitty"),
)
intent = DeterministicPlaceChatIntentParser().parse(
message="ahora quiero una cafeteria tranquila",
state=state,
has_user_location=True,
)
assert intent.target_category == "cafe"
assert intent.reference is None
assert intent.soft_preferences == ("tranquilo",)
patch = intent.state_patch.as_dict()
assert patch["reference"] is None
assert patch["soft_preferences"] == ["tranquilo"]
@pytest.mark.parametrize(
"message",
("dame una opcion mas barata", "recomiendame otra"),
)
def test_common_continuation_commands_keep_the_current_category(
message: str,
) -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message=message,
state=ConversationState(target_category="park"),
has_user_location=True,
)
assert intent.target_category == "park"
assert intent.category_source == "conversation_state"
@pytest.mark.parametrize(
("message", "preference"),
(
("estacionamiento", "estacionamiento"),
("banos", "banos"),
("tenga banos", "banos"),
("entretenimiento", "entretenimiento"),
("pantallas", "pantallas"),
("asientos", "asientos"),
("comida exterior", "comida_exterior"),
("mochila", "mochila"),
),
)
def test_bare_attribute_requests_are_preserved_as_preferences(
message: str,
preference: str,
) -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message=message,
state=ConversationState(),
has_user_location=True,
)
assert preference in intent.soft_preferences
def test_explicit_radius_is_strict_and_removed_from_anchor_text() -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message="una cafeteria cerca del Parque Central a 2 km",
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.location.anchor_text == "parque central"
assert intent.location.radius_meters == 2000
assert intent.location.strict_radius is True
def test_missing_category_preserves_open_concept_for_retrieval() -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message="quiero un lugar bonito para salir",
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category is None
assert "bonito" in intent.semantic_query
assert intent.unresolved == ("target_category",)
assert intent.state_patch.pending_clarification is None
def test_unknown_category_word_is_not_dropped_before_dense_retrieval() -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message="quiero donas artesanales",
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category is None
assert "donas" in intent.semantic_query
assert intent.raw_category_phrase is not None
def test_food_activity_defaults_to_restaurant_without_clarification() -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message="quiero comer algo",
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == "restaurant"
assert {
"restaurant",
"restaurante",
"comedor",
"fast_food_restaurant",
"taqueria",
"seafood_restaurant",
} <= set(intent.category_values)
assert intent.semantic_query == "restaurant restaurante comer"
assert intent.state_patch.target_category == "restaurant"
assert intent.confidence == 0.88
def test_explicit_category_wins_over_an_activity_default() -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message="quiero comer algo en una cafeteria",
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == "cafe"
assert intent.category_values[:5] == (
"cafe",
"café",
"cafeteria",
"coffee_shop",
"coffee shop",
)
assert "juice_bar" in intent.category_values
assert intent.state_patch.target_category == "cafe"
def test_other_high_confidence_activities_use_helpful_defaults() -> None:
parser = DeterministicPlaceChatIntentParser()
examples = {
"quiero hacer ejercicio": "sports",
"quiero ver una pelicula": "cinema",
"quiero comprar algo": "shopping",
"necesito un lugar donde dormir": "lodging",
}
for message, expected_category in examples.items():
intent = parser.parse(
message=message,
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == expected_category
@pytest.mark.parametrize(
("message", "expected_category", "expected_query_term"),
(
("parque", "park", "parque"),
("quiero ir al parque", "park", "parque"),
("cine", "cinema", "cine"),
("quiero ir al cine", "cinema", "cine"),
("compras", "shopping", "compras"),
("shopping", "shopping", "shopping"),
("quiero ir de compras", "shopping", "compras"),
),
)
def test_short_category_requests_keep_local_retrieval_vocabulary(
message: str,
expected_category: str,
expected_query_term: str,
) -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message=message,
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == expected_category
assert expected_query_term in intent.semantic_query.split()
def test_semantic_activity_inference_handles_non_literal_food_request() -> None:
class FoodActivityClassifier:
def classify(self, text: str) -> PlaceCategoryInference | None:
if "apetecen" in text and "tacos" in text:
return PlaceCategoryInference(
category="restaurant",
confidence=0.82,
source="semantic_activity",
)
return None
intent = DeterministicPlaceChatIntentParser(
activity_classifier=FoodActivityClassifier(),
).parse(
message="Después de todo el día se me apetecen unos buenos tacos",
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == "restaurant"
assert intent.confidence == 0.82
def test_new_clear_intent_cancels_a_stale_pending_clarification() -> None:
state = ConversationState(
target_category="cafe",
soft_preferences=("tematica",),
reference=PlaceReference(entity="hello kitty"),
pending_clarification=PendingClarification(
kind="location_scope",
location_anchor_text="parque central",
),
)
intent = DeterministicPlaceChatIntentParser().parse(
message="mejor quiero comer algo",
state=state,
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == "restaurant"
assert intent.reference is None
assert intent.state_patch.clear_reference is True
assert intent.state_patch.clear_pending_clarification is True
assert intent.state_patch.as_dict()["pending_clarification"] is None
def test_new_open_activity_cancels_a_stale_pending_clarification() -> None:
state = ConversationState(
target_category="park",
soft_preferences=("naturaleza",),
pending_clarification=PendingClarification(
clarification_id="pending-location-scope",
kind="location_scope",
location_anchor_text="parque central",
),
)
intent = DeterministicPlaceChatIntentParser().parse(
message="quiero ir a nadar",
state=state,
has_user_location=True,
)
patch = intent.state_patch.as_dict()
assert intent.action == "recommendations"
assert intent.clarification is None
assert intent.target_category is None
assert "nadar" in intent.semantic_query
assert "park" not in intent.semantic_query
assert "parque" not in intent.semantic_query
assert patch["target_category"] is None
assert patch["pending_clarification"] is None
@pytest.mark.parametrize("message", ("nadar", "piscina"))
def test_short_open_activity_escapes_a_stale_pending_clarification(
message: str,
) -> None:
state = ConversationState(
target_category="park",
pending_clarification=PendingClarification(
clarification_id="pending-category",
kind="target_category",
options=(
PendingClarificationOption(
option_id="park",
value="park",
label="Parques",
),
PendingClarificationOption(
option_id="cafe",
value="cafe",
label="Cafeterías",
),
),
),
)
intent = DeterministicPlaceChatIntentParser().parse(
message=message,
state=state,
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.clarification is None
assert intent.target_category is None
assert intent.state_patch.as_dict()["pending_clarification"] is None
def test_refreshing_preference_is_not_promoted_to_sports_category() -> None:
base_taxonomy = load_place_chat_taxonomy()
taxonomy = PlaceChatTaxonomy(
version=base_taxonomy.version,
categories=base_taxonomy.categories,
preferences=(
*base_taxonomy.preferences,
PreferenceDefinition(
canonical="para_refrescarse",
aliases=("nadar", "natacion", "alberca", "piscina"),
implies=(),
),
),
)
class SportsClassifier:
def classify(self, _text: str) -> PlaceCategoryInference | None:
return PlaceCategoryInference(
category="sports",
confidence=0.99,
source="semantic_activity",
category_values=("sports",),
)
def rank_supported(self, _text: str, limit: int = 3) -> tuple[object, ...]:
return ()
intent = DeterministicPlaceChatIntentParser(
taxonomy=taxonomy,
activity_classifier=SportsClassifier(),
).parse(
message="quiero ir a nadar",
state=ConversationState(target_category="park"),
has_user_location=True,
)
assert intent.target_category is None
assert intent.category_source == "unresolved"
assert "para_refrescarse" in intent.soft_preferences
assert "sports" not in intent.semantic_query
assert intent.state_patch.as_dict()["target_category"] is None
def test_common_category_typo_is_normalized_by_the_taxonomy() -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message="recomiendame una cafetria tranquila",
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == "cafe"
assert "tranquilo" in intent.soft_preferences
def test_pending_scope_can_be_resolved_on_the_following_turn() -> None:
parser = DeterministicPlaceChatIntentParser()
first = parser.parse(
message="cafeterias como la de Hello Kitty cerca del Parque Central",
state=ConversationState(),
has_user_location=True,
)
state = ConversationState(
target_category=first.target_category,
soft_preferences=first.soft_preferences,
exclusions=first.exclusions,
reference=first.reference,
pending_clarification=first.state_patch.pending_clarification,
taxonomy_version="places-taxonomy-v1",
)
nearby_results = parser.parse(
message="la primera opcion",
state=state,
has_user_location=True,
)
similar_near_user = parser.parse(
message="la segunda, quiero lugares parecidos",
state=state,
has_user_location=True,
)
assert nearby_results.action == "recommendations"
assert nearby_results.location.anchor_text == "parque central"
assert nearby_results.location.scope == "target_results"
assert nearby_results.state_patch.clear_pending_clarification is True
assert similar_near_user.action == "recommendations"
assert similar_near_user.location.source == "user_current"
assert similar_near_user.reference is not None
assert similar_near_user.reference.entity == state.reference.entity
assert similar_near_user.reference.attributes == state.reference.attributes
assert similar_near_user.reference.location_hint_text == "parque central"
assert "hello kitty" in similar_near_user.semantic_query
assert similar_near_user.state_patch.clear_pending_clarification is True
def test_pending_scope_is_resolved_by_structured_choice_without_repeating() -> None:
parser = DeterministicPlaceChatIntentParser()
first = parser.parse(
message="cafeterias como la de Hello Kitty cerca del Parque Central",
state=ConversationState(),
has_user_location=True,
)
pending = first.state_patch.pending_clarification
assert pending is not None
state = ConversationState(
target_category=first.target_category,
soft_preferences=first.soft_preferences,
exclusions=first.exclusions,
reference=first.reference,
pending_clarification=pending,
)
resolved = parser.parse(
message="Buscar cerca del Parque Central",
state=state,
has_user_location=True,
clarification_choice=ClarificationChoice(
clarification_id=pending.clarification_id,
option_id="target_results",
),
)
assert resolved.action == "recommendations"
assert resolved.clarification is None
assert resolved.state_patch.clear_pending_clarification is True
assert resolved.location.anchor_text == "parque central"
def test_structured_choice_rejects_a_stale_clarification_id() -> None:
parser = DeterministicPlaceChatIntentParser()
first = parser.parse(
message="cafeterias como la de Hello Kitty cerca del Parque Central",
state=ConversationState(),
has_user_location=True,
)
pending = first.state_patch.pending_clarification
assert pending is not None
with pytest.raises(ClarificationStateMismatchError):
parser.parse(
message="Restaurantes",
state=ConversationState(pending_clarification=pending),
has_user_location=True,
clarification_choice=ClarificationChoice(
clarification_id="00000000-0000-4000-8000-000000000000",
option_id="restaurant",
),
)
def test_unrecognized_legacy_reply_keeps_the_same_structured_options() -> None:
parser = DeterministicPlaceChatIntentParser()
first = parser.parse(
message="cafeterias como la de Hello Kitty cerca del Parque Central",
state=ConversationState(),
has_user_location=True,
)
pending = first.state_patch.pending_clarification
assert pending is not None
repeated = parser.parse(
message="no se",
state=ConversationState(
target_category=first.target_category,
reference=first.reference,
pending_clarification=pending,
),
has_user_location=True,
)
assert repeated.action == "clarification"
assert repeated.clarification is not None
assert repeated.clarification.clarification_id == pending.clarification_id
assert len(repeated.clarification.options) == 2
def test_exclusion_is_kept_out_of_positive_preferences() -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message="una cafeteria tematica pero sin musica",
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert "tematica" in intent.soft_preferences
assert "musica" not in intent.soft_preferences
assert intent.exclusions == ("musica",)
assert intent.state_patch.as_dict()["exclusions"] == ["musica"]
def test_unknown_exclusion_is_preserved_and_does_not_consume_positive_context() -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message="una cafeteria sin ruido con terraza",
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.exclusions == ("ruido",)
assert "terraza" in intent.semantic_query
assert "ruido" not in intent.semantic_query
def test_bert_slots_drive_open_category_exclusion_and_location_before_legacy() -> None:
message = "quiero donas artesanales sin ruido cerca de la plaza"
def token(value: str, entity: str, score: float) -> dict[str, object]:
start = message.index(value)
return {
"entity": entity,
"score": score,
"start": start,
"end": start + len(value),
}
class Classifier:
def __call__(self, _text: str) -> list[dict[str, object]]:
return [
token("donas", "B-CATEGORY", 0.96),
token("artesanales", "I-CATEGORY", 0.94),
token("ruido", "B-EXCLUSION", 0.95),
token("plaza", "B-LOCATION", 0.93),
]
extractor = BertPlaceIntentExtractor(
"places-intent-test",
model_version="test-v1",
classifier=Classifier(),
)
intent = DeterministicPlaceChatIntentParser(
contextual_extractor=extractor,
).parse(
message=message,
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == "donas artesanales"
assert intent.category_values == ("donas artesanales",)
assert intent.raw_category_phrase == "donas artesanales"
assert intent.exclusions == ("ruido",)
assert intent.location.scope == "target_results"
assert intent.location.anchor_text == "plaza"
assert "donas artesanales" in intent.semantic_query
assert "ruido" not in intent.semantic_query
assert intent.state_patch.target_category == "donas artesanales"
assert intent.intent_model_version == (
"bert-token:places-intent-test@test-v1+deterministic-open-v3"
)
def test_bert_raw_category_is_aligned_semantically_to_dynamic_storage_values() -> None:
message = "quiero donas artesanales"
start = message.index("donas")
class TokenClassifier:
def __call__(self, _text: str) -> list[dict[str, object]]:
return [
{
"entity": "B-CATEGORY",
"score": 0.96,
"start": start,
"end": len(message),
}
]
class Concept:
id = "donut_shop"
storage_values = ("bakery", "dessert")
class ActivityClassifier:
concepts = (Concept(),)
def classify(self, text: str) -> PlaceCategoryInference | None:
assert text == "donas artesanales"
return PlaceCategoryInference(
category="donut_shop",
confidence=0.91,
source="semantic_activity",
category_values=("bakery", "dessert"),
label="Donas",
)
intent = DeterministicPlaceChatIntentParser(
activity_classifier=ActivityClassifier(),
contextual_extractor=BertPlaceIntentExtractor(
"places-intent-test",
classifier=TokenClassifier(),
),
).parse(
message=message,
state=ConversationState(),
has_user_location=True,
)
assert intent.target_category == "donut_shop"
assert intent.category_values == ("bakery", "dessert")
assert intent.raw_category_phrase == "donas artesanales"
def test_bert_failure_falls_open_and_exposes_fallback_model_version() -> None:
class BrokenClassifier:
def __call__(self, _text: str) -> list[dict[str, object]]:
raise RuntimeError("inference backend unavailable")
extractor = BertPlaceIntentExtractor(
"broken-intent-model",
model_version="broken-v1",
classifier=BrokenClassifier(),
)
intent = DeterministicPlaceChatIntentParser(
contextual_extractor=extractor,
).parse(
message="recomiendame una cafeteria tranquila",
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == "cafe"
assert "tranquilo" in intent.soft_preferences
assert intent.intent_model_version == (
"deterministic-open-v3+bert-fallback:broken-v1"
)
def test_successful_bert_frame_does_not_reapply_manual_category_aliases() -> None:
extractor = BertPlaceIntentExtractor(
"places-intent-test",
classifier=lambda _text: [],
)
intent = DeterministicPlaceChatIntentParser(
contextual_extractor=extractor,
).parse(
message="cafeteria con una variacion no etiquetada",
state=ConversationState(),
has_user_location=True,
)
assert intent.target_category is None
assert intent.category_values == ()
assert "cafeteria" in intent.semantic_query
assert intent.category_source == "unresolved"
def test_partial_bert_frame_fuses_missing_context_slots_independently() -> None:
message = "cafeteria tranquila sin ruido cerca del centro"
category_start = message.index("cafeteria")
extractor = BertPlaceIntentExtractor(
"places-intent-test",
classifier=lambda _text: [
{
"entity": "B-CATEGORY",
"score": 0.97,
"start": category_start,
"end": category_start + len("cafeteria"),
}
],
)
intent = DeterministicPlaceChatIntentParser(
contextual_extractor=extractor,
).parse(
message=message,
state=ConversationState(),
has_user_location=True,
)
# The model-provided open category is preserved (no taxonomy remap), while
# omitted slots use narrow rollout fallbacks instead of disappearing.
assert intent.target_category == "cafeteria"
assert intent.location.scope == "target_results"
assert intent.location.source == "current_message"
assert intent.location.anchor_text == "centro"
assert intent.exclusions == ("ruido",)
assert "tranquilo" in intent.soft_preferences
assert "centro" not in intent.semantic_query
assert "ruido" not in intent.semantic_query
def test_bert_preference_reference_and_radius_remain_open_raw_signals() -> None:
message = "busco salones de te estilo Casa Azul con terraza maximo 2 km"
def token(value: str, entity: str) -> dict[str, object]:
start = message.index(value)
return {
"entity": entity,
"score": 0.94,
"start": start,
"end": start + len(value),
}
class Classifier:
def __call__(self, _text: str) -> list[dict[str, object]]:
return [
token("salones", "B-CATEGORY"),
token("de", "I-CATEGORY"),
token("te", "I-CATEGORY"),
token("Casa", "B-REFERENCE"),
token("Azul", "I-REFERENCE"),
token("terraza", "B-PREFERENCE"),
token("2", "B-RADIUS"),
token("km", "I-RADIUS"),
]
intent = DeterministicPlaceChatIntentParser(
contextual_extractor=BertPlaceIntentExtractor(
"places-intent-test",
classifier=Classifier(),
),
).parse(
message=message,
state=ConversationState(),
has_user_location=True,
)
assert intent.target_category == "salones de te"
assert intent.soft_preferences == ("terraza",)
assert intent.reference is not None
assert intent.reference.entity == "Casa Azul"
assert intent.location.source == "user_current"
assert intent.location.radius_meters == 2000
assert intent.location.strict_radius is True
def test_dynamic_category_clarification_selection_is_not_taxonomy_gated() -> None:
pending = PendingClarification(
clarification_id="dynamic-category-1",
kind="intent_category",
options=(
PendingClarificationOption(
option_id="donuts",
value="donas artesanales",
label="Donas artesanales",
),
PendingClarificationOption(
option_id="desserts",
value="postres frios",
label="Postres frios",
),
),
)
state = ConversationState(
soft_preferences=("tranquilo",),
exclusions=("ruido",),
pending_clarification=pending,
city="Puebla",
)
intent = DeterministicPlaceChatIntentParser().parse(
message="Donas artesanales",
state=state,
has_user_location=True,
clarification_choice=ClarificationChoice(
clarification_id="dynamic-category-1",
option_id="donuts",
),
)
assert intent.action == "recommendations"
assert intent.target_category == "donas artesanales"
assert intent.category_values == ("donas artesanales",)
assert intent.raw_category_phrase == "donas artesanales"
assert intent.soft_preferences == ("tranquilo",)
assert intent.exclusions == ("ruido",)
assert intent.hard_filters["city"] == "Puebla"
assert intent.state_patch.target_category == "donas artesanales"
assert intent.state_patch.clear_pending_clarification is True
assert "dynamic-clarification-v1" in intent.intent_model_version
@pytest.mark.parametrize(
("message", "expected"),
(
("la primera opcion", "park"),
("la segunda opcion", "cafe"),
("no la primera, la segunda", "cafe"),
("la primera, no la segunda", "park"),
),
)
def test_category_clarification_accepts_textual_ordinal_choice(
message: str,
expected: str,
) -> None:
pending = PendingClarification(
clarification_id="category-choice-1",
kind="intent_category",
options=(
PendingClarificationOption("park", "park", "Parques"),
PendingClarificationOption("cafe", "cafe", "Cafeterias"),
),
)
intent = DeterministicPlaceChatIntentParser().parse(
message=message,
state=ConversationState(pending_clarification=pending),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.target_category == expected
assert intent.state_patch.clear_pending_clarification is True
def test_pending_option_name_inside_a_new_request_is_not_auto_selected() -> None:
pending = PendingClarification(
clarification_id="incidental-category-1",
kind="intent_category",
options=(
PendingClarificationOption("park", "park", "Parques"),
PendingClarificationOption("cafe", "cafe", "Cafeterias"),
),
)
parser = DeterministicPlaceChatIntentParser()
restaurant = parser.parse(
message="quiero un restaurante cerca del cafe central",
state=ConversationState(pending_clarification=pending),
has_user_location=True,
)
park = parser.parse(
message="no quiero cafe, quiero un parque",
state=ConversationState(pending_clarification=pending),
has_user_location=True,
)
cafe_with_pool = parser.parse(
message="quiero cafe con alberca",
state=ConversationState(pending_clarification=pending),
has_user_location=True,
)
assert restaurant.target_category == "restaurant"
assert restaurant.location.anchor_text == "cafe central"
assert park.target_category == "park"
assert "cafe" in park.exclusions
assert cafe_with_pool.target_category == "cafe"
assert "para_refrescarse" in cafe_with_pool.soft_preferences
@pytest.mark.parametrize("message", ("un zoologico", "rio", "una playa"))
def test_nominal_new_topic_escapes_a_stale_category_clarification(
message: str,
) -> None:
pending = PendingClarification(
clarification_id="stale-category-1",
kind="intent_category",
options=(
PendingClarificationOption("park", "park", "Parques"),
PendingClarificationOption("cafe", "cafe", "Cafeterias"),
),
)
intent = DeterministicPlaceChatIntentParser().parse(
message=message,
state=ConversationState(
target_category="park",
pending_clarification=pending,
),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.clarification is None
assert intent.target_category is None
assert intent.state_patch.clear_pending_clarification is True
assert any(term in intent.semantic_query for term in ("zoologico", "rio", "playa"))
def test_open_catalog_category_values_are_rehydrated_on_following_turn() -> None:
class Concept:
id = "donut_shop"
storage_values = ("bakery", "dessert")
class ActivityClassifier:
concepts = (Concept(),)
def classify(self, text: str) -> PlaceCategoryInference | None:
if "donas" in text:
return PlaceCategoryInference(
category="donut_shop",
confidence=0.91,
source="semantic_activity",
category_values=("bakery", "dessert"),
label="Donas",
)
return None
parser = DeterministicPlaceChatIntentParser(
activity_classifier=ActivityClassifier(),
)
first = parser.parse(
message="quiero donas glaseadas",
state=ConversationState(),
has_user_location=True,
)
continued = parser.parse(
message="mas barato",
state=ConversationState(target_category=first.target_category),
has_user_location=True,
)
assert first.target_category == "donut_shop"
assert first.category_values == ("bakery", "dessert")
assert continued.target_category == "donut_shop"
assert continued.category_values == ("bakery", "dessert")
def test_generic_short_request_never_produces_an_empty_embedding_query() -> None:
intent = DeterministicPlaceChatIntentParser().parse(
message="dame opciones",
state=ConversationState(),
has_user_location=True,
)
assert intent.action == "recommendations"
assert intent.semantic_query == "dame opciones"