AlleksDev commited on
Commit
2b809ce
·
unverified ·
1 Parent(s): 2e096a8

Fix: Unprecission

Browse files
.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="No encontre lugares cercanos dentro del radio solicitado.",
 
 
 
 
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(intent)
 
 
 
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(clarified, trace_id)
 
 
 
 
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 encontre opciones suficientemente relacionadas con lo que buscas. "
285
- "Puedes cambiar alguna preferencia o pedirme otro tipo de lugar."
 
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(scores.items(), key=lambda item: (-item[1], item[0]))
 
 
 
 
 
 
 
 
 
 
 
 
 
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"{category.replace('_', ' ').title()} "
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
- patch.hard_filters
833
- if patch.hard_filters is not None
834
- else (dict(intent.hard_filters) if intent.hard_filters else None)
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
- option_labels.get(
36
  category,
37
- category.replace("_", " ").title(),
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 = dict(state.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 = dict(state.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
- rank = getattr(self._activity_classifier, "rank", None)
1196
- if not callable(rank):
 
 
 
 
1197
  return ()
1198
- matches = rank(normalized, limit=5)
1199
  return tuple(
1200
  IntentAlternative(
1201
  key=str(match.concept_id),
1202
- description=str(match.label),
 
 
 
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=dict(state.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
- is_active = _first_present(place, "is_active", "isActive", default=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": bool(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": bool(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=bool(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 intent, limit
169
  self.calls += 1
 
170
  return [
171
  PlaceChatCandidate(
172
- place_id="bakery_1",
173
- name="Panaderia Local",
174
- category="bakery",
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,