Spaces:
Running
Running
Fix: Unprecission
Browse files- .env.example +1 -0
- README.md +18 -0
- app/modules/places/api/dependencies.py +3 -0
- app/modules/places/api/internal_chat_schemas.py +7 -0
- app/modules/places/application/use_cases/chat_place_recommendations.py +297 -15
- app/modules/places/domain/chat_intent.py +2 -0
- app/modules/places/domain/clarifications.py +9 -2
- app/modules/places/infrastructure/deterministic_intent_parser.py +161 -8
- app/modules/places/infrastructure/main_api_place_source.py +37 -4
- app/modules/places/infrastructure/open_vocabulary_category_classifier.py +20 -0
- app/modules/places/infrastructure/semantic_activity_classifier.py +7 -0
- app/shared/config/settings.py +14 -0
- docs/api_endpoints.md +22 -0
- docs/cambios_api_principal_chat_lugares.md +154 -0
- docs/cambios_app_movil_chat_lugares.md +102 -0
- tests/test_internal_places_chat.py +22 -0
- tests/test_main_api_place_source.py +31 -0
- tests/test_open_vocabulary_category_classifier.py +39 -0
- tests/test_place_chat_intent_parser.py +92 -0
- tests/test_place_chat_recommendations_use_case.py +320 -7
- tests/test_place_embedding_configuration.py +23 -0
.env.example
CHANGED
|
@@ -111,6 +111,7 @@ PLACES_CHAT_AMBIGUITY_DELTA=0.15
|
|
| 111 |
PLACES_CHAT_HYPOTHESIS_MIN_CONFIDENCE=0.60
|
| 112 |
PLACES_CHAT_HYPOTHESIS_MAX_GAP=0.15
|
| 113 |
PLACES_CHAT_DEFAULT_RADIUS_METERS=5000
|
|
|
|
| 114 |
PLACES_CHAT_RANKING_VERSION=places-chat-v2
|
| 115 |
PLACES_CHAT_INTENT_PROVIDER=deterministic
|
| 116 |
# Required only when PLACES_CHAT_INTENT_PROVIDER=bert. The model must expose
|
|
|
|
| 111 |
PLACES_CHAT_HYPOTHESIS_MIN_CONFIDENCE=0.60
|
| 112 |
PLACES_CHAT_HYPOTHESIS_MAX_GAP=0.15
|
| 113 |
PLACES_CHAT_DEFAULT_RADIUS_METERS=5000
|
| 114 |
+
PLACES_CHAT_MAX_AUTO_RADIUS_METERS=50000
|
| 115 |
PLACES_CHAT_RANKING_VERSION=places-chat-v2
|
| 116 |
PLACES_CHAT_INTENT_PROVIDER=deterministic
|
| 117 |
# Required only when PLACES_CHAT_INTENT_PROVIDER=bert. The model must expose
|
README.md
CHANGED
|
@@ -205,6 +205,14 @@ Las ambiguedades que cambiarian los resultados devuelven `action=clarification`
|
|
| 205 |
`state_patch` con `pending_clarification`; el siguiente turno puede resolverlo con frases
|
| 206 |
como "la primera opcion" o "la segunda, cerca de mi".
|
| 207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
El chat no exige que el usuario nombre siempre una categoria. Puede habilitar un BERT
|
| 209 |
fine-tuneado de token classification para extraer valores abiertos de categoria,
|
| 210 |
preferencia, exclusion, ubicacion, referencia y radio. Esos textos se alinean despues
|
|
@@ -222,6 +230,16 @@ del ranking cuando el proveedor de lugares cercanos esta configurado. El flag in
|
|
| 222 |
`PLACES_CHAT_V2_ENABLED=false` y debe activarse despues de desplegar en Go tanto el proxy
|
| 223 |
de chat como `/api/v1/internal/places/resolve-anchor`.
|
| 224 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
## SQL RDS
|
| 226 |
|
| 227 |
Contrato de referencia:
|
|
|
|
| 205 |
`state_patch` con `pending_clarification`; el siguiente turno puede resolverlo con frases
|
| 206 |
como "la primera opcion" o "la segunda, cerca de mi".
|
| 207 |
|
| 208 |
+
Los turnos puramente sociales (`hola`, `ola`, `gracias`, despedidas y confirmaciones)
|
| 209 |
+
se responden sin ejecutar clasificacion, filtro geografico ni retrieval. Un saludo que
|
| 210 |
+
tambien contiene una busqueda, por ejemplo `hola, recomiendame una cafeteria`, conserva
|
| 211 |
+
la intencion de lugares. Las hipotesis de una aclaracion solo se muestran cuando superan
|
| 212 |
+
el umbral semantico y cuentan con candidatos locales suficientes; sus IDs siguen siendo
|
| 213 |
+
tecnicos, pero sus etiquetas fallback son legibles y la localizacion final pertenece a
|
| 214 |
+
la API principal.
|
| 215 |
+
|
| 216 |
El chat no exige que el usuario nombre siempre una categoria. Puede habilitar un BERT
|
| 217 |
fine-tuneado de token classification para extraer valores abiertos de categoria,
|
| 218 |
preferencia, exclusion, ubicacion, referencia y radio. Esos textos se alinean despues
|
|
|
|
| 230 |
`PLACES_CHAT_V2_ENABLED=false` y debe activarse despues de desplegar en Go tanto el proxy
|
| 231 |
de chat como `/api/v1/internal/places/resolve-anchor`.
|
| 232 |
|
| 233 |
+
Un radio implicito inicia con `PLACES_CHAT_DEFAULT_RADIUS_METERS` y, si no produce
|
| 234 |
+
lugares o evidencia de la categoria, puede ampliarse hasta
|
| 235 |
+
`PLACES_CHAT_MAX_AUTO_RADIUS_METERS`. Un radio escrito por el usuario nunca se amplia.
|
| 236 |
+
El radio efectivo queda en `location_directive.radius_meters`; los `place_ids` usados
|
| 237 |
+
para cada consulta son transitorios y no se persisten en `state_patch`.
|
| 238 |
+
|
| 239 |
+
Los ajustes que pertenecen a otros repositorios se documentan en
|
| 240 |
+
`docs/cambios_api_principal_chat_lugares.md` y
|
| 241 |
+
`docs/cambios_app_movil_chat_lugares.md`; no se implementan desde este servicio.
|
| 242 |
+
|
| 243 |
## SQL RDS
|
| 244 |
|
| 245 |
Contrato de referencia:
|
app/modules/places/api/dependencies.py
CHANGED
|
@@ -237,4 +237,7 @@ def get_chat_place_recommendations_use_case() -> ChatPlaceRecommendationsUseCase
|
|
| 237 |
else None
|
| 238 |
),
|
| 239 |
default_radius_meters=settings.places_chat_default_radius_meters,
|
|
|
|
|
|
|
|
|
|
| 240 |
)
|
|
|
|
| 237 |
else None
|
| 238 |
),
|
| 239 |
default_radius_meters=settings.places_chat_default_radius_meters,
|
| 240 |
+
maximum_auto_radius_meters=(
|
| 241 |
+
settings.places_chat_max_auto_radius_meters
|
| 242 |
+
),
|
| 243 |
)
|
app/modules/places/api/internal_chat_schemas.py
CHANGED
|
@@ -333,5 +333,12 @@ def internal_chat_result_to_schema(
|
|
| 333 |
"category_source": result.category_source,
|
| 334 |
"raw_category_phrase": result.raw_category_phrase,
|
| 335 |
"intent_model_version": result.intent_model_version,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
},
|
| 337 |
)
|
|
|
|
| 333 |
"category_source": result.category_source,
|
| 334 |
"raw_category_phrase": result.raw_category_phrase,
|
| 335 |
"intent_model_version": result.intent_model_version,
|
| 336 |
+
"input_kind": (
|
| 337 |
+
"non_search"
|
| 338 |
+
if "non_search_input" in result.unresolved
|
| 339 |
+
else "place_search"
|
| 340 |
+
),
|
| 341 |
+
"effective_radius_meters": directive.radius_meters,
|
| 342 |
+
"radius_is_strict": directive.strict_radius,
|
| 343 |
},
|
| 344 |
)
|
app/modules/places/application/use_cases/chat_place_recommendations.py
CHANGED
|
@@ -86,6 +86,7 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 86 |
maximum_hypothesis_gap: float = 0.15,
|
| 87 |
nearby_place_provider: NearbyPlaceProvider | None = None,
|
| 88 |
default_radius_meters: int = 5_000,
|
|
|
|
| 89 |
) -> None:
|
| 90 |
if not 0.0 <= minimum_intent_confidence <= 1.0:
|
| 91 |
raise ValueError("minimum_intent_confidence must be between zero and one")
|
|
@@ -97,6 +98,11 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 97 |
raise ValueError("maximum_hypothesis_gap must be between zero and one")
|
| 98 |
if not 1 <= default_radius_meters <= 50_000:
|
| 99 |
raise ValueError("default_radius_meters must be between 1 and 50000")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
self._intent_parser = intent_parser
|
| 101 |
self._anchor_resolver = anchor_resolver
|
| 102 |
self._retriever = retriever
|
|
@@ -111,6 +117,7 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 111 |
self._maximum_hypothesis_gap = maximum_hypothesis_gap
|
| 112 |
self._nearby_place_provider = nearby_place_provider
|
| 113 |
self._default_radius_meters = default_radius_meters
|
|
|
|
| 114 |
|
| 115 |
async def execute(
|
| 116 |
self,
|
|
@@ -136,6 +143,19 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 136 |
has_user_location,
|
| 137 |
clarification_choice,
|
| 138 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
if intent.action == "clarification":
|
| 140 |
return self._clarification_result(intent, trace_id)
|
| 141 |
|
|
@@ -175,27 +195,48 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 175 |
if directive.source == "user_current"
|
| 176 |
else intent.location.longitude
|
| 177 |
)
|
|
|
|
| 178 |
if (
|
| 179 |
directive.source in {"user_current", "explicit_anchor", "state_anchor"}
|
| 180 |
and self._nearby_place_provider is not None
|
| 181 |
and geographic_latitude is not None
|
| 182 |
and geographic_longitude is not None
|
| 183 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
nearby_ids = await self._nearby_place_provider.get_nearby_place_ids(
|
| 185 |
latitude=geographic_latitude,
|
| 186 |
longitude=geographic_longitude,
|
| 187 |
-
radius_meters=
|
| 188 |
-
directive.radius_meters or self._default_radius_meters
|
| 189 |
-
),
|
| 190 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
if not nearby_ids:
|
| 192 |
return self._result(
|
| 193 |
action="no_match",
|
| 194 |
-
message=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
intent=intent,
|
| 196 |
directive=directive,
|
| 197 |
candidates=(),
|
| 198 |
-
unresolved=(),
|
| 199 |
trace_id=trace_id,
|
| 200 |
)
|
| 201 |
intent = replace(
|
|
@@ -205,6 +246,10 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 205 |
"place_ids": tuple(sorted(nearby_ids)),
|
| 206 |
},
|
| 207 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
if intent.confidence < self._minimum_intent_confidence:
|
| 210 |
evidence_candidates = tuple(
|
|
@@ -214,7 +259,10 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 214 |
)
|
| 215 |
)
|
| 216 |
pending = (
|
| 217 |
-
self._category_clarification_from_hypotheses(
|
|
|
|
|
|
|
|
|
|
| 218 |
or self._category_clarification_from_candidates(evidence_candidates)
|
| 219 |
)
|
| 220 |
if pending is not None:
|
|
@@ -223,7 +271,11 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 223 |
pending,
|
| 224 |
unresolved=("intent_confidence",),
|
| 225 |
)
|
| 226 |
-
return self._clarification_result(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
|
| 228 |
sufficient_candidates = tuple(
|
| 229 |
candidate
|
|
@@ -277,12 +329,80 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 277 |
candidates = tuple(
|
| 278 |
await self._retriever.retrieve(intent=intent, limit=candidate_limit)
|
| 279 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
if not candidates:
|
| 281 |
return self._result(
|
| 282 |
action="no_match",
|
| 283 |
message=(
|
| 284 |
-
"No
|
| 285 |
-
"Puedes cambiar alguna preferencia o pedirme otro tipo
|
|
|
|
| 286 |
),
|
| 287 |
intent=intent,
|
| 288 |
directive=directive,
|
|
@@ -595,21 +715,47 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 595 |
def _category_clarification_from_hypotheses(
|
| 596 |
self,
|
| 597 |
intent: ParsedPlaceChatIntent,
|
|
|
|
| 598 |
) -> PendingClarification | None:
|
| 599 |
scores: dict[str, float] = {}
|
| 600 |
labels: dict[str, str] = {}
|
|
|
|
| 601 |
if intent.target_category:
|
| 602 |
scores[intent.target_category] = intent.confidence
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
|
| 604 |
for alternative in intent.alternatives:
|
| 605 |
key = alternative.key.strip()
|
| 606 |
if not key or key in _NON_CATEGORY_ALTERNATIVE_KEYS:
|
| 607 |
continue
|
| 608 |
scores[key] = max(scores.get(key, 0.0), alternative.confidence)
|
|
|
|
|
|
|
|
|
|
| 609 |
if alternative.description.strip():
|
| 610 |
labels[key] = alternative.description.strip()
|
| 611 |
|
| 612 |
-
ranked = sorted(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
if not ranked or ranked[0][1] < self._minimum_hypothesis_confidence:
|
| 614 |
return None
|
| 615 |
top_score = ranked[0][1]
|
|
@@ -633,6 +779,7 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 633 |
) -> PendingClarification | None:
|
| 634 |
counts: dict[str, int] = {}
|
| 635 |
best_scores: dict[str, float] = {}
|
|
|
|
| 636 |
for candidate in candidates:
|
| 637 |
diagnostics = candidate.metadata.get("retrieval_diagnostics", {})
|
| 638 |
if diagnostics.get("meets_minimum_content_score") is False:
|
|
@@ -645,6 +792,9 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 645 |
best_scores.get(category, 0.0),
|
| 646 |
candidate.content_score,
|
| 647 |
)
|
|
|
|
|
|
|
|
|
|
| 648 |
ordered = tuple(
|
| 649 |
category
|
| 650 |
for category, _ in sorted(
|
|
@@ -656,7 +806,7 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 656 |
return None
|
| 657 |
labels = {
|
| 658 |
category: (
|
| 659 |
-
f"{
|
| 660 |
f"({counts[category]} opciones encontradas)"
|
| 661 |
)
|
| 662 |
for category in ordered
|
|
@@ -781,6 +931,57 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 781 |
return bool(diagnostics["meets_minimum_content_score"])
|
| 782 |
return candidate.content_score > 0.0
|
| 783 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 784 |
@staticmethod
|
| 785 |
def _candidate_context(candidate: PlaceChatCandidate) -> dict[str, Any]:
|
| 786 |
return {
|
|
@@ -798,6 +999,8 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 798 |
self,
|
| 799 |
intent: ParsedPlaceChatIntent,
|
| 800 |
trace_id: str,
|
|
|
|
|
|
|
| 801 |
) -> ChatPlaceRecommendationsResult:
|
| 802 |
if intent.clarification is None:
|
| 803 |
raise RuntimeError("clarification action requires structured options")
|
|
@@ -805,7 +1008,7 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 805 |
action="clarification",
|
| 806 |
message=intent.clarification.prompt,
|
| 807 |
intent=intent,
|
| 808 |
-
directive=self._location_directive(intent),
|
| 809 |
candidates=(),
|
| 810 |
unresolved=intent.unresolved,
|
| 811 |
trace_id=trace_id,
|
|
@@ -819,6 +1022,12 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 819 |
) -> ParsedPlaceChatIntent:
|
| 820 |
clarification = to_public_clarification(pending)
|
| 821 |
patch = intent.state_patch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 822 |
return replace(
|
| 823 |
intent,
|
| 824 |
action="clarification",
|
|
@@ -829,9 +1038,9 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 829 |
patch.target_category or intent.target_category
|
| 830 |
),
|
| 831 |
hard_filters=(
|
| 832 |
-
|
| 833 |
-
if patch.hard_filters is not None
|
| 834 |
-
else
|
| 835 |
),
|
| 836 |
soft_preferences=(
|
| 837 |
patch.soft_preferences
|
|
@@ -924,6 +1133,79 @@ class ChatPlaceRecommendationsUseCase:
|
|
| 924 |
)
|
| 925 |
|
| 926 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 927 |
def _distance_meters(
|
| 928 |
left: ResolvedPlaceAnchor,
|
| 929 |
right: ResolvedPlaceAnchor,
|
|
|
|
| 86 |
maximum_hypothesis_gap: float = 0.15,
|
| 87 |
nearby_place_provider: NearbyPlaceProvider | None = None,
|
| 88 |
default_radius_meters: int = 5_000,
|
| 89 |
+
maximum_auto_radius_meters: int = 50_000,
|
| 90 |
) -> None:
|
| 91 |
if not 0.0 <= minimum_intent_confidence <= 1.0:
|
| 92 |
raise ValueError("minimum_intent_confidence must be between zero and one")
|
|
|
|
| 98 |
raise ValueError("maximum_hypothesis_gap must be between zero and one")
|
| 99 |
if not 1 <= default_radius_meters <= 50_000:
|
| 100 |
raise ValueError("default_radius_meters must be between 1 and 50000")
|
| 101 |
+
if not default_radius_meters <= maximum_auto_radius_meters <= 50_000:
|
| 102 |
+
raise ValueError(
|
| 103 |
+
"maximum_auto_radius_meters must be between the default radius "
|
| 104 |
+
"and 50000"
|
| 105 |
+
)
|
| 106 |
self._intent_parser = intent_parser
|
| 107 |
self._anchor_resolver = anchor_resolver
|
| 108 |
self._retriever = retriever
|
|
|
|
| 117 |
self._maximum_hypothesis_gap = maximum_hypothesis_gap
|
| 118 |
self._nearby_place_provider = nearby_place_provider
|
| 119 |
self._default_radius_meters = default_radius_meters
|
| 120 |
+
self._maximum_auto_radius_meters = maximum_auto_radius_meters
|
| 121 |
|
| 122 |
async def execute(
|
| 123 |
self,
|
|
|
|
| 143 |
has_user_location,
|
| 144 |
clarification_choice,
|
| 145 |
)
|
| 146 |
+
if intent.action == "no_match":
|
| 147 |
+
return self._result(
|
| 148 |
+
action="no_match",
|
| 149 |
+
message=(
|
| 150 |
+
intent.response_message
|
| 151 |
+
or "Cuéntame qué tipo de lugar o actividad buscas."
|
| 152 |
+
),
|
| 153 |
+
intent=intent,
|
| 154 |
+
directive=self._location_directive(intent),
|
| 155 |
+
candidates=(),
|
| 156 |
+
unresolved=intent.unresolved,
|
| 157 |
+
trace_id=trace_id,
|
| 158 |
+
)
|
| 159 |
if intent.action == "clarification":
|
| 160 |
return self._clarification_result(intent, trace_id)
|
| 161 |
|
|
|
|
| 195 |
if directive.source == "user_current"
|
| 196 |
else intent.location.longitude
|
| 197 |
)
|
| 198 |
+
can_auto_expand_radius = False
|
| 199 |
if (
|
| 200 |
directive.source in {"user_current", "explicit_anchor", "state_anchor"}
|
| 201 |
and self._nearby_place_provider is not None
|
| 202 |
and geographic_latitude is not None
|
| 203 |
and geographic_longitude is not None
|
| 204 |
):
|
| 205 |
+
allows_auto_expansion = (
|
| 206 |
+
directive.radius_meters is None and not directive.strict_radius
|
| 207 |
+
)
|
| 208 |
+
effective_radius = (
|
| 209 |
+
directive.radius_meters or self._default_radius_meters
|
| 210 |
+
)
|
| 211 |
nearby_ids = await self._nearby_place_provider.get_nearby_place_ids(
|
| 212 |
latitude=geographic_latitude,
|
| 213 |
longitude=geographic_longitude,
|
| 214 |
+
radius_meters=effective_radius,
|
|
|
|
|
|
|
| 215 |
)
|
| 216 |
+
if (
|
| 217 |
+
not nearby_ids
|
| 218 |
+
and allows_auto_expansion
|
| 219 |
+
and effective_radius < self._maximum_auto_radius_meters
|
| 220 |
+
):
|
| 221 |
+
effective_radius = self._maximum_auto_radius_meters
|
| 222 |
+
nearby_ids = await self._nearby_place_provider.get_nearby_place_ids(
|
| 223 |
+
latitude=geographic_latitude,
|
| 224 |
+
longitude=geographic_longitude,
|
| 225 |
+
radius_meters=effective_radius,
|
| 226 |
+
)
|
| 227 |
+
directive = replace(directive, radius_meters=effective_radius)
|
| 228 |
if not nearby_ids:
|
| 229 |
return self._result(
|
| 230 |
action="no_match",
|
| 231 |
+
message=(
|
| 232 |
+
"No encontré lugares disponibles cerca de tu ubicación "
|
| 233 |
+
f"dentro de {self._distance_label(effective_radius)}. "
|
| 234 |
+
"Puedes indicar otra zona o intentarlo con un plan distinto."
|
| 235 |
+
),
|
| 236 |
intent=intent,
|
| 237 |
directive=directive,
|
| 238 |
candidates=(),
|
| 239 |
+
unresolved=("nearby_catalog_empty",),
|
| 240 |
trace_id=trace_id,
|
| 241 |
)
|
| 242 |
intent = replace(
|
|
|
|
| 246 |
"place_ids": tuple(sorted(nearby_ids)),
|
| 247 |
},
|
| 248 |
)
|
| 249 |
+
can_auto_expand_radius = (
|
| 250 |
+
allows_auto_expansion
|
| 251 |
+
and effective_radius < self._maximum_auto_radius_meters
|
| 252 |
+
)
|
| 253 |
|
| 254 |
if intent.confidence < self._minimum_intent_confidence:
|
| 255 |
evidence_candidates = tuple(
|
|
|
|
| 259 |
)
|
| 260 |
)
|
| 261 |
pending = (
|
| 262 |
+
self._category_clarification_from_hypotheses(
|
| 263 |
+
intent,
|
| 264 |
+
evidence_candidates,
|
| 265 |
+
)
|
| 266 |
or self._category_clarification_from_candidates(evidence_candidates)
|
| 267 |
)
|
| 268 |
if pending is not None:
|
|
|
|
| 271 |
pending,
|
| 272 |
unresolved=("intent_confidence",),
|
| 273 |
)
|
| 274 |
+
return self._clarification_result(
|
| 275 |
+
clarified,
|
| 276 |
+
trace_id,
|
| 277 |
+
directive=directive,
|
| 278 |
+
)
|
| 279 |
|
| 280 |
sufficient_candidates = tuple(
|
| 281 |
candidate
|
|
|
|
| 329 |
candidates = tuple(
|
| 330 |
await self._retriever.retrieve(intent=intent, limit=candidate_limit)
|
| 331 |
)
|
| 332 |
+
category_supported = self._has_category_supported_candidate(
|
| 333 |
+
intent,
|
| 334 |
+
candidates,
|
| 335 |
+
)
|
| 336 |
+
if (
|
| 337 |
+
can_auto_expand_radius
|
| 338 |
+
and (not candidates or (intent.target_category and not category_supported))
|
| 339 |
+
and self._nearby_place_provider is not None
|
| 340 |
+
and geographic_latitude is not None
|
| 341 |
+
and geographic_longitude is not None
|
| 342 |
+
):
|
| 343 |
+
expanded_radius = self._maximum_auto_radius_meters
|
| 344 |
+
expanded_ids = await self._nearby_place_provider.get_nearby_place_ids(
|
| 345 |
+
latitude=geographic_latitude,
|
| 346 |
+
longitude=geographic_longitude,
|
| 347 |
+
radius_meters=expanded_radius,
|
| 348 |
+
)
|
| 349 |
+
directive = replace(directive, radius_meters=expanded_radius)
|
| 350 |
+
if expanded_ids:
|
| 351 |
+
expanded_intent = replace(
|
| 352 |
+
intent,
|
| 353 |
+
hard_filters={
|
| 354 |
+
**intent.hard_filters,
|
| 355 |
+
"place_ids": tuple(sorted(expanded_ids)),
|
| 356 |
+
},
|
| 357 |
+
)
|
| 358 |
+
expanded_candidates = tuple(
|
| 359 |
+
await self._retriever.retrieve(
|
| 360 |
+
intent=expanded_intent,
|
| 361 |
+
limit=candidate_limit,
|
| 362 |
+
)
|
| 363 |
+
)
|
| 364 |
+
expanded_category_supported = (
|
| 365 |
+
self._has_category_supported_candidate(
|
| 366 |
+
expanded_intent,
|
| 367 |
+
expanded_candidates,
|
| 368 |
+
)
|
| 369 |
+
)
|
| 370 |
+
if (
|
| 371 |
+
not candidates
|
| 372 |
+
or expanded_category_supported
|
| 373 |
+
or not intent.target_category
|
| 374 |
+
):
|
| 375 |
+
intent = expanded_intent
|
| 376 |
+
candidates = expanded_candidates
|
| 377 |
+
category_supported = expanded_category_supported
|
| 378 |
+
|
| 379 |
+
if intent.target_category and candidates and not category_supported:
|
| 380 |
+
category_label = self._display_category(intent.target_category)
|
| 381 |
+
radius_label = (
|
| 382 |
+
f" dentro de {self._distance_label(directive.radius_meters)}"
|
| 383 |
+
if directive.radius_meters is not None
|
| 384 |
+
else ""
|
| 385 |
+
)
|
| 386 |
+
return self._result(
|
| 387 |
+
action="no_match",
|
| 388 |
+
message=(
|
| 389 |
+
f"No encontré opciones disponibles de {category_label}"
|
| 390 |
+
f"{radius_label}. Prueba con otra categoría o indícame "
|
| 391 |
+
"una zona diferente."
|
| 392 |
+
),
|
| 393 |
+
intent=intent,
|
| 394 |
+
directive=directive,
|
| 395 |
+
candidates=(),
|
| 396 |
+
unresolved=("category_availability",),
|
| 397 |
+
trace_id=trace_id,
|
| 398 |
+
)
|
| 399 |
if not candidates:
|
| 400 |
return self._result(
|
| 401 |
action="no_match",
|
| 402 |
message=(
|
| 403 |
+
"No encontré opciones suficientemente relacionadas con lo que "
|
| 404 |
+
"buscas. Puedes cambiar alguna preferencia o pedirme otro tipo "
|
| 405 |
+
"de lugar."
|
| 406 |
),
|
| 407 |
intent=intent,
|
| 408 |
directive=directive,
|
|
|
|
| 715 |
def _category_clarification_from_hypotheses(
|
| 716 |
self,
|
| 717 |
intent: ParsedPlaceChatIntent,
|
| 718 |
+
candidates: Sequence[PlaceChatCandidate],
|
| 719 |
) -> PendingClarification | None:
|
| 720 |
scores: dict[str, float] = {}
|
| 721 |
labels: dict[str, str] = {}
|
| 722 |
+
category_values: dict[str, tuple[str, ...]] = {}
|
| 723 |
if intent.target_category:
|
| 724 |
scores[intent.target_category] = intent.confidence
|
| 725 |
+
category_values[intent.target_category] = tuple(
|
| 726 |
+
dict.fromkeys(
|
| 727 |
+
(
|
| 728 |
+
intent.target_category,
|
| 729 |
+
*intent.category_values,
|
| 730 |
+
)
|
| 731 |
+
)
|
| 732 |
+
)
|
| 733 |
|
| 734 |
for alternative in intent.alternatives:
|
| 735 |
key = alternative.key.strip()
|
| 736 |
if not key or key in _NON_CATEGORY_ALTERNATIVE_KEYS:
|
| 737 |
continue
|
| 738 |
scores[key] = max(scores.get(key, 0.0), alternative.confidence)
|
| 739 |
+
category_values[key] = tuple(
|
| 740 |
+
dict.fromkeys((key, *alternative.category_values))
|
| 741 |
+
)
|
| 742 |
if alternative.description.strip():
|
| 743 |
labels[key] = alternative.description.strip()
|
| 744 |
|
| 745 |
+
ranked = sorted(
|
| 746 |
+
(
|
| 747 |
+
(category, score)
|
| 748 |
+
for category, score in scores.items()
|
| 749 |
+
if any(
|
| 750 |
+
self._candidate_supports_category_values(
|
| 751 |
+
candidate,
|
| 752 |
+
category_values.get(category, (category,)),
|
| 753 |
+
)
|
| 754 |
+
for candidate in candidates
|
| 755 |
+
)
|
| 756 |
+
),
|
| 757 |
+
key=lambda item: (-item[1], item[0]),
|
| 758 |
+
)
|
| 759 |
if not ranked or ranked[0][1] < self._minimum_hypothesis_confidence:
|
| 760 |
return None
|
| 761 |
top_score = ranked[0][1]
|
|
|
|
| 779 |
) -> PendingClarification | None:
|
| 780 |
counts: dict[str, int] = {}
|
| 781 |
best_scores: dict[str, float] = {}
|
| 782 |
+
display_labels: dict[str, str] = {}
|
| 783 |
for candidate in candidates:
|
| 784 |
diagnostics = candidate.metadata.get("retrieval_diagnostics", {})
|
| 785 |
if diagnostics.get("meets_minimum_content_score") is False:
|
|
|
|
| 792 |
best_scores.get(category, 0.0),
|
| 793 |
candidate.content_score,
|
| 794 |
)
|
| 795 |
+
source_label = candidate.metadata.get("category_label")
|
| 796 |
+
if isinstance(source_label, str) and source_label.strip():
|
| 797 |
+
display_labels[category] = source_label.strip()
|
| 798 |
ordered = tuple(
|
| 799 |
category
|
| 800 |
for category, _ in sorted(
|
|
|
|
| 806 |
return None
|
| 807 |
labels = {
|
| 808 |
category: (
|
| 809 |
+
f"{display_labels.get(category) or _display_category(category)} "
|
| 810 |
f"({counts[category]} opciones encontradas)"
|
| 811 |
)
|
| 812 |
for category in ordered
|
|
|
|
| 931 |
return bool(diagnostics["meets_minimum_content_score"])
|
| 932 |
return candidate.content_score > 0.0
|
| 933 |
|
| 934 |
+
@classmethod
|
| 935 |
+
def _candidate_supports_category_values(
|
| 936 |
+
cls,
|
| 937 |
+
candidate: PlaceChatCandidate,
|
| 938 |
+
values: Sequence[str],
|
| 939 |
+
) -> bool:
|
| 940 |
+
if not cls._meets_content_threshold(candidate):
|
| 941 |
+
return False
|
| 942 |
+
supported = {
|
| 943 |
+
normalized
|
| 944 |
+
for value in values
|
| 945 |
+
if (normalized := _normalized_category(value))
|
| 946 |
+
}
|
| 947 |
+
return _candidate_has_category_evidence(candidate, supported)
|
| 948 |
+
|
| 949 |
+
@staticmethod
|
| 950 |
+
def _has_category_supported_candidate(
|
| 951 |
+
intent: ParsedPlaceChatIntent,
|
| 952 |
+
candidates: Sequence[PlaceChatCandidate],
|
| 953 |
+
) -> bool:
|
| 954 |
+
if not intent.target_category:
|
| 955 |
+
return True
|
| 956 |
+
|
| 957 |
+
requested_values = {
|
| 958 |
+
normalized
|
| 959 |
+
for value in (
|
| 960 |
+
intent.target_category,
|
| 961 |
+
*intent.category_values,
|
| 962 |
+
*intent.compatible_category_values,
|
| 963 |
+
)
|
| 964 |
+
if (normalized := _normalized_category(value))
|
| 965 |
+
}
|
| 966 |
+
for candidate in candidates:
|
| 967 |
+
diagnostics = candidate.metadata.get("retrieval_diagnostics", {})
|
| 968 |
+
category_match = diagnostics.get("category_match")
|
| 969 |
+
if category_match not in {None, "none", "not_requested"}:
|
| 970 |
+
return True
|
| 971 |
+
if _candidate_has_category_evidence(candidate, requested_values):
|
| 972 |
+
return True
|
| 973 |
+
return False
|
| 974 |
+
|
| 975 |
+
@staticmethod
|
| 976 |
+
def _display_category(value: str) -> str:
|
| 977 |
+
return _display_category(value).casefold()
|
| 978 |
+
|
| 979 |
+
@staticmethod
|
| 980 |
+
def _distance_label(radius_meters: int) -> str:
|
| 981 |
+
if radius_meters >= 1_000 and radius_meters % 1_000 == 0:
|
| 982 |
+
return f"{radius_meters // 1_000} km"
|
| 983 |
+
return f"{radius_meters} m"
|
| 984 |
+
|
| 985 |
@staticmethod
|
| 986 |
def _candidate_context(candidate: PlaceChatCandidate) -> dict[str, Any]:
|
| 987 |
return {
|
|
|
|
| 999 |
self,
|
| 1000 |
intent: ParsedPlaceChatIntent,
|
| 1001 |
trace_id: str,
|
| 1002 |
+
*,
|
| 1003 |
+
directive: PlaceChatLocationDirective | None = None,
|
| 1004 |
) -> ChatPlaceRecommendationsResult:
|
| 1005 |
if intent.clarification is None:
|
| 1006 |
raise RuntimeError("clarification action requires structured options")
|
|
|
|
| 1008 |
action="clarification",
|
| 1009 |
message=intent.clarification.prompt,
|
| 1010 |
intent=intent,
|
| 1011 |
+
directive=directive or self._location_directive(intent),
|
| 1012 |
candidates=(),
|
| 1013 |
unresolved=intent.unresolved,
|
| 1014 |
trace_id=trace_id,
|
|
|
|
| 1022 |
) -> ParsedPlaceChatIntent:
|
| 1023 |
clarification = to_public_clarification(pending)
|
| 1024 |
patch = intent.state_patch
|
| 1025 |
+
source_hard_filters = (
|
| 1026 |
+
patch.hard_filters
|
| 1027 |
+
if patch.hard_filters is not None
|
| 1028 |
+
else intent.hard_filters
|
| 1029 |
+
)
|
| 1030 |
+
persistent_hard_filters = _persistent_hard_filters(source_hard_filters)
|
| 1031 |
return replace(
|
| 1032 |
intent,
|
| 1033 |
action="clarification",
|
|
|
|
| 1038 |
patch.target_category or intent.target_category
|
| 1039 |
),
|
| 1040 |
hard_filters=(
|
| 1041 |
+
persistent_hard_filters
|
| 1042 |
+
if patch.hard_filters is not None or persistent_hard_filters
|
| 1043 |
+
else None
|
| 1044 |
),
|
| 1045 |
soft_preferences=(
|
| 1046 |
patch.soft_preferences
|
|
|
|
| 1133 |
)
|
| 1134 |
|
| 1135 |
|
| 1136 |
+
def _persistent_hard_filters(filters: dict[str, Any] | None) -> dict[str, Any]:
|
| 1137 |
+
if not filters:
|
| 1138 |
+
return {}
|
| 1139 |
+
return {
|
| 1140 |
+
key: value
|
| 1141 |
+
for key, value in filters.items()
|
| 1142 |
+
if key != "place_ids"
|
| 1143 |
+
}
|
| 1144 |
+
|
| 1145 |
+
|
| 1146 |
+
def _normalized_category(value: str | None) -> str:
|
| 1147 |
+
if not value:
|
| 1148 |
+
return ""
|
| 1149 |
+
return " ".join(value.replace("_", " ").replace("-", " ").casefold().split())
|
| 1150 |
+
|
| 1151 |
+
|
| 1152 |
+
def _display_category(value: str) -> str:
|
| 1153 |
+
humanized = " ".join(value.replace("_", " ").replace("-", " ").split())
|
| 1154 |
+
if not humanized:
|
| 1155 |
+
return "Lugar"
|
| 1156 |
+
return humanized[:1].upper() + humanized[1:]
|
| 1157 |
+
|
| 1158 |
+
|
| 1159 |
+
def _candidate_category_values(candidate: PlaceChatCandidate) -> set[str]:
|
| 1160 |
+
values: set[str] = set()
|
| 1161 |
+
for raw_value in (
|
| 1162 |
+
candidate.category,
|
| 1163 |
+
candidate.metadata.get("category_label"),
|
| 1164 |
+
candidate.metadata.get("tags"),
|
| 1165 |
+
candidate.metadata.get("tag_names"),
|
| 1166 |
+
):
|
| 1167 |
+
for value in _text_values(raw_value):
|
| 1168 |
+
normalized = _normalized_category(value)
|
| 1169 |
+
if normalized:
|
| 1170 |
+
values.add(normalized)
|
| 1171 |
+
return values
|
| 1172 |
+
|
| 1173 |
+
|
| 1174 |
+
def _candidate_has_category_evidence(
|
| 1175 |
+
candidate: PlaceChatCandidate,
|
| 1176 |
+
requested_values: set[str],
|
| 1177 |
+
) -> bool:
|
| 1178 |
+
if not requested_values:
|
| 1179 |
+
return False
|
| 1180 |
+
if _candidate_category_values(candidate) & requested_values:
|
| 1181 |
+
return True
|
| 1182 |
+
|
| 1183 |
+
raw_evidence: list[str] = []
|
| 1184 |
+
for raw_value in (
|
| 1185 |
+
candidate.category,
|
| 1186 |
+
candidate.metadata.get("category_label"),
|
| 1187 |
+
candidate.metadata.get("tags"),
|
| 1188 |
+
candidate.metadata.get("tag_names"),
|
| 1189 |
+
):
|
| 1190 |
+
raw_evidence.extend(_text_values(raw_value))
|
| 1191 |
+
haystack = f" {' '.join(_normalized_category(value) for value in raw_evidence)} "
|
| 1192 |
+
return any(f" {value} " in haystack for value in requested_values)
|
| 1193 |
+
|
| 1194 |
+
|
| 1195 |
+
def _text_values(value: Any) -> tuple[str, ...]:
|
| 1196 |
+
if value is None:
|
| 1197 |
+
return ()
|
| 1198 |
+
if isinstance(value, str):
|
| 1199 |
+
return tuple(
|
| 1200 |
+
part.strip()
|
| 1201 |
+
for part in value.replace(";", ",").split(",")
|
| 1202 |
+
if part.strip()
|
| 1203 |
+
)
|
| 1204 |
+
if isinstance(value, Sequence) and not isinstance(value, bytes | bytearray):
|
| 1205 |
+
return tuple(str(item).strip() for item in value if str(item).strip())
|
| 1206 |
+
return (str(value).strip(),) if str(value).strip() else ()
|
| 1207 |
+
|
| 1208 |
+
|
| 1209 |
def _distance_meters(
|
| 1210 |
left: ResolvedPlaceAnchor,
|
| 1211 |
right: ResolvedPlaceAnchor,
|
app/modules/places/domain/chat_intent.py
CHANGED
|
@@ -122,6 +122,7 @@ class IntentAlternative:
|
|
| 122 |
key: str
|
| 123 |
description: str
|
| 124 |
confidence: float
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
@dataclass(frozen=True)
|
|
@@ -224,6 +225,7 @@ class ParsedPlaceChatIntent:
|
|
| 224 |
alternatives: tuple[IntentAlternative, ...] = ()
|
| 225 |
unresolved: tuple[str, ...] = ()
|
| 226 |
clarification_message: str | None = None
|
|
|
|
| 227 |
raw_category_phrase: str | None = None
|
| 228 |
intent_model_version: str = "deterministic-open-v2"
|
| 229 |
|
|
|
|
| 122 |
key: str
|
| 123 |
description: str
|
| 124 |
confidence: float
|
| 125 |
+
category_values: tuple[str, ...] = ()
|
| 126 |
|
| 127 |
|
| 128 |
@dataclass(frozen=True)
|
|
|
|
| 225 |
alternatives: tuple[IntentAlternative, ...] = ()
|
| 226 |
unresolved: tuple[str, ...] = ()
|
| 227 |
clarification_message: str | None = None
|
| 228 |
+
response_message: str | None = None
|
| 229 |
raw_category_phrase: str | None = None
|
| 230 |
intent_model_version: str = "deterministic-open-v2"
|
| 231 |
|
app/modules/places/domain/clarifications.py
CHANGED
|
@@ -32,9 +32,9 @@ def new_category_clarification(
|
|
| 32 |
option_id=option_id,
|
| 33 |
value=category,
|
| 34 |
label=_bounded_text(
|
| 35 |
-
|
| 36 |
category,
|
| 37 |
-
|
| 38 |
),
|
| 39 |
160,
|
| 40 |
),
|
|
@@ -157,6 +157,13 @@ def _bounded_text(value: str, maximum: int) -> str:
|
|
| 157 |
return value[: maximum - 1].rstrip() + "…"
|
| 158 |
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
def _category_option_ids(values: Sequence[str]) -> tuple[str, ...]:
|
| 161 |
seen: set[str] = set()
|
| 162 |
option_ids: list[str] = []
|
|
|
|
| 32 |
option_id=option_id,
|
| 33 |
value=category,
|
| 34 |
label=_bounded_text(
|
| 35 |
+
_category_display_label(
|
| 36 |
category,
|
| 37 |
+
option_labels.get(category),
|
| 38 |
),
|
| 39 |
160,
|
| 40 |
),
|
|
|
|
| 157 |
return value[: maximum - 1].rstrip() + "…"
|
| 158 |
|
| 159 |
|
| 160 |
+
def _category_display_label(category: str, label: str | None) -> str:
|
| 161 |
+
value = " ".join((label or category).replace("_", " ").split()).strip()
|
| 162 |
+
if not value:
|
| 163 |
+
value = "Lugar"
|
| 164 |
+
return value[:1].upper() + value[1:]
|
| 165 |
+
|
| 166 |
+
|
| 167 |
def _category_option_ids(values: Sequence[str]) -> tuple[str, ...]:
|
| 168 |
seen: set[str] = set()
|
| 169 |
option_ids: list[str] = []
|
app/modules/places/infrastructure/deterministic_intent_parser.py
CHANGED
|
@@ -66,6 +66,45 @@ _RADIUS_VALUE_PATTERN = re.compile(
|
|
| 66 |
r"(?P<unit>km|kilometros?|m|metros?)\b"
|
| 67 |
)
|
| 68 |
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
_GENERIC_REQUEST_TOKENS = {
|
| 70 |
"dame",
|
| 71 |
"favor",
|
|
@@ -222,6 +261,29 @@ class DeterministicPlaceChatIntentParser:
|
|
| 222 |
state=state,
|
| 223 |
has_user_location=has_user_location,
|
| 224 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
contextual = self._contextual_signals(message)
|
| 226 |
# Token classification is multi-label and a valid frame can still be
|
| 227 |
# incomplete. Strip both model-detected context spans and any remaining
|
|
@@ -402,7 +464,7 @@ class DeterministicPlaceChatIntentParser:
|
|
| 402 |
if reference_text
|
| 403 |
else inherited_reference
|
| 404 |
)
|
| 405 |
-
hard_filters =
|
| 406 |
if state.city and "city" not in hard_filters:
|
| 407 |
hard_filters["city"] = state.city
|
| 408 |
if state.state and "state" not in hard_filters:
|
|
@@ -564,6 +626,34 @@ class DeterministicPlaceChatIntentParser:
|
|
| 564 |
intent_model_version=contextual.intent_model_version,
|
| 565 |
)
|
| 566 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
def _location_scope_clarification(
|
| 568 |
self,
|
| 569 |
target_category: str,
|
|
@@ -924,7 +1014,7 @@ class DeterministicPlaceChatIntentParser:
|
|
| 924 |
raise ClarificationStateMismatchError(
|
| 925 |
"clarification category must not be empty"
|
| 926 |
)
|
| 927 |
-
hard_filters =
|
| 928 |
if state.city and "city" not in hard_filters:
|
| 929 |
hard_filters["city"] = state.city
|
| 930 |
if state.state and "state" not in hard_filters:
|
|
@@ -1192,20 +1282,38 @@ class DeterministicPlaceChatIntentParser:
|
|
| 1192 |
) -> tuple[IntentAlternative, ...]:
|
| 1193 |
if self._activity_classifier is None:
|
| 1194 |
return ()
|
| 1195 |
-
|
| 1196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1197 |
return ()
|
| 1198 |
-
matches =
|
| 1199 |
return tuple(
|
| 1200 |
IntentAlternative(
|
| 1201 |
key=str(match.concept_id),
|
| 1202 |
-
description=
|
|
|
|
|
|
|
|
|
|
| 1203 |
confidence=max(0.0, min(1.0, (float(match.score) + 1.0) / 2.0)),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1204 |
)
|
| 1205 |
for match in matches
|
| 1206 |
if getattr(match, "concept_id", None)
|
| 1207 |
)
|
| 1208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1209 |
@staticmethod
|
| 1210 |
def _target_clause(normalized: str) -> str:
|
| 1211 |
target_clause = _REFERENCE_PATTERN.sub(" ", normalized)
|
|
@@ -1270,6 +1378,7 @@ class DeterministicPlaceChatIntentParser:
|
|
| 1270 |
) -> ParsedPlaceChatIntent:
|
| 1271 |
pending = ensure_legacy_pending_options(pending)
|
| 1272 |
clarification = to_public_clarification(pending)
|
|
|
|
| 1273 |
location = (
|
| 1274 |
LocationIntent(scope="user_current_location", source="user_current")
|
| 1275 |
if has_user_location
|
|
@@ -1279,7 +1388,7 @@ class DeterministicPlaceChatIntentParser:
|
|
| 1279 |
action="clarification",
|
| 1280 |
target_category=state.target_category,
|
| 1281 |
category_values=(),
|
| 1282 |
-
hard_filters=
|
| 1283 |
soft_preferences=state.soft_preferences,
|
| 1284 |
exclusions=state.exclusions,
|
| 1285 |
reference=state.reference,
|
|
@@ -1287,8 +1396,11 @@ class DeterministicPlaceChatIntentParser:
|
|
| 1287 |
semantic_query="",
|
| 1288 |
confidence=0.5,
|
| 1289 |
state_patch=ConversationStatePatch(
|
|
|
|
|
|
|
|
|
|
| 1290 |
pending_clarification=pending,
|
| 1291 |
-
taxonomy_version=self._taxonomy.version
|
| 1292 |
),
|
| 1293 |
category_source=(
|
| 1294 |
"conversation_state" if state.target_category else "unresolved"
|
|
@@ -1346,6 +1458,47 @@ def load_place_chat_taxonomy() -> PlaceChatTaxonomy:
|
|
| 1346 |
)
|
| 1347 |
|
| 1348 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1349 |
def _contains_phrase(text: str, phrase: str) -> bool:
|
| 1350 |
return bool(re.search(rf"(?<!\w){re.escape(phrase)}(?!\w)", text))
|
| 1351 |
|
|
|
|
| 66 |
r"(?P<unit>km|kilometros?|m|metros?)\b"
|
| 67 |
)
|
| 68 |
_LOGGER = logging.getLogger(__name__)
|
| 69 |
+
_TRANSIENT_HARD_FILTER_KEYS = {"place_ids"}
|
| 70 |
+
_SOCIAL_GREETING_VALUES = {
|
| 71 |
+
"buenas",
|
| 72 |
+
"buenas noches",
|
| 73 |
+
"buenas tardes",
|
| 74 |
+
"buenos dias",
|
| 75 |
+
"hello",
|
| 76 |
+
"hey",
|
| 77 |
+
"hola",
|
| 78 |
+
"hola como estas",
|
| 79 |
+
"hola que tal",
|
| 80 |
+
"hola que tal como estas",
|
| 81 |
+
"holi",
|
| 82 |
+
"holis",
|
| 83 |
+
"ola",
|
| 84 |
+
"ola que tal",
|
| 85 |
+
"que tal",
|
| 86 |
+
}
|
| 87 |
+
_SOCIAL_THANKS_VALUES = {
|
| 88 |
+
"gracias",
|
| 89 |
+
"mil gracias",
|
| 90 |
+
"muchas gracias",
|
| 91 |
+
"te lo agradezco",
|
| 92 |
+
}
|
| 93 |
+
_SOCIAL_FAREWELL_VALUES = {
|
| 94 |
+
"adios",
|
| 95 |
+
"bye",
|
| 96 |
+
"hasta luego",
|
| 97 |
+
"hasta pronto",
|
| 98 |
+
"nos vemos",
|
| 99 |
+
}
|
| 100 |
+
_SOCIAL_ACKNOWLEDGEMENT_VALUES = {
|
| 101 |
+
"entendido",
|
| 102 |
+
"listo",
|
| 103 |
+
"ok",
|
| 104 |
+
"okay",
|
| 105 |
+
"perfecto",
|
| 106 |
+
"vale",
|
| 107 |
+
}
|
| 108 |
_GENERIC_REQUEST_TOKENS = {
|
| 109 |
"dame",
|
| 110 |
"favor",
|
|
|
|
| 261 |
state=state,
|
| 262 |
has_user_location=has_user_location,
|
| 263 |
)
|
| 264 |
+
social_turn = _social_turn(normalized)
|
| 265 |
+
if social_turn is not None:
|
| 266 |
+
social_kind, social_message = social_turn
|
| 267 |
+
if social_kind == "greeting" and state.pending_clarification is not None:
|
| 268 |
+
repeated = self._clarification(
|
| 269 |
+
pending=state.pending_clarification,
|
| 270 |
+
unresolved=(state.pending_clarification.kind,),
|
| 271 |
+
state=state,
|
| 272 |
+
has_user_location=has_user_location,
|
| 273 |
+
)
|
| 274 |
+
if repeated.clarification is None:
|
| 275 |
+
raise RuntimeError("pending clarification requires public options")
|
| 276 |
+
prompt = f"¡Hola! {repeated.clarification.prompt}"
|
| 277 |
+
return replace(
|
| 278 |
+
repeated,
|
| 279 |
+
clarification=replace(repeated.clarification, prompt=prompt),
|
| 280 |
+
clarification_message=prompt,
|
| 281 |
+
response_message=prompt,
|
| 282 |
+
)
|
| 283 |
+
return self._direct_response(
|
| 284 |
+
message=social_message,
|
| 285 |
+
state=state,
|
| 286 |
+
)
|
| 287 |
contextual = self._contextual_signals(message)
|
| 288 |
# Token classification is multi-label and a valid frame can still be
|
| 289 |
# incomplete. Strip both model-detected context spans and any remaining
|
|
|
|
| 464 |
if reference_text
|
| 465 |
else inherited_reference
|
| 466 |
)
|
| 467 |
+
hard_filters = _persistent_hard_filters(state.hard_filters)
|
| 468 |
if state.city and "city" not in hard_filters:
|
| 469 |
hard_filters["city"] = state.city
|
| 470 |
if state.state and "state" not in hard_filters:
|
|
|
|
| 626 |
intent_model_version=contextual.intent_model_version,
|
| 627 |
)
|
| 628 |
|
| 629 |
+
def _direct_response(
|
| 630 |
+
self,
|
| 631 |
+
message: str,
|
| 632 |
+
state: ConversationState,
|
| 633 |
+
) -> ParsedPlaceChatIntent:
|
| 634 |
+
hard_filters = _persistent_hard_filters(state.hard_filters)
|
| 635 |
+
return ParsedPlaceChatIntent(
|
| 636 |
+
action="no_match",
|
| 637 |
+
target_category=None,
|
| 638 |
+
category_values=(),
|
| 639 |
+
hard_filters=hard_filters,
|
| 640 |
+
soft_preferences=(),
|
| 641 |
+
exclusions=(),
|
| 642 |
+
reference=None,
|
| 643 |
+
location=LocationIntent(scope="unresolved", source="none"),
|
| 644 |
+
semantic_query="",
|
| 645 |
+
confidence=1.0,
|
| 646 |
+
state_patch=ConversationStatePatch(
|
| 647 |
+
hard_filters=(
|
| 648 |
+
hard_filters if hard_filters != state.hard_filters else None
|
| 649 |
+
),
|
| 650 |
+
taxonomy_version=self._taxonomy.version,
|
| 651 |
+
),
|
| 652 |
+
unresolved=("non_search_input",),
|
| 653 |
+
response_message=message,
|
| 654 |
+
intent_model_version=_DETERMINISTIC_INTENT_VERSION,
|
| 655 |
+
)
|
| 656 |
+
|
| 657 |
def _location_scope_clarification(
|
| 658 |
self,
|
| 659 |
target_category: str,
|
|
|
|
| 1014 |
raise ClarificationStateMismatchError(
|
| 1015 |
"clarification category must not be empty"
|
| 1016 |
)
|
| 1017 |
+
hard_filters = _persistent_hard_filters(state.hard_filters)
|
| 1018 |
if state.city and "city" not in hard_filters:
|
| 1019 |
hard_filters["city"] = state.city
|
| 1020 |
if state.state and "state" not in hard_filters:
|
|
|
|
| 1282 |
) -> tuple[IntentAlternative, ...]:
|
| 1283 |
if self._activity_classifier is None:
|
| 1284 |
return ()
|
| 1285 |
+
rank_supported = getattr(
|
| 1286 |
+
self._activity_classifier,
|
| 1287 |
+
"rank_supported",
|
| 1288 |
+
None,
|
| 1289 |
+
)
|
| 1290 |
+
if not callable(rank_supported):
|
| 1291 |
return ()
|
| 1292 |
+
matches = rank_supported(normalized, limit=5)
|
| 1293 |
return tuple(
|
| 1294 |
IntentAlternative(
|
| 1295 |
key=str(match.concept_id),
|
| 1296 |
+
description=self._category_display_label(
|
| 1297 |
+
str(match.concept_id),
|
| 1298 |
+
str(match.label),
|
| 1299 |
+
),
|
| 1300 |
confidence=max(0.0, min(1.0, (float(match.score) + 1.0) / 2.0)),
|
| 1301 |
+
category_values=tuple(
|
| 1302 |
+
str(value)
|
| 1303 |
+
for value in getattr(match, "storage_values", ())
|
| 1304 |
+
if str(value).strip()
|
| 1305 |
+
),
|
| 1306 |
)
|
| 1307 |
for match in matches
|
| 1308 |
if getattr(match, "concept_id", None)
|
| 1309 |
)
|
| 1310 |
|
| 1311 |
+
def _category_display_label(self, category: str, fallback: str) -> str:
|
| 1312 |
+
definition = self._taxonomy.category(category)
|
| 1313 |
+
if definition is not None and definition.aliases:
|
| 1314 |
+
return _humanize_category_label(definition.aliases[0])
|
| 1315 |
+
return _humanize_category_label(fallback or category)
|
| 1316 |
+
|
| 1317 |
@staticmethod
|
| 1318 |
def _target_clause(normalized: str) -> str:
|
| 1319 |
target_clause = _REFERENCE_PATTERN.sub(" ", normalized)
|
|
|
|
| 1378 |
) -> ParsedPlaceChatIntent:
|
| 1379 |
pending = ensure_legacy_pending_options(pending)
|
| 1380 |
clarification = to_public_clarification(pending)
|
| 1381 |
+
hard_filters = _persistent_hard_filters(state.hard_filters)
|
| 1382 |
location = (
|
| 1383 |
LocationIntent(scope="user_current_location", source="user_current")
|
| 1384 |
if has_user_location
|
|
|
|
| 1388 |
action="clarification",
|
| 1389 |
target_category=state.target_category,
|
| 1390 |
category_values=(),
|
| 1391 |
+
hard_filters=hard_filters,
|
| 1392 |
soft_preferences=state.soft_preferences,
|
| 1393 |
exclusions=state.exclusions,
|
| 1394 |
reference=state.reference,
|
|
|
|
| 1396 |
semantic_query="",
|
| 1397 |
confidence=0.5,
|
| 1398 |
state_patch=ConversationStatePatch(
|
| 1399 |
+
hard_filters=(
|
| 1400 |
+
hard_filters if hard_filters != state.hard_filters else None
|
| 1401 |
+
),
|
| 1402 |
pending_clarification=pending,
|
| 1403 |
+
taxonomy_version=self._taxonomy.version,
|
| 1404 |
),
|
| 1405 |
category_source=(
|
| 1406 |
"conversation_state" if state.target_category else "unresolved"
|
|
|
|
| 1458 |
)
|
| 1459 |
|
| 1460 |
|
| 1461 |
+
def _social_turn(normalized: str) -> tuple[str, str] | None:
|
| 1462 |
+
standalone = re.sub(r"[^a-z0-9]+", " ", normalized).strip()
|
| 1463 |
+
if standalone in _SOCIAL_GREETING_VALUES:
|
| 1464 |
+
return (
|
| 1465 |
+
"greeting",
|
| 1466 |
+
"¡Hola! Cuéntame qué tipo de lugar o actividad buscas y te ayudo "
|
| 1467 |
+
"a encontrar opciones cerca de ti.",
|
| 1468 |
+
)
|
| 1469 |
+
if standalone in _SOCIAL_THANKS_VALUES:
|
| 1470 |
+
return (
|
| 1471 |
+
"thanks",
|
| 1472 |
+
"¡Con gusto! Cuando quieras, dime qué lugar o plan buscas y seguimos.",
|
| 1473 |
+
)
|
| 1474 |
+
if standalone in _SOCIAL_FAREWELL_VALUES:
|
| 1475 |
+
return (
|
| 1476 |
+
"farewell",
|
| 1477 |
+
"¡Hasta luego! Cuando necesites ideas de lugares, aquí estaré.",
|
| 1478 |
+
)
|
| 1479 |
+
if standalone in _SOCIAL_ACKNOWLEDGEMENT_VALUES:
|
| 1480 |
+
return (
|
| 1481 |
+
"acknowledgement",
|
| 1482 |
+
"Perfecto. Dime qué lugar o actividad te interesa para continuar.",
|
| 1483 |
+
)
|
| 1484 |
+
return None
|
| 1485 |
+
|
| 1486 |
+
|
| 1487 |
+
def _persistent_hard_filters(filters: dict[str, Any]) -> dict[str, Any]:
|
| 1488 |
+
return {
|
| 1489 |
+
key: value
|
| 1490 |
+
for key, value in filters.items()
|
| 1491 |
+
if key not in _TRANSIENT_HARD_FILTER_KEYS
|
| 1492 |
+
}
|
| 1493 |
+
|
| 1494 |
+
|
| 1495 |
+
def _humanize_category_label(value: str) -> str:
|
| 1496 |
+
label = " ".join(value.replace("_", " ").split()).strip()
|
| 1497 |
+
if not label:
|
| 1498 |
+
return "Lugar"
|
| 1499 |
+
return label[:1].upper() + label[1:]
|
| 1500 |
+
|
| 1501 |
+
|
| 1502 |
def _contains_phrase(text: str, phrase: str) -> bool:
|
| 1503 |
return bool(re.search(rf"(?<!\w){re.escape(phrase)}(?!\w)", text))
|
| 1504 |
|
app/modules/places/infrastructure/main_api_place_source.py
CHANGED
|
@@ -173,11 +173,27 @@ def place_to_source_record(place: dict[str, Any]) -> PlaceSourceRecord | None:
|
|
| 173 |
|
| 174 |
name = str(_first_present(place, "name", "title", default="")).strip()
|
| 175 |
category = _first_present(place, "category", "type")
|
|
|
|
| 176 |
city = _first_present(place, "city", "municipality")
|
| 177 |
state = _first_present(place, "state", default="Chiapas")
|
| 178 |
source = _first_present(place, "source")
|
| 179 |
price_range = _first_present(place, "price_range", "priceRange")
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
description = str(_first_present(place, "description", "summary", "about", default=""))
|
| 182 |
address = str(_first_present(place, "address", "formatted_address", default=""))
|
| 183 |
|
|
@@ -196,11 +212,12 @@ def place_to_source_record(place: dict[str, Any]) -> PlaceSourceRecord | None:
|
|
| 196 |
metadata = {
|
| 197 |
"name": name,
|
| 198 |
"category": _to_metadata_value(category),
|
|
|
|
| 199 |
"city": _to_metadata_value(city),
|
| 200 |
"state": _to_metadata_value(state),
|
| 201 |
"source": _to_metadata_value(source),
|
| 202 |
"price_range": _to_metadata_value(price_range),
|
| 203 |
-
"is_active":
|
| 204 |
"occasion": ",".join(occasion),
|
| 205 |
"tags": ",".join(resolved_tags.names),
|
| 206 |
"tag_ids": list(resolved_tags.ids),
|
|
@@ -218,7 +235,7 @@ def place_to_source_record(place: dict[str, Any]) -> PlaceSourceRecord | None:
|
|
| 218 |
{
|
| 219 |
"document": document,
|
| 220 |
"metadata": filtered_metadata,
|
| 221 |
-
"is_active":
|
| 222 |
"semantic_document_version": PLACE_SEMANTIC_DOCUMENT_VERSION,
|
| 223 |
}
|
| 224 |
)
|
|
@@ -228,7 +245,7 @@ def place_to_source_record(place: dict[str, Any]) -> PlaceSourceRecord | None:
|
|
| 228 |
document=document,
|
| 229 |
metadata=filtered_metadata,
|
| 230 |
content_hash=content_hash,
|
| 231 |
-
is_active=
|
| 232 |
)
|
| 233 |
|
| 234 |
|
|
@@ -254,6 +271,22 @@ def _as_text_list(value: Any) -> list[str]:
|
|
| 254 |
return [str(value).strip()]
|
| 255 |
|
| 256 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
def _to_metadata_value(value: Any) -> str | int | float | bool | None:
|
| 258 |
if value is None or isinstance(value, str | int | float | bool):
|
| 259 |
return value
|
|
|
|
| 173 |
|
| 174 |
name = str(_first_present(place, "name", "title", default="")).strip()
|
| 175 |
category = _first_present(place, "category", "type")
|
| 176 |
+
category_label = _first_present(place, "category_label", "categoryLabel")
|
| 177 |
city = _first_present(place, "city", "municipality")
|
| 178 |
state = _first_present(place, "state", default="Chiapas")
|
| 179 |
source = _first_present(place, "source")
|
| 180 |
price_range = _first_present(place, "price_range", "priceRange")
|
| 181 |
+
explicit_is_active = _first_present(place, "is_active", "isActive")
|
| 182 |
+
is_permanently_closed = _as_bool(
|
| 183 |
+
_first_present(
|
| 184 |
+
place,
|
| 185 |
+
"is_permanently_closed",
|
| 186 |
+
"isPermanentlyClosed",
|
| 187 |
+
default=False,
|
| 188 |
+
),
|
| 189 |
+
default=False,
|
| 190 |
+
)
|
| 191 |
+
if is_permanently_closed:
|
| 192 |
+
is_active = False
|
| 193 |
+
elif explicit_is_active is None:
|
| 194 |
+
is_active = True
|
| 195 |
+
else:
|
| 196 |
+
is_active = _as_bool(explicit_is_active, default=True)
|
| 197 |
description = str(_first_present(place, "description", "summary", "about", default=""))
|
| 198 |
address = str(_first_present(place, "address", "formatted_address", default=""))
|
| 199 |
|
|
|
|
| 212 |
metadata = {
|
| 213 |
"name": name,
|
| 214 |
"category": _to_metadata_value(category),
|
| 215 |
+
"category_label": _to_metadata_value(category_label),
|
| 216 |
"city": _to_metadata_value(city),
|
| 217 |
"state": _to_metadata_value(state),
|
| 218 |
"source": _to_metadata_value(source),
|
| 219 |
"price_range": _to_metadata_value(price_range),
|
| 220 |
+
"is_active": is_active,
|
| 221 |
"occasion": ",".join(occasion),
|
| 222 |
"tags": ",".join(resolved_tags.names),
|
| 223 |
"tag_ids": list(resolved_tags.ids),
|
|
|
|
| 235 |
{
|
| 236 |
"document": document,
|
| 237 |
"metadata": filtered_metadata,
|
| 238 |
+
"is_active": is_active,
|
| 239 |
"semantic_document_version": PLACE_SEMANTIC_DOCUMENT_VERSION,
|
| 240 |
}
|
| 241 |
)
|
|
|
|
| 245 |
document=document,
|
| 246 |
metadata=filtered_metadata,
|
| 247 |
content_hash=content_hash,
|
| 248 |
+
is_active=is_active,
|
| 249 |
)
|
| 250 |
|
| 251 |
|
|
|
|
| 271 |
return [str(value).strip()]
|
| 272 |
|
| 273 |
|
| 274 |
+
def _as_bool(value: Any, *, default: bool) -> bool:
|
| 275 |
+
if value is None:
|
| 276 |
+
return default
|
| 277 |
+
if isinstance(value, bool):
|
| 278 |
+
return value
|
| 279 |
+
if isinstance(value, int | float):
|
| 280 |
+
return value != 0
|
| 281 |
+
if isinstance(value, str):
|
| 282 |
+
normalized = value.strip().casefold()
|
| 283 |
+
if normalized in {"true", "1", "yes", "si", "sí"}:
|
| 284 |
+
return True
|
| 285 |
+
if normalized in {"false", "0", "no", ""}:
|
| 286 |
+
return False
|
| 287 |
+
return bool(value)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
def _to_metadata_value(value: Any) -> str | int | float | bool | None:
|
| 291 |
if value is None or isinstance(value, str | int | float | bool):
|
| 292 |
return value
|
app/modules/places/infrastructure/open_vocabulary_category_classifier.py
CHANGED
|
@@ -180,6 +180,26 @@ class OpenVocabularyPlaceCategoryClassifier:
|
|
| 180 |
)
|
| 181 |
return tuple(matches)
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
def classify(self, text: str) -> PlaceCategoryInference | None:
|
| 184 |
"""Return the best concept only when its score and margin are sufficient."""
|
| 185 |
|
|
|
|
| 180 |
)
|
| 181 |
return tuple(matches)
|
| 182 |
|
| 183 |
+
def rank_supported(
|
| 184 |
+
self,
|
| 185 |
+
text: str,
|
| 186 |
+
limit: int = 3,
|
| 187 |
+
) -> tuple[PlaceCategoryMatch, ...]:
|
| 188 |
+
"""Return only hypotheses with enough absolute semantic evidence.
|
| 189 |
+
|
| 190 |
+
``rank`` intentionally exposes diagnostic nearest neighbours even for
|
| 191 |
+
out-of-distribution text. Those raw neighbours must not become public
|
| 192 |
+
clarification buttons. Ambiguous supported concepts may remain tied;
|
| 193 |
+
the margin is a requirement for automatic classification, not for
|
| 194 |
+
asking the user to choose between two genuinely plausible options.
|
| 195 |
+
"""
|
| 196 |
+
|
| 197 |
+
return tuple(
|
| 198 |
+
match
|
| 199 |
+
for match in self.rank(text, limit=limit)
|
| 200 |
+
if match.score >= self._minimum_similarity
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
def classify(self, text: str) -> PlaceCategoryInference | None:
|
| 204 |
"""Return the best concept only when its score and margin are sufficient."""
|
| 205 |
|
app/modules/places/infrastructure/semantic_activity_classifier.py
CHANGED
|
@@ -54,3 +54,10 @@ class SemanticPlaceActivityClassifier:
|
|
| 54 |
|
| 55 |
def rank(self, text: str, limit: int = 3) -> tuple[PlaceCategoryMatch, ...]:
|
| 56 |
return self._delegate.rank(text, limit=limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
def rank(self, text: str, limit: int = 3) -> tuple[PlaceCategoryMatch, ...]:
|
| 56 |
return self._delegate.rank(text, limit=limit)
|
| 57 |
+
|
| 58 |
+
def rank_supported(
|
| 59 |
+
self,
|
| 60 |
+
text: str,
|
| 61 |
+
limit: int = 3,
|
| 62 |
+
) -> tuple[PlaceCategoryMatch, ...]:
|
| 63 |
+
return self._delegate.rank_supported(text, limit=limit)
|
app/shared/config/settings.py
CHANGED
|
@@ -321,6 +321,12 @@ class Settings(BaseSettings):
|
|
| 321 |
le=50_000,
|
| 322 |
alias="PLACES_CHAT_DEFAULT_RADIUS_METERS",
|
| 323 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
places_chat_ranking_version: str = Field(
|
| 325 |
default="places-chat-v2",
|
| 326 |
min_length=1,
|
|
@@ -480,6 +486,14 @@ class Settings(BaseSettings):
|
|
| 480 |
"PLACES_CHAT_INTENT_PROVIDER debe ser disabled, "
|
| 481 |
"deterministic o bert"
|
| 482 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
if self.places_chat_bert_model_path is not None:
|
| 484 |
self.places_chat_bert_model_path = (
|
| 485 |
self.places_chat_bert_model_path.strip() or None
|
|
|
|
| 321 |
le=50_000,
|
| 322 |
alias="PLACES_CHAT_DEFAULT_RADIUS_METERS",
|
| 323 |
)
|
| 324 |
+
places_chat_max_auto_radius_meters: int = Field(
|
| 325 |
+
default=50_000,
|
| 326 |
+
ge=1,
|
| 327 |
+
le=50_000,
|
| 328 |
+
alias="PLACES_CHAT_MAX_AUTO_RADIUS_METERS",
|
| 329 |
+
)
|
| 330 |
places_chat_ranking_version: str = Field(
|
| 331 |
default="places-chat-v2",
|
| 332 |
min_length=1,
|
|
|
|
| 486 |
"PLACES_CHAT_INTENT_PROVIDER debe ser disabled, "
|
| 487 |
"deterministic o bert"
|
| 488 |
)
|
| 489 |
+
if (
|
| 490 |
+
self.places_chat_max_auto_radius_meters
|
| 491 |
+
< self.places_chat_default_radius_meters
|
| 492 |
+
):
|
| 493 |
+
raise ValueError(
|
| 494 |
+
"PLACES_CHAT_MAX_AUTO_RADIUS_METERS debe ser mayor o igual que "
|
| 495 |
+
"PLACES_CHAT_DEFAULT_RADIUS_METERS"
|
| 496 |
+
)
|
| 497 |
if self.places_chat_bert_model_path is not None:
|
| 498 |
self.places_chat_bert_model_path = (
|
| 499 |
self.places_chat_bert_model_path.strip() or None
|
docs/api_endpoints.md
CHANGED
|
@@ -388,6 +388,12 @@ scores tecnicos. No devuelve cards, coordenadas ni metadata privada. `clarificat
|
|
| 388 |
hidratar los IDs, revalidar el anchor y aplicar distancia/PostGIS antes de responder a
|
| 389 |
la app.
|
| 390 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
`state` puede incluir `target_category`, `hard_filters`, `soft_preferences`,
|
| 392 |
`exclusions`, `reference`, `explicit_target_location` y `pending_clarification`. Si
|
| 393 |
`taxonomy_version` no coincide con la version desplegada, el endpoint responde `409`.
|
|
@@ -520,6 +526,20 @@ clasificador exige similitud minima y separacion frente a la segunda categoria;
|
|
| 520 |
frase abierta como `quiero salir` aun solicita aclaracion. Una categoria explicita tiene
|
| 521 |
prioridad y una nueva intencion clara elimina un `pending_clarification` obsoleto.
|
| 522 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
## 3. Publicaciones
|
| 524 |
|
| 525 |
### `POST /posts/recommendations`
|
|
@@ -974,6 +994,8 @@ finalizados. Los cursores quedan ligados a query, recursos, filtros, ubicacion,
|
|
| 974 |
| `PLACES_CHAT_MIN_CONTENT_SCORE` | Umbral minimo antes de devolver candidatos |
|
| 975 |
| `PLACES_CHAT_INTENT_MIN_CONFIDENCE` | Confianza minima; por debajo se solicita aclaracion |
|
| 976 |
| `PLACES_CHAT_AMBIGUITY_DELTA` | Diferencia maxima para considerar ambiguos dos anchors |
|
|
|
|
|
|
|
| 977 |
| `PLACES_CHAT_RANKING_VERSION` | Version observable de la politica de ranking |
|
| 978 |
| `PLACES_CHAT_TAXONOMY_VERSION` | Version del parser y del estado conversacional |
|
| 979 |
| `MAX_REQUEST_BODY_BYTES` | Debe ser al menos `131072`; valor recomendado `262144` para 500 candidatos |
|
|
|
|
| 388 |
hidratar los IDs, revalidar el anchor y aplicar distancia/PostGIS antes de responder a
|
| 389 |
la app.
|
| 390 |
|
| 391 |
+
Un turno puramente social se devuelve como `action="no_match"`,
|
| 392 |
+
`unresolved=["non_search_input"]`, `candidates=[]` y
|
| 393 |
+
`metadata.input_kind="non_search"`, con un mensaje conversacional. Esto evita convertir
|
| 394 |
+
saludos como `ola` en categorias cercanas por similitud. Si el mensaje tambien contiene
|
| 395 |
+
una solicitud de lugares, se procesa como busqueda normal.
|
| 396 |
+
|
| 397 |
`state` puede incluir `target_category`, `hard_filters`, `soft_preferences`,
|
| 398 |
`exclusions`, `reference`, `explicit_target_location` y `pending_clarification`. Si
|
| 399 |
`taxonomy_version` no coincide con la version desplegada, el endpoint responde `409`.
|
|
|
|
| 526 |
frase abierta como `quiero salir` aun solicita aclaracion. Una categoria explicita tiene
|
| 527 |
prioridad y una nueva intencion clara elimina un `pending_clarification` obsoleto.
|
| 528 |
|
| 529 |
+
Las opciones de categoria conservan `option.id` y el `value` pendiente como valores
|
| 530 |
+
tecnicos. NLP humaniza la etiqueta fallback y solo publica hipotesis respaldadas por
|
| 531 |
+
candidatos locales con evidencia suficiente. La API principal debe localizar
|
| 532 |
+
`label/message` mediante su catalogo sin alterar IDs, valores, orden ni allowlist.
|
| 533 |
+
|
| 534 |
+
Cuando hay coordenadas y proveedor nearby, un radio implicito comienza en
|
| 535 |
+
`PLACES_CHAT_DEFAULT_RADIUS_METERS` y puede ampliarse de forma acotada hasta
|
| 536 |
+
`PLACES_CHAT_MAX_AUTO_RADIUS_METERS`. El valor realmente consultado se devuelve en
|
| 537 |
+
`location_directive.radius_meters`. Un radio explicito (`strict_radius=true`) no se
|
| 538 |
+
amplia y los `place_ids` geograficos nunca se guardan en el estado conversacional.
|
| 539 |
+
|
| 540 |
+
Cambios de integracion fuera de NLP: `docs/cambios_api_principal_chat_lugares.md` y
|
| 541 |
+
`docs/cambios_app_movil_chat_lugares.md`.
|
| 542 |
+
|
| 543 |
## 3. Publicaciones
|
| 544 |
|
| 545 |
### `POST /posts/recommendations`
|
|
|
|
| 994 |
| `PLACES_CHAT_MIN_CONTENT_SCORE` | Umbral minimo antes de devolver candidatos |
|
| 995 |
| `PLACES_CHAT_INTENT_MIN_CONFIDENCE` | Confianza minima; por debajo se solicita aclaracion |
|
| 996 |
| `PLACES_CHAT_AMBIGUITY_DELTA` | Diferencia maxima para considerar ambiguos dos anchors |
|
| 997 |
+
| `PLACES_CHAT_DEFAULT_RADIUS_METERS` | Radio inicial para ubicacion implicita; default `5000` |
|
| 998 |
+
| `PLACES_CHAT_MAX_AUTO_RADIUS_METERS` | Limite de expansion automatica no estricta; default `50000` |
|
| 999 |
| `PLACES_CHAT_RANKING_VERSION` | Version observable de la politica de ranking |
|
| 1000 |
| `PLACES_CHAT_TAXONOMY_VERSION` | Version del parser y del estado conversacional |
|
| 1001 |
| `MAX_REQUEST_BODY_BYTES` | Debe ser al menos `131072`; valor recomendado `262144` para 500 candidatos |
|
docs/cambios_api_principal_chat_lugares.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cambios requeridos en la API principal para el chat de lugares
|
| 2 |
+
|
| 3 |
+
> Estado: pendiente fuera de este repositorio. Este documento describe cambios que deben implementarse en la API principal. No se modificó código Go como parte del ajuste del servicio NLP.
|
| 4 |
+
|
| 5 |
+
## Objetivo
|
| 6 |
+
|
| 7 |
+
La API principal debe conservar el ranking y las decisiones producidas por `POST /internal/places/chat`, hidratar únicamente recursos vigentes y presentar aclaraciones localizadas sin alterar los identificadores técnicos que protegen el flujo conversacional.
|
| 8 |
+
|
| 9 |
+
## 1. No volver a aplicar la categoría como filtro duro
|
| 10 |
+
|
| 11 |
+
NLP trata la categoría como evidencia de ranking, no como una restricción absoluta. Después de recibir los candidatos de NLP, la API principal no debe descartarlos mediante un `categoryMatches` estricto ni exigir que la categoría almacenada coincida literalmente con `target_category`.
|
| 12 |
+
|
| 13 |
+
La hidratación posterior a NLP puede aplicar estas restricciones duras:
|
| 14 |
+
|
| 15 |
+
- existencia y vigencia del lugar;
|
| 16 |
+
- permisos y reglas de visibilidad;
|
| 17 |
+
- alcance geográfico explícito y distancia final con PostGIS;
|
| 18 |
+
- cualquier otra regla de negocio que sea independiente de una coincidencia textual de categoría.
|
| 19 |
+
|
| 20 |
+
Una diferencia de categoría no debe eliminar por sí sola un candidato. Por ejemplo, una intención `cafe` puede recuperar un lugar registrado con una categoría compatible o más específica. NLP ya incorpora categoría, compatibilidad, contenido semántico, evidencia lexical y exclusiones en su puntuación.
|
| 21 |
+
|
| 22 |
+
Si `categoryMatches` no puede retirarse inmediatamente, debe ejecutarse temporalmente en modo de observación: registrar qué candidatos habría descartado, pero no modificar la respuesta. Su eliminación definitiva debe ocurrir después de comparar esos registros con el ranking de NLP.
|
| 23 |
+
|
| 24 |
+
## 2. Localizar las opciones de aclaración sin cambiar su identidad
|
| 25 |
+
|
| 26 |
+
La localización aplica exclusivamente cuando:
|
| 27 |
+
|
| 28 |
+
```text
|
| 29 |
+
clarification.kind = target_category | intent_category
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
No debe aplicarse a `location_scope`, `location_anchor`, `reference_entity` ni `reference_location_anchor`, porque sus etiquetas ya describen lugares o acciones concretas.
|
| 33 |
+
|
| 34 |
+
### Fuente de traducciones
|
| 35 |
+
|
| 36 |
+
La API principal ya expone:
|
| 37 |
+
|
| 38 |
+
```http
|
| 39 |
+
GET /api/v1/places/categories?lang=es
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
El catálogo devuelto contiene pares equivalentes a:
|
| 43 |
+
|
| 44 |
+
```json
|
| 45 |
+
{
|
| 46 |
+
"value": "religious_organization",
|
| 47 |
+
"label": "Organización religiosa"
|
| 48 |
+
}
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
La API principal debe reutilizar ese catálogo, preferentemente mediante la misma capa de aplicación o repositorio que atiende el endpoint, y puede almacenarlo en caché por idioma. Un error al obtener traducciones no debe hacer fallar el chat.
|
| 52 |
+
|
| 53 |
+
### Algoritmo de correspondencia
|
| 54 |
+
|
| 55 |
+
Para cada elemento de `clarification.options`:
|
| 56 |
+
|
| 57 |
+
1. Buscar por `id` la opción correspondiente en `state_patch.pending_clarification.options`.
|
| 58 |
+
2. Tomar su `value` técnico.
|
| 59 |
+
3. Buscar una entrada del catálogo cuyo `value` coincida exactamente con ese valor.
|
| 60 |
+
4. En el DTO dirigido a la app, reemplazar únicamente `label` y `message` por el `label` localizado.
|
| 61 |
+
5. Conservar el orden original de NLP.
|
| 62 |
+
|
| 63 |
+
Ejemplo de salida para la app:
|
| 64 |
+
|
| 65 |
+
```json
|
| 66 |
+
{
|
| 67 |
+
"id": "religious_organization",
|
| 68 |
+
"label": "Organización religiosa",
|
| 69 |
+
"message": "Organización religiosa"
|
| 70 |
+
}
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
### Invariantes obligatorios
|
| 74 |
+
|
| 75 |
+
La localización nunca debe modificar:
|
| 76 |
+
|
| 77 |
+
- `clarification.id`;
|
| 78 |
+
- `clarification.options[].id`;
|
| 79 |
+
- `state_patch.pending_clarification.options[].id`;
|
| 80 |
+
- `state_patch.pending_clarification.options[].value`;
|
| 81 |
+
- el orden de las opciones;
|
| 82 |
+
- la allowlist que se persiste para validar el turno siguiente.
|
| 83 |
+
|
| 84 |
+
El estado técnico recibido desde NLP debe persistirse y reenviarse sin sustituir IDs o valores por textos traducidos. La traducción pertenece únicamente al DTO de presentación.
|
| 85 |
+
|
| 86 |
+
Si no existe una traducción, el fallback debe ser el `label` entregado por NLP. Como último recurso puede humanizarse el valor reemplazando guiones bajos por espacios; nunca debe usarse ese texto como `option_id`.
|
| 87 |
+
|
| 88 |
+
## 3. Selección estructurada
|
| 89 |
+
|
| 90 |
+
Cuando la app seleccione una opción, la API principal debe reenviar a NLP tanto el estado pendiente original como la selección estructurada:
|
| 91 |
+
|
| 92 |
+
```json
|
| 93 |
+
{
|
| 94 |
+
"clarification_choice": {
|
| 95 |
+
"clarification_id": "22222222-2222-4222-8222-222222222222",
|
| 96 |
+
"option_id": "religious_organization"
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
No debe intentar resolver la selección comparando el texto localizado, la posición del botón o el mensaje visible. Una selección inexistente u obsoleta debe conservar el manejo de conflicto del contrato interno.
|
| 102 |
+
|
| 103 |
+
## 4. Hidratación y observabilidad
|
| 104 |
+
|
| 105 |
+
La API principal es responsable de hidratar los IDs técnicos de NLP y de aplicar
|
| 106 |
+
vigencia, permisos y distancia.
|
| 107 |
+
|
| 108 |
+
El mensaje actual `No encontré lugares vigentes que cumplan con los criterios` se
|
| 109 |
+
construye después de que NLP ya devolvió candidatos y la API principal terminó con una
|
| 110 |
+
lista hidratada vacía. Por tanto, verlo no demuestra que `/nearby` haya encontrado cero
|
| 111 |
+
lugares: también puede significar que `categoryMatches`, vigencia, permisos o distancia
|
| 112 |
+
descartaron todos los IDs. Esta distinción debe conservarse en métricas y logs.
|
| 113 |
+
|
| 114 |
+
Para poder explicar una respuesta vacía debe emitir un registro estructurado,
|
| 115 |
+
correlacionado por `trace_id`, que incluya al menos:
|
| 116 |
+
|
| 117 |
+
- `conversation_id` y número de turno;
|
| 118 |
+
- `trace_id` de NLP;
|
| 119 |
+
- cantidad de candidatos recibidos desde NLP;
|
| 120 |
+
- cantidad de IDs encontrados durante la hidratación;
|
| 121 |
+
- cantidad final enviada a la app;
|
| 122 |
+
- conteos de descarte por causa: inexistente, inactivo o eliminado, sin permisos, fuera del radio y error de hidratación;
|
| 123 |
+
- cantidad que el antiguo `categoryMatches` habría descartado durante su periodo de observación.
|
| 124 |
+
|
| 125 |
+
No deben registrarse coordenadas exactas ni datos privados innecesarios. Los IDs pueden registrarse solo cuando la política operativa lo permita; los conteos y el `trace_id` son obligatorios.
|
| 126 |
+
|
| 127 |
+
Si NLP devuelve `action="recommendations"` pero ningún candidato sobrevive a las reglas autorizadas, la API principal debe producir un estado de `no_match` coherente para la app y registrar una razón interna como `empty_after_hydration`. No debe atribuir automáticamente el vacío a la categoría ni ocultar la causa operativa.
|
| 128 |
+
|
| 129 |
+
## 5. Ciclo de vida de lugares
|
| 130 |
+
|
| 131 |
+
La fuente utilizada para sincronizar Places con NLP y los endpoints de hidratación/nearby deben compartir la misma definición de vigencia.
|
| 132 |
+
|
| 133 |
+
El contrato de ciclo de vida debe cubrir:
|
| 134 |
+
|
| 135 |
+
- **snapshot completo:** debe tener un límite consistente, paginación estable y una señal inequívoca de finalización exitosa;
|
| 136 |
+
- **cierre o desactivación:** debe exponer `is_active=false` o un evento equivalente para que NLP deje de recomendar el lugar;
|
| 137 |
+
- **eliminación:** debe producir una baja o tombstone identificable, no limitarse a omitir silenciosamente el registro;
|
| 138 |
+
- **reconciliación:** los lugares ausentes solo pueden marcarse inactivos después de completar correctamente un snapshot total;
|
| 139 |
+
- **fallo o snapshot parcial:** nunca debe provocar una desactivación masiva ni considerarse una reconciliación válida;
|
| 140 |
+
- **consistencia:** `/api/v1/places/nearby`, la hidratación por IDs y el snapshot deben usar el mismo tipo de ID y las mismas reglas de vigencia.
|
| 141 |
+
|
| 142 |
+
Estos requisitos permiten que el filtro `is_active=true` de NLP represente el estado real y reducen los casos en que Go recibe IDs que ya no puede hidratar.
|
| 143 |
+
|
| 144 |
+
## Criterios de aceptación
|
| 145 |
+
|
| 146 |
+
- Un candidato relevante no se elimina solo porque su categoría almacenada no coincide literalmente con `target_category`.
|
| 147 |
+
- `categoryMatches` deja de modificar la lista final; mientras exista en observación, solo genera métricas.
|
| 148 |
+
- Para una opción con `value="religious_organization"`, la app recibe `label="Organización religiosa"` y el mismo `id` técnico.
|
| 149 |
+
- La selección posterior reenvía el `clarification_id` y `option_id` originales, aunque el usuario haya visto un texto traducido.
|
| 150 |
+
- La localización conserva orden, valores y allowlist, y no modifica aclaraciones geográficas o de referencias.
|
| 151 |
+
- Si el catálogo de traducciones no está disponible, el chat continúa con un fallback legible.
|
| 152 |
+
- Cada respuesta vacía después de hidratación puede investigarse mediante `trace_id`, conteos y razones de descarte.
|
| 153 |
+
- Un lugar cerrado o eliminado deja de aparecer después de una sincronización completa exitosa.
|
| 154 |
+
- Un snapshot parcial o fallido no desactiva lugares que siguen vigentes.
|
docs/cambios_app_movil_chat_lugares.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cambios requeridos en la app móvil para el chat de lugares
|
| 2 |
+
|
| 3 |
+
> Estado: pendiente fuera de este repositorio. Este documento describe cambios que deben implementarse en la app móvil. No se modificó código móvil como parte del ajuste del servicio NLP.
|
| 4 |
+
|
| 5 |
+
## Objetivo
|
| 6 |
+
|
| 7 |
+
La app debe presentar aclaraciones con sus textos localizados y conservar la identidad estructurada de cada opción al continuar la conversación. La app consume únicamente el contrato público de la API principal; nunca debe llamar directamente al servicio NLP.
|
| 8 |
+
|
| 9 |
+
## 1. Renderizado de aclaraciones
|
| 10 |
+
|
| 11 |
+
Cuando la respuesta incluya `action="clarification"`, la interfaz debe:
|
| 12 |
+
|
| 13 |
+
- mostrar `clarification.prompt` o el mensaje conversacional definido por la API principal;
|
| 14 |
+
- conservar el orden de `clarification.options`;
|
| 15 |
+
- usar `option.label` como texto visible de cada botón;
|
| 16 |
+
- mantener `option.id` únicamente como dato interno asociado al botón.
|
| 17 |
+
|
| 18 |
+
La app nunca debe mostrar `option.id` ni `value`, ni construir etiquetas reemplazando guiones bajos. Tampoco debe traducir categorías por su cuenta si la API principal ya entrega `label` y `message` localizados.
|
| 19 |
+
|
| 20 |
+
Ejemplo esperado:
|
| 21 |
+
|
| 22 |
+
```json
|
| 23 |
+
{
|
| 24 |
+
"id": "religious_organization",
|
| 25 |
+
"label": "Organización religiosa",
|
| 26 |
+
"message": "Organización religiosa"
|
| 27 |
+
}
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
El botón debe mostrar `Organización religiosa`, no `religious_organization`.
|
| 31 |
+
|
| 32 |
+
## 2. Envío de la selección
|
| 33 |
+
|
| 34 |
+
Al tocar un botón, la app debe conservar la opción seleccionada y enviar a la API principal una elección estructurada:
|
| 35 |
+
|
| 36 |
+
```json
|
| 37 |
+
{
|
| 38 |
+
"message": "Organización religiosa",
|
| 39 |
+
"clarification_choice": {
|
| 40 |
+
"clarification_id": "22222222-2222-4222-8222-222222222222",
|
| 41 |
+
"option_id": "religious_organization"
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
El texto de la burbuja del usuario puede tomarse de `option.message`, pero la selección se identifica exclusivamente con:
|
| 47 |
+
|
| 48 |
+
- `clarification.id` como `clarification_id`;
|
| 49 |
+
- `option.id` como `option_id`.
|
| 50 |
+
|
| 51 |
+
La app no debe:
|
| 52 |
+
|
| 53 |
+
- enviar el `label` o `message` traducido como `option_id`;
|
| 54 |
+
- resolver una opción por su posición en la lista;
|
| 55 |
+
- inferir la selección comparando texto libre;
|
| 56 |
+
- reconstruir IDs a partir de una etiqueta;
|
| 57 |
+
- reutilizar una opción perteneciente a una aclaración anterior.
|
| 58 |
+
|
| 59 |
+
La API principal conserva el estado conversacional y la allowlist. La app solo debe mantener los identificadores necesarios para enviar la elección del turno visible.
|
| 60 |
+
|
| 61 |
+
## 3. Estados de carga y selección
|
| 62 |
+
|
| 63 |
+
Después de tocar una opción, el botón debe quedar temporalmente deshabilitado para evitar envíos duplicados. Las opciones anteriores deben dejar de ser interactivas cuando llegue el siguiente turno o cuando la API informe que la aclaración es obsoleta.
|
| 64 |
+
|
| 65 |
+
Si la API principal responde con un conflicto por una selección vencida, la app debe descartar esos botones y mostrar el estado conversacional más reciente; no debe reintentar con el texto o con otro índice.
|
| 66 |
+
|
| 67 |
+
## 4. Manejo de `no_match`
|
| 68 |
+
|
| 69 |
+
Cuando la API principal devuelva `action="no_match"`, la app debe:
|
| 70 |
+
|
| 71 |
+
- mostrar el mensaje recibido;
|
| 72 |
+
- retirar las opciones de aclaración del turno anterior;
|
| 73 |
+
- limpiar cards o recomendaciones anteriores que pudieran confundirse con la respuesta actual;
|
| 74 |
+
- mantener disponible el campo de texto para que el usuario reformule su búsqueda;
|
| 75 |
+
- no fabricar categorías, cards ni mensajes alternativos a partir de IDs previos.
|
| 76 |
+
|
| 77 |
+
Este comportamiento también permite presentar respuestas amistosas ante saludos o mensajes que todavía no contienen una intención de búsqueda, sin mostrar opciones arbitrarias.
|
| 78 |
+
|
| 79 |
+
## 5. Responsabilidades que no pertenecen a móvil
|
| 80 |
+
|
| 81 |
+
La app no debe:
|
| 82 |
+
|
| 83 |
+
- consumir `POST /internal/places/chat` directamente;
|
| 84 |
+
- consultar `GET /api/v1/places/categories?lang=es` para corregir una respuesta que la API principal ya debe localizar;
|
| 85 |
+
- hidratar IDs de lugares;
|
| 86 |
+
- decidir vigencia, permisos o distancia;
|
| 87 |
+
- volver a filtrar resultados por categoría.
|
| 88 |
+
|
| 89 |
+
Estas responsabilidades pertenecen a la API principal y a NLP según el contrato de integración.
|
| 90 |
+
|
| 91 |
+
## Criterios de aceptación
|
| 92 |
+
|
| 93 |
+
- Ningún botón muestra valores como `ropa_barata` o `religious_organization` cuando existe un `label` localizado.
|
| 94 |
+
- Todos los botones renderizan exactamente `option.label` y conservan el orden recibido.
|
| 95 |
+
- Al seleccionar `Organización religiosa`, la petición envía `option_id="religious_organization"` y el `clarification_id` vigente.
|
| 96 |
+
- Dos opciones con textos iguales siguen siendo distinguibles porque la selección usa su ID, no texto ni posición.
|
| 97 |
+
- Una traducción o cambio de copy no modifica el identificador enviado al backend.
|
| 98 |
+
- Una selección no puede enviarse dos veces mientras el turno está en proceso.
|
| 99 |
+
- Ante una aclaración obsoleta, la app elimina sus botones y no intenta resolverla por texto.
|
| 100 |
+
- Una respuesta `no_match` elimina cards y opciones anteriores y muestra el mensaje actual.
|
| 101 |
+
- La app no llama directamente a NLP ni duplica la lógica de traducción, hidratación o filtrado de la API principal.
|
| 102 |
+
|
tests/test_internal_places_chat.py
CHANGED
|
@@ -88,6 +88,28 @@ def test_internal_chat_recommends_restaurants_for_implicit_food_intent() -> None
|
|
| 88 |
assert payload["candidates"]
|
| 89 |
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
@pytest.mark.parametrize(
|
| 92 |
("message", "expected_place_id"),
|
| 93 |
(
|
|
|
|
| 88 |
assert payload["candidates"]
|
| 89 |
|
| 90 |
|
| 91 |
+
@pytest.mark.parametrize("message", ("hola", "ola", "¡buenas!"))
|
| 92 |
+
def test_internal_chat_answers_social_greetings_without_recommendations(
|
| 93 |
+
message: str,
|
| 94 |
+
) -> None:
|
| 95 |
+
client = TestClient(create_app())
|
| 96 |
+
|
| 97 |
+
response = client.post(
|
| 98 |
+
"/internal/places/chat",
|
| 99 |
+
json={**BASE_REQUEST, "message": message},
|
| 100 |
+
headers=AUTHORIZATION,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
assert response.status_code == 200
|
| 104 |
+
payload = response.json()
|
| 105 |
+
assert payload["action"] == "no_match"
|
| 106 |
+
assert payload["candidates"] == []
|
| 107 |
+
assert payload["clarification"] is None
|
| 108 |
+
assert payload["unresolved"] == ["non_search_input"]
|
| 109 |
+
assert payload["metadata"]["input_kind"] == "non_search"
|
| 110 |
+
assert "hola" in payload["message"].casefold()
|
| 111 |
+
|
| 112 |
+
|
| 113 |
@pytest.mark.parametrize(
|
| 114 |
("message", "expected_place_id"),
|
| 115 |
(
|
tests/test_main_api_place_source.py
CHANGED
|
@@ -31,6 +31,37 @@ def test_place_to_source_record_maps_api_place() -> None:
|
|
| 31 |
assert len(record.content_hash) == 64
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
def test_place_to_source_record_resolves_tags_without_token_repetition() -> None:
|
| 35 |
record = place_to_source_record(
|
| 36 |
{
|
|
|
|
| 31 |
assert len(record.content_hash) == 64
|
| 32 |
|
| 33 |
|
| 34 |
+
def test_place_to_source_record_maps_category_label_and_closed_state() -> None:
|
| 35 |
+
record = place_to_source_record(
|
| 36 |
+
{
|
| 37 |
+
"id": "place_closed",
|
| 38 |
+
"name": "Lugar cerrado",
|
| 39 |
+
"category": "religious_organization",
|
| 40 |
+
"categoryLabel": "Organización religiosa",
|
| 41 |
+
"isActive": True,
|
| 42 |
+
"isPermanentlyClosed": True,
|
| 43 |
+
}
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
assert record is not None
|
| 47 |
+
assert record.metadata["category_label"] == "Organización religiosa"
|
| 48 |
+
assert record.metadata["is_active"] is False
|
| 49 |
+
assert record.is_active is False
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_explicit_string_active_state_is_not_coerced_as_truthy() -> None:
|
| 53 |
+
record = place_to_source_record(
|
| 54 |
+
{
|
| 55 |
+
"id": "place_inactive",
|
| 56 |
+
"name": "Lugar inactivo",
|
| 57 |
+
"is_active": "false",
|
| 58 |
+
}
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
assert record is not None
|
| 62 |
+
assert record.is_active is False
|
| 63 |
+
|
| 64 |
+
|
| 65 |
def test_place_to_source_record_resolves_tags_without_token_repetition() -> None:
|
| 66 |
record = place_to_source_record(
|
| 67 |
{
|
tests/test_open_vocabulary_category_classifier.py
CHANGED
|
@@ -96,6 +96,45 @@ def test_rank_limit_still_calculates_top_margin_against_runner_up() -> None:
|
|
| 96 |
assert only_match[0].margin > 0.88
|
| 97 |
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
def test_query_and_concept_encoders_can_use_distinct_e5_prefix_roles() -> None:
|
| 100 |
vectors = _catalog_vectors()
|
| 101 |
query_embeddings = ControlledEmbeddingProvider(
|
|
|
|
| 96 |
assert only_match[0].margin > 0.88
|
| 97 |
|
| 98 |
|
| 99 |
+
def test_rank_supported_discards_nearest_neighbors_below_absolute_threshold() -> None:
|
| 100 |
+
classifier = OpenVocabularyPlaceCategoryClassifier(
|
| 101 |
+
_concepts(),
|
| 102 |
+
ControlledEmbeddingProvider(_catalog_vectors()),
|
| 103 |
+
minimum_similarity=0.5,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
raw_matches = classifier.rank("algo completamente distinto", limit=2)
|
| 107 |
+
supported_matches = classifier.rank_supported(
|
| 108 |
+
"algo completamente distinto",
|
| 109 |
+
limit=2,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
assert len(raw_matches) == 2
|
| 113 |
+
assert all(match.score < 0.5 for match in raw_matches)
|
| 114 |
+
assert supported_matches == ()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_rank_supported_keeps_supported_ties_for_clarification() -> None:
|
| 118 |
+
classifier = OpenVocabularyPlaceCategoryClassifier(
|
| 119 |
+
_concepts(),
|
| 120 |
+
ControlledEmbeddingProvider(_catalog_vectors()),
|
| 121 |
+
minimum_similarity=0.5,
|
| 122 |
+
minimum_margin=0.05,
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
supported_matches = classifier.rank_supported("quiero salir", limit=2)
|
| 126 |
+
|
| 127 |
+
assert classifier.classify("quiero salir") is None
|
| 128 |
+
assert [match.concept_id for match in supported_matches] == [
|
| 129 |
+
"sweet_baked_goods",
|
| 130 |
+
"urban_nature",
|
| 131 |
+
]
|
| 132 |
+
assert supported_matches[0].score == pytest.approx(
|
| 133 |
+
supported_matches[1].score
|
| 134 |
+
)
|
| 135 |
+
assert supported_matches[0].score > 0.5
|
| 136 |
+
|
| 137 |
+
|
| 138 |
def test_query_and_concept_encoders_can_use_distinct_e5_prefix_roles() -> None:
|
| 139 |
vectors = _catalog_vectors()
|
| 140 |
query_embeddings = ControlledEmbeddingProvider(
|
tests/test_place_chat_intent_parser.py
CHANGED
|
@@ -18,6 +18,98 @@ from app.modules.places.infrastructure.deterministic_intent_parser import (
|
|
| 18 |
)
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def test_category_is_not_polluted_by_the_location_anchor() -> None:
|
| 22 |
intent = DeterministicPlaceChatIntentParser().parse(
|
| 23 |
message="Recomiendame alguna cafeteria cerca del Parque Central",
|
|
|
|
| 18 |
)
|
| 19 |
|
| 20 |
|
| 21 |
+
@pytest.mark.parametrize(
|
| 22 |
+
"message",
|
| 23 |
+
("ola", "hola", "holi", "buenas", "¿qué tal?"),
|
| 24 |
+
)
|
| 25 |
+
def test_standalone_greetings_return_a_friendly_non_search_response(
|
| 26 |
+
message: str,
|
| 27 |
+
) -> None:
|
| 28 |
+
intent = DeterministicPlaceChatIntentParser().parse(
|
| 29 |
+
message=message,
|
| 30 |
+
state=ConversationState(),
|
| 31 |
+
has_user_location=True,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
assert intent.action == "no_match"
|
| 35 |
+
assert intent.unresolved == ("non_search_input",)
|
| 36 |
+
assert intent.response_message is not None
|
| 37 |
+
assert "hola" in intent.response_message.casefold()
|
| 38 |
+
assert "lugar o actividad" in intent.response_message.casefold()
|
| 39 |
+
assert intent.semantic_query == ""
|
| 40 |
+
assert intent.alternatives == ()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_greeting_with_a_place_request_remains_actionable() -> None:
|
| 44 |
+
intent = DeterministicPlaceChatIntentParser().parse(
|
| 45 |
+
message="hola, busco una cafeteria tranquila",
|
| 46 |
+
state=ConversationState(),
|
| 47 |
+
has_user_location=True,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
assert intent.action == "recommendations"
|
| 51 |
+
assert intent.target_category == "cafe"
|
| 52 |
+
assert "tranquilo" in intent.soft_preferences
|
| 53 |
+
assert "non_search_input" not in intent.unresolved
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_greeting_repeats_the_same_pending_clarification() -> None:
|
| 57 |
+
pending = PendingClarification(
|
| 58 |
+
clarification_id="pending-category-1",
|
| 59 |
+
kind="intent_category",
|
| 60 |
+
options=(
|
| 61 |
+
PendingClarificationOption(
|
| 62 |
+
option_id="cafe",
|
| 63 |
+
value="cafe",
|
| 64 |
+
label="Cafetería",
|
| 65 |
+
),
|
| 66 |
+
PendingClarificationOption(
|
| 67 |
+
option_id="restaurant",
|
| 68 |
+
value="restaurant",
|
| 69 |
+
label="Restaurante",
|
| 70 |
+
),
|
| 71 |
+
),
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
intent = DeterministicPlaceChatIntentParser().parse(
|
| 75 |
+
message="hola",
|
| 76 |
+
state=ConversationState(pending_clarification=pending),
|
| 77 |
+
has_user_location=True,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
assert intent.action == "clarification"
|
| 81 |
+
assert intent.clarification is not None
|
| 82 |
+
assert intent.clarification.clarification_id == pending.clarification_id
|
| 83 |
+
assert [option.option_id for option in intent.clarification.options] == [
|
| 84 |
+
"cafe",
|
| 85 |
+
"restaurant",
|
| 86 |
+
]
|
| 87 |
+
repeated = intent.state_patch.pending_clarification
|
| 88 |
+
assert repeated is not None
|
| 89 |
+
assert repeated.clarification_id == pending.clarification_id
|
| 90 |
+
assert repeated.options == pending.options
|
| 91 |
+
assert intent.response_message is not None
|
| 92 |
+
assert intent.response_message.startswith("¡Hola!")
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_transient_place_ids_are_removed_from_filters_and_state_patch() -> None:
|
| 96 |
+
intent = DeterministicPlaceChatIntentParser().parse(
|
| 97 |
+
message="una cafeteria",
|
| 98 |
+
state=ConversationState(
|
| 99 |
+
hard_filters={
|
| 100 |
+
"city": "Puebla",
|
| 101 |
+
"place_ids": ("stale-nearby-id",),
|
| 102 |
+
},
|
| 103 |
+
),
|
| 104 |
+
has_user_location=True,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
assert intent.action == "recommendations"
|
| 108 |
+
assert intent.hard_filters == {"city": "Puebla"}
|
| 109 |
+
assert intent.state_patch.hard_filters == {"city": "Puebla"}
|
| 110 |
+
assert intent.state_patch.as_dict()["hard_filters"] == {"city": "Puebla"}
|
| 111 |
+
|
| 112 |
+
|
| 113 |
def test_category_is_not_polluted_by_the_location_anchor() -> None:
|
| 114 |
intent = DeterministicPlaceChatIntentParser().parse(
|
| 115 |
message="Recomiendame alguna cafeteria cerca del Parque Central",
|
tests/test_place_chat_recommendations_use_case.py
CHANGED
|
@@ -107,6 +107,33 @@ def test_open_category_buttons_use_safe_ids_and_preserve_raw_values() -> None:
|
|
| 107 |
]
|
| 108 |
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
@pytest.mark.asyncio
|
| 111 |
async def test_llm_cannot_change_action_or_candidates() -> None:
|
| 112 |
without_llm = await build_use_case(llm_enabled=False).execute(
|
|
@@ -161,23 +188,33 @@ class LowConfidenceIntentParser:
|
|
| 161 |
|
| 162 |
|
| 163 |
class CategoryEvidenceRetriever:
|
| 164 |
-
def __init__(self) -> None:
|
| 165 |
self.calls = 0
|
|
|
|
|
|
|
| 166 |
|
| 167 |
async def retrieve(self, intent, limit):
|
| 168 |
-
del
|
| 169 |
self.calls += 1
|
|
|
|
| 170 |
return [
|
| 171 |
PlaceChatCandidate(
|
| 172 |
-
place_id="
|
| 173 |
-
name="
|
| 174 |
-
category=
|
| 175 |
-
content_score=0.62,
|
| 176 |
-
semantic_score=0.64,
|
| 177 |
lexical_score=0.20,
|
| 178 |
match_level="broad",
|
| 179 |
matched_reasons=("algo dulce",),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
)
|
|
|
|
| 181 |
]
|
| 182 |
|
| 183 |
|
|
@@ -201,6 +238,7 @@ async def test_low_confidence_uses_dynamic_category_hypotheses() -> None:
|
|
| 201 |
result = await build_use_case(
|
| 202 |
llm_enabled=False,
|
| 203 |
intent_parser=parser,
|
|
|
|
| 204 |
).execute(
|
| 205 |
message="quiero algo dulce y tranquilo",
|
| 206 |
state=ConversationState(),
|
|
@@ -228,6 +266,56 @@ async def test_low_confidence_uses_dynamic_category_hypotheses() -> None:
|
|
| 228 |
] == ["bakery", "ice_cream"]
|
| 229 |
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
@pytest.mark.asyncio
|
| 232 |
async def test_low_confidence_without_top_k_uses_retrieval_evidence_not_fixed_menu() -> None:
|
| 233 |
retriever = CategoryEvidenceRetriever()
|
|
@@ -324,6 +412,186 @@ async def test_weak_candidates_are_returned_as_reviewable_not_confident() -> Non
|
|
| 324 |
assert "evidencia" in result.message.casefold()
|
| 325 |
|
| 326 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
class RecordingNearbyProvider:
|
| 328 |
def __init__(self) -> None:
|
| 329 |
self.call = None
|
|
@@ -382,6 +650,51 @@ async def test_current_location_ids_are_applied_before_content_retrieval() -> No
|
|
| 382 |
assert retriever.intent.hard_filters["place_ids"] == ("near_1", "near_2")
|
| 383 |
|
| 384 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
class CoordinateAnchorResolver:
|
| 386 |
async def resolve(self, text, city, state, limit=3):
|
| 387 |
del text, city, state, limit
|
|
|
|
| 107 |
]
|
| 108 |
|
| 109 |
|
| 110 |
+
def test_category_buttons_humanize_labels_without_changing_ids() -> None:
|
| 111 |
+
from app.modules.places.domain.clarifications import new_category_clarification
|
| 112 |
+
|
| 113 |
+
pending = new_category_clarification(
|
| 114 |
+
("religious_organization", "ropa_barata"),
|
| 115 |
+
kind="intent_category",
|
| 116 |
+
)
|
| 117 |
+
clarification = to_public_clarification(pending)
|
| 118 |
+
|
| 119 |
+
assert [option.option_id for option in pending.options] == [
|
| 120 |
+
"religious_organization",
|
| 121 |
+
"ropa_barata",
|
| 122 |
+
]
|
| 123 |
+
assert [option.value for option in pending.options] == [
|
| 124 |
+
"religious_organization",
|
| 125 |
+
"ropa_barata",
|
| 126 |
+
]
|
| 127 |
+
assert [option.label for option in clarification.options] == [
|
| 128 |
+
"Religious organization",
|
| 129 |
+
"Ropa barata",
|
| 130 |
+
]
|
| 131 |
+
assert [option.option_id for option in clarification.options] == [
|
| 132 |
+
"religious_organization",
|
| 133 |
+
"ropa_barata",
|
| 134 |
+
]
|
| 135 |
+
|
| 136 |
+
|
| 137 |
@pytest.mark.asyncio
|
| 138 |
async def test_llm_cannot_change_action_or_candidates() -> None:
|
| 139 |
without_llm = await build_use_case(llm_enabled=False).execute(
|
|
|
|
| 188 |
|
| 189 |
|
| 190 |
class CategoryEvidenceRetriever:
|
| 191 |
+
def __init__(self, categories: tuple[str, ...] = ("bakery",)) -> None:
|
| 192 |
self.calls = 0
|
| 193 |
+
self.intents = []
|
| 194 |
+
self._categories = categories
|
| 195 |
|
| 196 |
async def retrieve(self, intent, limit):
|
| 197 |
+
del limit
|
| 198 |
self.calls += 1
|
| 199 |
+
self.intents.append(intent)
|
| 200 |
return [
|
| 201 |
PlaceChatCandidate(
|
| 202 |
+
place_id=f"{category}_1",
|
| 203 |
+
name=f"Opcion local {category}",
|
| 204 |
+
category=category,
|
| 205 |
+
content_score=0.62 - (index * 0.01),
|
| 206 |
+
semantic_score=0.64 - (index * 0.01),
|
| 207 |
lexical_score=0.20,
|
| 208 |
match_level="broad",
|
| 209 |
matched_reasons=("algo dulce",),
|
| 210 |
+
metadata={
|
| 211 |
+
"retrieval_diagnostics": {
|
| 212 |
+
"category_match": "exact",
|
| 213 |
+
"meets_minimum_content_score": True,
|
| 214 |
+
}
|
| 215 |
+
},
|
| 216 |
)
|
| 217 |
+
for index, category in enumerate(self._categories)
|
| 218 |
]
|
| 219 |
|
| 220 |
|
|
|
|
| 238 |
result = await build_use_case(
|
| 239 |
llm_enabled=False,
|
| 240 |
intent_parser=parser,
|
| 241 |
+
retriever=CategoryEvidenceRetriever(("bakery", "ice_cream")),
|
| 242 |
).execute(
|
| 243 |
message="quiero algo dulce y tranquilo",
|
| 244 |
state=ConversationState(),
|
|
|
|
| 266 |
] == ["bakery", "ice_cream"]
|
| 267 |
|
| 268 |
|
| 269 |
+
@pytest.mark.asyncio
|
| 270 |
+
async def test_low_confidence_hypotheses_without_local_evidence_are_not_buttons() -> None:
|
| 271 |
+
parser = LowConfidenceIntentParser(
|
| 272 |
+
alternatives=(
|
| 273 |
+
IntentAlternative(
|
| 274 |
+
key="bakery",
|
| 275 |
+
description="Panaderias artesanales",
|
| 276 |
+
confidence=0.68,
|
| 277 |
+
category_values=("bakery",),
|
| 278 |
+
),
|
| 279 |
+
IntentAlternative(
|
| 280 |
+
key="ice_cream",
|
| 281 |
+
description="Postres y heladerias",
|
| 282 |
+
confidence=0.64,
|
| 283 |
+
category_values=("ice_cream",),
|
| 284 |
+
),
|
| 285 |
+
IntentAlternative(
|
| 286 |
+
key="office",
|
| 287 |
+
description="Oficinas",
|
| 288 |
+
confidence=0.63,
|
| 289 |
+
category_values=("office",),
|
| 290 |
+
),
|
| 291 |
+
)
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
result = await build_use_case(
|
| 295 |
+
llm_enabled=False,
|
| 296 |
+
intent_parser=parser,
|
| 297 |
+
retriever=CategoryEvidenceRetriever(("bakery", "ice_cream")),
|
| 298 |
+
).execute(
|
| 299 |
+
message="quiero algo dulce y tranquilo",
|
| 300 |
+
state=ConversationState(),
|
| 301 |
+
user_latitude=16.7531,
|
| 302 |
+
user_longitude=-93.1156,
|
| 303 |
+
candidate_limit=5,
|
| 304 |
+
result_limit=3,
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
assert result.action == "clarification"
|
| 308 |
+
assert result.clarification is not None
|
| 309 |
+
assert [option.option_id for option in result.clarification.options] == [
|
| 310 |
+
"bakery",
|
| 311 |
+
"ice_cream",
|
| 312 |
+
]
|
| 313 |
+
assert "office" not in {
|
| 314 |
+
option["id"]
|
| 315 |
+
for option in result.state_patch["pending_clarification"]["options"]
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
|
| 319 |
@pytest.mark.asyncio
|
| 320 |
async def test_low_confidence_without_top_k_uses_retrieval_evidence_not_fixed_menu() -> None:
|
| 321 |
retriever = CategoryEvidenceRetriever()
|
|
|
|
| 412 |
assert "evidencia" in result.message.casefold()
|
| 413 |
|
| 414 |
|
| 415 |
+
class SequencedNearbyProvider:
|
| 416 |
+
def __init__(self, responses: tuple[set[str], ...]) -> None:
|
| 417 |
+
self.calls = []
|
| 418 |
+
self._responses = responses
|
| 419 |
+
|
| 420 |
+
async def get_nearby_place_ids(self, latitude, longitude, radius_meters):
|
| 421 |
+
self.calls.append((latitude, longitude, radius_meters))
|
| 422 |
+
index = len(self.calls) - 1
|
| 423 |
+
if index >= len(self._responses):
|
| 424 |
+
raise AssertionError("nearby provider received an unexpected call")
|
| 425 |
+
return self._responses[index]
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
class ForbiddenRetriever:
|
| 429 |
+
def __init__(self) -> None:
|
| 430 |
+
self.calls = 0
|
| 431 |
+
|
| 432 |
+
async def retrieve(self, intent, limit):
|
| 433 |
+
del intent, limit
|
| 434 |
+
self.calls += 1
|
| 435 |
+
raise AssertionError("retriever must not be called")
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
class ForbiddenNearbyProvider:
|
| 439 |
+
def __init__(self) -> None:
|
| 440 |
+
self.calls = 0
|
| 441 |
+
|
| 442 |
+
async def get_nearby_place_ids(self, latitude, longitude, radius_meters):
|
| 443 |
+
del latitude, longitude, radius_meters
|
| 444 |
+
self.calls += 1
|
| 445 |
+
raise AssertionError("nearby provider must not be called")
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
class UnsupportedCategoryRetriever:
|
| 449 |
+
def __init__(self) -> None:
|
| 450 |
+
self.intents = []
|
| 451 |
+
|
| 452 |
+
async def retrieve(self, intent, limit):
|
| 453 |
+
del limit
|
| 454 |
+
self.intents.append(intent)
|
| 455 |
+
return [
|
| 456 |
+
PlaceChatCandidate(
|
| 457 |
+
place_id=f"park_{len(self.intents)}",
|
| 458 |
+
name="Parque disponible",
|
| 459 |
+
category="park",
|
| 460 |
+
content_score=0.82,
|
| 461 |
+
semantic_score=0.79,
|
| 462 |
+
lexical_score=0.20,
|
| 463 |
+
match_level="broad",
|
| 464 |
+
matched_reasons=("aire libre",),
|
| 465 |
+
metadata={
|
| 466 |
+
"retrieval_diagnostics": {
|
| 467 |
+
"category_match": "none",
|
| 468 |
+
"meets_minimum_content_score": True,
|
| 469 |
+
}
|
| 470 |
+
},
|
| 471 |
+
)
|
| 472 |
+
]
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
@pytest.mark.asyncio
|
| 476 |
+
async def test_greeting_does_not_call_retriever_or_nearby_provider() -> None:
|
| 477 |
+
retriever = ForbiddenRetriever()
|
| 478 |
+
nearby = ForbiddenNearbyProvider()
|
| 479 |
+
|
| 480 |
+
result = await build_use_case(
|
| 481 |
+
llm_enabled=False,
|
| 482 |
+
retriever=retriever,
|
| 483 |
+
nearby_place_provider=nearby,
|
| 484 |
+
).execute(
|
| 485 |
+
message="ola",
|
| 486 |
+
state=ConversationState(),
|
| 487 |
+
user_latitude=16.7531,
|
| 488 |
+
user_longitude=-93.1156,
|
| 489 |
+
candidate_limit=5,
|
| 490 |
+
result_limit=3,
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
assert result.action == "no_match"
|
| 494 |
+
assert result.candidates == ()
|
| 495 |
+
assert result.unresolved == ("non_search_input",)
|
| 496 |
+
assert "hola" in result.message.casefold()
|
| 497 |
+
assert retriever.calls == 0
|
| 498 |
+
assert nearby.calls == 0
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
@pytest.mark.asyncio
|
| 502 |
+
async def test_empty_implicit_radius_expands_from_five_to_fifty_kilometers() -> None:
|
| 503 |
+
nearby = SequencedNearbyProvider((set(), set()))
|
| 504 |
+
retriever = ForbiddenRetriever()
|
| 505 |
+
|
| 506 |
+
result = await build_use_case(
|
| 507 |
+
llm_enabled=False,
|
| 508 |
+
retriever=retriever,
|
| 509 |
+
nearby_place_provider=nearby,
|
| 510 |
+
).execute(
|
| 511 |
+
message="una cafeteria",
|
| 512 |
+
state=ConversationState(),
|
| 513 |
+
user_latitude=16.7531,
|
| 514 |
+
user_longitude=-93.1156,
|
| 515 |
+
candidate_limit=5,
|
| 516 |
+
result_limit=3,
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
assert result.action == "no_match"
|
| 520 |
+
assert result.unresolved == ("nearby_catalog_empty",)
|
| 521 |
+
assert nearby.calls == [
|
| 522 |
+
(16.7531, -93.1156, 5_000),
|
| 523 |
+
(16.7531, -93.1156, 50_000),
|
| 524 |
+
]
|
| 525 |
+
assert result.location_directive.radius_meters == 50_000
|
| 526 |
+
assert result.location_directive.strict_radius is False
|
| 527 |
+
assert retriever.calls == 0
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
@pytest.mark.asyncio
|
| 531 |
+
async def test_empty_explicit_radius_does_not_expand() -> None:
|
| 532 |
+
nearby = SequencedNearbyProvider((set(),))
|
| 533 |
+
retriever = ForbiddenRetriever()
|
| 534 |
+
|
| 535 |
+
result = await build_use_case(
|
| 536 |
+
llm_enabled=False,
|
| 537 |
+
retriever=retriever,
|
| 538 |
+
nearby_place_provider=nearby,
|
| 539 |
+
).execute(
|
| 540 |
+
message="una cafeteria a 2 km",
|
| 541 |
+
state=ConversationState(),
|
| 542 |
+
user_latitude=16.7531,
|
| 543 |
+
user_longitude=-93.1156,
|
| 544 |
+
candidate_limit=5,
|
| 545 |
+
result_limit=3,
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
assert result.action == "no_match"
|
| 549 |
+
assert result.unresolved == ("nearby_catalog_empty",)
|
| 550 |
+
assert nearby.calls == [(16.7531, -93.1156, 2_000)]
|
| 551 |
+
assert result.location_directive.radius_meters == 2_000
|
| 552 |
+
assert result.location_directive.strict_radius is True
|
| 553 |
+
assert retriever.calls == 0
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
@pytest.mark.asyncio
|
| 557 |
+
async def test_unsupported_category_expands_then_returns_category_availability() -> None:
|
| 558 |
+
nearby = SequencedNearbyProvider(
|
| 559 |
+
(
|
| 560 |
+
{"park_near"},
|
| 561 |
+
{"park_far", "park_near"},
|
| 562 |
+
)
|
| 563 |
+
)
|
| 564 |
+
retriever = UnsupportedCategoryRetriever()
|
| 565 |
+
|
| 566 |
+
result = await build_use_case(
|
| 567 |
+
llm_enabled=False,
|
| 568 |
+
retriever=retriever,
|
| 569 |
+
nearby_place_provider=nearby,
|
| 570 |
+
).execute(
|
| 571 |
+
message="una cafeteria",
|
| 572 |
+
state=ConversationState(),
|
| 573 |
+
user_latitude=16.7531,
|
| 574 |
+
user_longitude=-93.1156,
|
| 575 |
+
candidate_limit=5,
|
| 576 |
+
result_limit=3,
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
assert result.action == "no_match"
|
| 580 |
+
assert result.candidates == ()
|
| 581 |
+
assert result.unresolved == ("category_availability",)
|
| 582 |
+
assert nearby.calls == [
|
| 583 |
+
(16.7531, -93.1156, 5_000),
|
| 584 |
+
(16.7531, -93.1156, 50_000),
|
| 585 |
+
]
|
| 586 |
+
assert len(retriever.intents) == 2
|
| 587 |
+
assert retriever.intents[0].hard_filters["place_ids"] == ("park_near",)
|
| 588 |
+
assert retriever.intents[1].hard_filters["place_ids"] == (
|
| 589 |
+
"park_far",
|
| 590 |
+
"park_near",
|
| 591 |
+
)
|
| 592 |
+
assert result.location_directive.radius_meters == 50_000
|
| 593 |
+
|
| 594 |
+
|
| 595 |
class RecordingNearbyProvider:
|
| 596 |
def __init__(self) -> None:
|
| 597 |
self.call = None
|
|
|
|
| 650 |
assert retriever.intent.hard_filters["place_ids"] == ("near_1", "near_2")
|
| 651 |
|
| 652 |
|
| 653 |
+
@pytest.mark.asyncio
|
| 654 |
+
async def test_transient_geographic_place_ids_are_not_persisted_in_clarification() -> None:
|
| 655 |
+
nearby = SequencedNearbyProvider(({"bakery_1", "ice_cream_1"},))
|
| 656 |
+
retriever = CategoryEvidenceRetriever(("bakery", "ice_cream"))
|
| 657 |
+
parser = LowConfidenceIntentParser(
|
| 658 |
+
alternatives=(
|
| 659 |
+
IntentAlternative(
|
| 660 |
+
key="bakery",
|
| 661 |
+
description="Panaderias artesanales",
|
| 662 |
+
confidence=0.68,
|
| 663 |
+
category_values=("bakery",),
|
| 664 |
+
),
|
| 665 |
+
IntentAlternative(
|
| 666 |
+
key="ice_cream",
|
| 667 |
+
description="Postres y heladerias",
|
| 668 |
+
confidence=0.64,
|
| 669 |
+
category_values=("ice_cream",),
|
| 670 |
+
),
|
| 671 |
+
)
|
| 672 |
+
)
|
| 673 |
+
|
| 674 |
+
result = await build_use_case(
|
| 675 |
+
llm_enabled=False,
|
| 676 |
+
intent_parser=parser,
|
| 677 |
+
retriever=retriever,
|
| 678 |
+
nearby_place_provider=nearby,
|
| 679 |
+
).execute(
|
| 680 |
+
message="quiero algo dulce y tranquilo",
|
| 681 |
+
state=ConversationState(hard_filters={"city": "Tuxtla"}),
|
| 682 |
+
user_latitude=16.7531,
|
| 683 |
+
user_longitude=-93.1156,
|
| 684 |
+
candidate_limit=5,
|
| 685 |
+
result_limit=3,
|
| 686 |
+
)
|
| 687 |
+
|
| 688 |
+
assert result.action == "clarification"
|
| 689 |
+
assert retriever.intents[0].hard_filters["place_ids"] == (
|
| 690 |
+
"bakery_1",
|
| 691 |
+
"ice_cream_1",
|
| 692 |
+
)
|
| 693 |
+
assert result.state_patch["hard_filters"] == {"city": "Tuxtla"}
|
| 694 |
+
assert "place_ids" not in result.state_patch["hard_filters"]
|
| 695 |
+
assert result.location_directive.radius_meters == 5_000
|
| 696 |
+
|
| 697 |
+
|
| 698 |
class CoordinateAnchorResolver:
|
| 699 |
async def resolve(self, text, city, state, limit=3):
|
| 700 |
del text, city, state, limit
|
tests/test_place_embedding_configuration.py
CHANGED
|
@@ -214,6 +214,29 @@ def test_place_chat_intent_provider_defaults_to_deterministic() -> None:
|
|
| 214 |
assert settings.places_chat_bert_model_path is None
|
| 215 |
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
def test_place_chat_bert_provider_is_explicit_and_validated() -> None:
|
| 218 |
settings = Settings(
|
| 219 |
_env_file=None,
|
|
|
|
| 214 |
assert settings.places_chat_bert_model_path is None
|
| 215 |
|
| 216 |
|
| 217 |
+
def test_place_chat_auto_radius_is_configurable_and_bounded() -> None:
|
| 218 |
+
settings = Settings(
|
| 219 |
+
_env_file=None,
|
| 220 |
+
ENV="local",
|
| 221 |
+
PLACES_CHAT_DEFAULT_RADIUS_METERS=4_000,
|
| 222 |
+
PLACES_CHAT_MAX_AUTO_RADIUS_METERS=20_000,
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
assert settings.places_chat_default_radius_meters == 4_000
|
| 226 |
+
assert settings.places_chat_max_auto_radius_meters == 20_000
|
| 227 |
+
|
| 228 |
+
with pytest.raises(
|
| 229 |
+
ValidationError,
|
| 230 |
+
match="PLACES_CHAT_MAX_AUTO_RADIUS_METERS",
|
| 231 |
+
):
|
| 232 |
+
Settings(
|
| 233 |
+
_env_file=None,
|
| 234 |
+
ENV="local",
|
| 235 |
+
PLACES_CHAT_DEFAULT_RADIUS_METERS=10_000,
|
| 236 |
+
PLACES_CHAT_MAX_AUTO_RADIUS_METERS=5_000,
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
def test_place_chat_bert_provider_is_explicit_and_validated() -> None:
|
| 241 |
settings = Settings(
|
| 242 |
_env_file=None,
|