diff --git a/.env.example b/.env.example index 4ade6f35266a6393d831a5eefd6a0881eadb2276..264f7e45abea84c4561711f9e24ad6fd25d45c7c 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,37 @@ FASTTEXT_MODEL_REPO_ID=facebook/fasttext-es-vectors FASTTEXT_MODEL_FILENAME=model.bin FASTTEXT_AUTO_DOWNLOAD=true +# Embeddings exclusivos de Places. Mantener FastText hasta aplicar/backfillear +# sql/migrations/20260716_02_places_semantic_v1.sql. +PLACES_EMBEDDING_PROVIDER=fasttext +PLACES_EMBEDDING_DIMENSION=300 +PLACES_EMBEDDING_MODEL=facebook/fasttext-es-vectors +PLACES_EMBEDDING_VERSION=common-crawl-300-v1 +PLACES_EMBEDDING_QUERY_PREFIX= +PLACES_EMBEDDING_PASSAGE_PREFIX= +PLACES_EMBEDDING_BATCH_SIZE=32 +PLACES_EMBEDDING_DEVICE= +PLACES_CATEGORY_CATALOG_PATH= +PLACES_CATEGORY_MIN_SIMILARITY=0.44 +PLACES_CATEGORY_MIN_MARGIN=0.04 +PLACES_PGVECTOR_MATCH_FUNCTION=match_places +PLACES_PGVECTOR_HYBRID_FUNCTION= +PLACES_PGVECTOR_UPSERT_FUNCTION=upsert_place_embedding +PLACES_PGVECTOR_HASH_FUNCTION=get_place_content_hashes + +# Perfil BERT recomendado despues del backfill: +# PLACES_EMBEDDING_PROVIDER=sentence_transformer +# PLACES_EMBEDDING_DIMENSION=768 +# PLACES_EMBEDDING_MODEL=intfloat/multilingual-e5-base +# PLACES_EMBEDDING_VERSION=places-e5-domain-v1 +# Quoting preserves the significant trailing space used by E5. +# PLACES_EMBEDDING_QUERY_PREFIX="query: " +# PLACES_EMBEDDING_PASSAGE_PREFIX="passage: " +# PLACES_PGVECTOR_MATCH_FUNCTION=match_places_semantic_v1 +# PLACES_PGVECTOR_HYBRID_FUNCTION=search_places_semantic_v1 +# PLACES_PGVECTOR_UPSERT_FUNCTION=upsert_place_embedding_semantic_v1 +# PLACES_PGVECTOR_HASH_FUNCTION=get_place_content_hashes_semantic_v1 + BM25_K1=1.5 BM25_B=0.75 BM25_RELEVANCE_THRESHOLD=3.0 @@ -70,7 +101,17 @@ PLACES_CHAT_CANDIDATE_LIMIT=30 PLACES_CHAT_MIN_CONTENT_SCORE=0.20 PLACES_CHAT_INTENT_MIN_CONFIDENCE=0.70 PLACES_CHAT_AMBIGUITY_DELTA=0.15 +PLACES_CHAT_HYPOTHESIS_MIN_CONFIDENCE=0.60 +PLACES_CHAT_HYPOTHESIS_MAX_GAP=0.15 +PLACES_CHAT_DEFAULT_RADIUS_METERS=5000 PLACES_CHAT_RANKING_VERSION=places-chat-v2 +PLACES_CHAT_INTENT_PROVIDER=deterministic +# Required only when PLACES_CHAT_INTENT_PROVIDER=bert. The model must expose +# IOB token labels for CATEGORY/PREFERENCE/EXCLUSION/LOCATION/REFERENCE/RADIUS. +PLACES_CHAT_BERT_MODEL_PATH= +PLACES_CHAT_BERT_MODEL_VERSION= +PLACES_CHAT_BERT_DEVICE=cpu +PLACES_CHAT_BERT_MIN_TOKEN_CONFIDENCE=0.60 PLACES_CHAT_TAXONOMY_VERSION=places-taxonomy-v1 LOG_LEVEL=INFO diff --git a/Dockerfile b/Dockerfile index 829f78cf4220700ab9b106642d873e917d17ee4c..45a3bbd3eb80fa8e8f365acece969a579619044d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 ENV PORT=7860 ENV FASTTEXT_MODEL_PATH=/opt/models/fasttext-es/model.bin +ARG DOWNLOAD_FASTTEXT_MODEL=true WORKDIR /app @@ -15,13 +16,15 @@ COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip \ && pip install --no-cache-dir -r requirements.txt -# Keep the 300-dimensional Spanish FastText model in a cached image layer. -# Normal source-code changes do not download the multi-GB model again. +# Keep the rollback FastText model in a cached layer unless a BERT-only image +# is requested with --build-arg DOWNLOAD_FASTTEXT_MODEL=false. COPY app/shared/nlp/embeddings/download_fasttext_model.py /tmp/download_fasttext_model.py -RUN HF_HOME=/tmp/hf-cache python /tmp/download_fasttext_model.py \ - --repo-id facebook/fasttext-es-vectors \ - --filename model.bin \ - --destination ${FASTTEXT_MODEL_PATH} \ +RUN if [ "${DOWNLOAD_FASTTEXT_MODEL}" = "true" ]; then \ + HF_HOME=/tmp/hf-cache python /tmp/download_fasttext_model.py \ + --repo-id facebook/fasttext-es-vectors \ + --filename model.bin \ + --destination ${FASTTEXT_MODEL_PATH}; \ + fi \ && rm -rf /tmp/hf-cache /tmp/download_fasttext_model.py COPY app ./app diff --git a/README.md b/README.md index 91e88b7595feee527d4fb568e813f0cf60ee28eb..b95835ba019ad1c019814ba3febfe654c952b9f5 100644 --- a/README.md +++ b/README.md @@ -197,25 +197,28 @@ y ubicacion actual a la API principal; Go llama `POST /internal/places/chat`, hi IDs devueltos, calcula distancias con PostGIS y aplica el orden geografico final. NLP separa categoria, preferencias, exclusiones, referencia y alcance geografico antes -de buscar. La categoria y `is_active=true` son filtros inviolables. Por eso una consulta -de cafeterias no puede devolver parques aunque el texto incluya "cerca del parque". +de buscar. `is_active=true`, ciudad/estado confirmados y los IDs obtenidos por un filtro +geografico explicito son restricciones duras; la categoria es una hipotesis de ranking. +Esto permite recuperar vocabulario nuevo o categorias distintas entre sistemas sin +confundir una referencia como "cerca del parque" con el tipo de resultado solicitado. Las ambiguedades que cambiarian los resultados devuelven `action=clarification` y un `state_patch` con `pending_clarification`; el siguiente turno puede resolverlo con frases como "la primera opcion" o "la segunda, cerca de mi". -El chat no exige que el usuario nombre siempre una categoria. Primero aplica defaults -deterministas y, para expresiones no literales, compara el mensaje con prototipos de -actividad mediante el mismo FastText local usado por retrieval. Por ejemplo, "quiero -comer algo" busca restaurantes, "quiero hacer ejercicio" busca opciones deportivas y -"donde dormir" busca hospedaje. El clasificador se abstiene si la similitud es baja o -dos categorias quedan demasiado cerca; no consume Llama ni otra API. Una categoria -explicita siempre prevalece. Una intencion nueva y clara tambien cancela cualquier -aclaracion pendiente anterior. Solo se pregunta cuando las interpretaciones plausibles -cambiarian materialmente los resultados; las preferencias faltantes no bloquean una -primera recomendacion. - -La recuperacion combina FastText/pgvector, BM25 y coincidencias de facetas. NLP devuelve -solo candidatos tecnicos y `content_score`; no incorpora GPS al score. El flag inicial es +El chat no exige que el usuario nombre siempre una categoria. Puede habilitar un BERT +fine-tuneado de token classification para extraer valores abiertos de categoria, +preferencia, exclusion, ubicacion, referencia y radio. Esos textos se alinean despues +contra un catalogo dinamico mediante embeddings; no se convierten con aliases dentro del +adaptador BERT. Las reglas lexicas existentes quedan como fallback de despliegue y no +bloquean retrieval. El clasificador se abstiene si la similitud es baja o dos conceptos +quedan demasiado cerca. Las aclaraciones usan hipotesis con evidencia o facetas de los +candidatos recuperados, no un menu fijo. + +La recuperacion combina dense retrieval (FastText de rollback o SentenceTransformer), +BM25 y coincidencias de facetas. La categoria y las exclusiones aportan señales positivas +o negativas; no eliminan candidatos por una coincidencia textual aislada. NLP devuelve +candidatos tecnicos y `content_score`; el GPS se aplica como filtro explicito de IDs antes +del ranking cuando el proveedor de lugares cercanos esta configurado. El flag inicial es `PLACES_CHAT_V2_ENABLED=false` y debe activarse despues de desplegar en Go tanto el proxy de chat como `/api/v1/internal/places/resolve-anchor`. @@ -346,23 +349,68 @@ Si el score maximo no supera `SEMANTIC_NO_MATCH_THRESHOLD`, envia a Llama el mod `SEMANTIC_RELEVANCE_THRESHOLD` usa `low_confidence`; por encima usa `confident`. Llama solo embellece el tono y recibe exclusivamente los lugares seleccionados. -### Documento Semantico Ponderado De Lugares +### Documento Semantico Estructurado De Lugares Los IDs numericos de tags devueltos por la API principal se resuelven mediante el catalogo versionado en `app/modules/places/infrastructure/place_tag_catalog.json`. -El documento que se envia a FastText contiene exclusivamente señales semanticas y -aplica estos pesos mediante repeticion antes del promedio de embeddings: +El documento de Places contiene cada señal una sola vez y explicita el rol de cada +campo. Esto evita que la repeticion manual distorsione un encoder BERT: ```text -tags x6, category x4, description x3, name x1 +Nombre: ... Tipo registrado: ... Descripcion: ... Etiquetas: ... ``` -Las categorias generales se expanden con terminos de intencion en espanol. Direccion, -ciudad, estado, `source`, precio e IDs desconocidos permanecen fuera del embedding; -siguen disponibles como metadatos o filtros cuando corresponde. La version interna -`weighted-tags-v2` forma parte del hash del documento, por lo que ejecutar nuevamente -`initial_load_place_embeddings` actualiza todas las filas por `UPSERT` sin truncar la -tabla ni cambiar `VECTOR(300)`. +No se expanden categorias mediante diccionarios de sinonimos. Direccion, ciudad, +estado, `source`, precio e IDs desconocidos permanecen fuera del embedding; siguen +disponibles como metadatos o filtros. La version `structured-place-v3` forma parte del +hash y fuerza un re-embedding seguro cuando cambia el documento. + +### Migracion BERT/Sentence-Transformer exclusiva de Places + +La migracion es aditiva y no cambia los vectores de posts, perfiles o feed: + +1. Ejecuta `sql/migrations/20260716_02_places_semantic_v1.sql` con el rol DBA. +2. Configura temporalmente el perfil BERT mostrado en `.env.example`. +3. Ejecuta `python -m app.jobs.sync_place_embeddings` para backfill de la tabla + `place_embeddings_semantic_v1`. +4. Ejecuta `sql/verify_places_semantic_v1.sql` y revisa que el plan use HNSW con + un volumen representativo. +5. Activa `match_places_semantic_v1` y `search_places_semantic_v1` primero en shadow. +6. Conserva `match_places` y la tabla FastText para rollback. + +`/places/chat` ya no requiere una categoria canonica para recuperar candidatos. La +categoria inferida solo aporta afinidad al ranking; la union SQL obtiene pools dense y +lexical independientes. Un cliente puede optar al contrato conversacional estructurado +enviando `conversation_id`, `conversation_state`, `clarification_choice` o +`user_location` mientras `PLACES_CHAT_V2_ENABLED=true`. + +Para fine-tuning, `scripts/train_place_retriever.py` acepta JSONL con `query`, +`positive` y `hard_negatives`. El artefacto resultante se configura mediante +`PLACES_EMBEDDING_MODEL`; no se incluye un modelo ficticio preentrenado en el repo. + +El extractor de intencion se entrena por separado con +`scripts/train_place_intent_bert.py`. Su JSONL contiene `text` y spans abiertos +`{start, end, slot}`; los slots permitidos son `CATEGORY`, `PREFERENCE`, +`EXCLUSION`, `LOCATION`, `REFERENCE` y `RADIUS`. Los valores concretos (por ejemplo +"donas artesanales") nunca se convierten en labels del modelo: + +```powershell +python -m pip install -r requirements-training.txt +python scripts/train_place_intent_bert.py ` + --train-file data/places-intent-train.jsonl ` + --validation-file data/places-intent-validation.jsonl ` + --output-dir .models/places-intent-bert +``` + +Para activarlo, configura `PLACES_CHAT_INTENT_PROVIDER=bert`, +`PLACES_CHAT_BERT_MODEL_PATH=.models/places-intent-bert` y una version inmutable en +`PLACES_CHAT_BERT_MODEL_VERSION`. El parser determinista queda como fallback si el +modelo no puede cargarse; cuando BERT responde, no se vuelven a aplicar aliases de +categoria ni implicaciones manuales sobre sus spans. + +Una imagen que ya no necesite el artefacto de rollback FastText puede construirse con +`docker build --build-arg DOWNLOAD_FASTTEXT_MODEL=false .`. Conserva el valor por +defecto durante el shadow/canary para permitir rollback inmediato. `GET` o `POST /places/search/metrics?k=5` conserva un benchmark offline separado llamado `built_in_places_v3_bm25`. Contiene doce lugares controlados, diez consultas y qrels graduados para calcular honestamente `Precision@k`, `Recall@k`, `MRR`, `MAP` y `nDCG@k`. Estas metricas requieren juicios de relevancia y por eso no se presentan como si midieran una consulta arbitraria de produccion. diff --git a/app/jobs/sync_place_embeddings.py b/app/jobs/sync_place_embeddings.py index d6ed7d0aa183ede12c966a9f1613585f3476171c..91918ce560d2dde0f0eae12e0f4f4921167dec63 100644 --- a/app/jobs/sync_place_embeddings.py +++ b/app/jobs/sync_place_embeddings.py @@ -9,7 +9,7 @@ from app.modules.places.infrastructure.main_api_place_source import ( from app.shared.config.settings import Settings, get_settings from app.shared.logging.config import configure_logging, get_logger from app.shared.nlp.embeddings.base import EmbeddingProvider -from app.shared.nlp.embeddings.factory import create_embedding_provider +from app.shared.nlp.embeddings.factory import create_place_embedding_provider from app.shared.nlp.embeddings.versioning import versioned_embedding_hash from app.shared.vector_store.aws_pgvector import AwsPgvectorClient from app.shared.vector_store.models import VectorUpsertRecord @@ -35,12 +35,17 @@ async def main() -> None: source = MainApiPlacesClient(settings) vector_client = AwsPgvectorClient(settings, role="writer") - embedding_provider = create_embedding_provider(settings) + embedding_provider = create_place_embedding_provider(settings, text_role="passage") counters = SyncCounters() batch: list[PlaceSourceRecord] = [] logger.info("Starting place embedding sync") - logger.info("Embedding model=%s version=%s", settings.embedding_model, settings.embedding_version) + logger.info( + "Places embedding model=%s version=%s dimension=%s", + settings.places_embedding_model, + settings.places_embedding_version, + settings.places_embedding_dimension, + ) async for place in source.iter_places( page_limit=args.page_limit, @@ -93,14 +98,15 @@ async def _flush_batch( counters.processed += len(batch) try: existing_hashes = await vector_client.fetch_place_content_hashes( - [record.id for record in batch] + [record.id for record in batch], + function_name=settings.places_pgvector_hash_function, ) expected_hashes = { record.id: versioned_embedding_hash( source_content_hash=record.content_hash, - model=settings.embedding_model, - version=settings.embedding_version, - dimension=settings.embedding_dimension, + model=settings.places_embedding_model, + version=settings.places_embedding_version, + dimension=settings.places_embedding_dimension, ) for record in batch } @@ -130,7 +136,12 @@ async def _flush_batch( if dry_run: logger.info("Dry run: prepared %s place upserts", len(upserts)) else: - await vector_client.upsert_place_embeddings(upserts) + await vector_client.upsert_place_embeddings( + upserts, + function_name=settings.places_pgvector_upsert_function, + embedding_model=settings.places_embedding_model, + embedding_version=settings.places_embedding_version, + ) counters.upserted += len(upserts) except Exception: counters.errors += len(batch) diff --git a/app/modules/places/api/dependencies.py b/app/modules/places/api/dependencies.py index 8fe5527a8f1d07545775c8cdbb635f97e59b91b9..7a2d98fe1c8e2dd7d841948b929e20ab59a267d3 100644 --- a/app/modules/places/api/dependencies.py +++ b/app/modules/places/api/dependencies.py @@ -13,6 +13,9 @@ from app.modules.places.infrastructure.aws_pgvector_place_repository import ( AwsPgvectorPlaceRepository, ) from app.modules.places.infrastructure.bm25_place_ranker import Bm25PlaceRanker +from app.modules.places.infrastructure.bert_intent_extractor import ( + BertPlaceIntentExtractor, +) from app.modules.places.infrastructure.main_api_nearby_place_provider import ( MainApiNearbyPlaceProvider, ) @@ -33,13 +36,21 @@ from app.modules.places.infrastructure.place_search_benchmark import ( QRELS_SOURCE, get_default_place_search_benchmark, ) -from app.modules.places.infrastructure.semantic_place_ranker import SemanticPlaceRanker -from app.modules.places.infrastructure.semantic_activity_classifier import ( - SemanticPlaceActivityClassifier, +from app.modules.places.infrastructure.open_vocabulary_category_classifier import ( + OpenVocabularyPlaceCategoryClassifier, ) +from app.modules.places.infrastructure.place_category_catalog import ( + load_place_category_concepts, +) +from app.modules.places.infrastructure.place_semantic_document import place_tag_catalog +from app.modules.places.infrastructure.semantic_place_ranker import SemanticPlaceRanker from app.shared.cache.memory import SimpleTTLCache from app.shared.config.settings import get_settings -from app.shared.dependencies import get_embedding_provider, get_llm_provider +from app.shared.dependencies import ( + get_llm_provider, + get_place_embedding_provider, + get_place_passage_embedding_provider, +) from app.shared.nlp.embeddings.mock import MockEmbeddingProvider from app.shared.nlp.llm.output_guard import PlaceChatOutputGuard from app.shared.vector_store.aws_pgvector import AwsPgvectorClient @@ -49,14 +60,21 @@ from app.shared.vector_store.aws_pgvector import AwsPgvectorClient def get_place_repository() -> MockPlaceVectorRepository | AwsPgvectorPlaceRepository: settings = get_settings() if settings.vector_store_provider == "aws_pgvector": - return AwsPgvectorPlaceRepository(vector_client=AwsPgvectorClient(settings, role="reader")) - return MockPlaceVectorRepository(embedding_provider=get_embedding_provider()) + return AwsPgvectorPlaceRepository( + vector_client=AwsPgvectorClient(settings, role="reader"), + match_function=settings.places_pgvector_match_function, + hybrid_function=settings.places_pgvector_hybrid_function, + ) + return MockPlaceVectorRepository(embedding_provider=get_place_embedding_provider()) @lru_cache def get_place_ranker() -> SemanticPlaceRanker: settings = get_settings() - return SemanticPlaceRanker(dimension=settings.embedding_dimension) + return SemanticPlaceRanker( + dimension=settings.places_embedding_dimension, + model_name=settings.places_embedding_model, + ) @lru_cache @@ -79,7 +97,7 @@ def get_nearby_place_provider() -> MainApiNearbyPlaceProvider: @lru_cache def get_search_places_use_case() -> SearchPlacesUseCase: return SearchPlacesUseCase( - embedding_provider=get_embedding_provider(), + embedding_provider=get_place_embedding_provider(), place_repository=get_place_repository(), ranker=get_place_ranker(), cache=get_place_search_cache(), @@ -101,7 +119,9 @@ def get_recommend_places_use_case() -> RecommendPlacesUseCase: @lru_cache def get_evaluate_place_search_use_case() -> EvaluatePlaceSearchUseCase: settings = get_settings() - embedding_provider = MockEmbeddingProvider(dimension=settings.embedding_dimension) + embedding_provider = MockEmbeddingProvider( + dimension=settings.places_embedding_dimension + ) benchmark_search = SearchPlacesUseCase( embedding_provider=embedding_provider, place_repository=MockPlaceVectorRepository(embedding_provider), @@ -133,14 +153,42 @@ def get_place_chat_intent_parser() -> DeterministicPlaceChatIntentParser: raise RuntimeError( "PLACES_CHAT_TAXONOMY_VERSION does not match the bundled taxonomy" ) + concepts = load_place_category_concepts( + settings.places_category_catalog_path, + fallback_tags=place_tag_catalog().values(), + ) activity_classifier = ( - SemanticPlaceActivityClassifier(get_embedding_provider()) - if settings.embedding_provider.casefold() != "mock" + OpenVocabularyPlaceCategoryClassifier( + concepts=concepts, + embedding_provider=get_place_embedding_provider(), + concept_embedding_provider=( + get_place_passage_embedding_provider() + if settings.places_embedding_provider + in {"sentence_transformer", "bert"} + else get_place_embedding_provider() + ), + minimum_similarity=settings.places_category_min_similarity, + minimum_margin=settings.places_category_min_margin, + ) + if settings.places_embedding_provider.casefold() != "mock" and concepts + else None + ) + contextual_extractor = ( + BertPlaceIntentExtractor( + settings.places_chat_bert_model_path or "", + model_version=settings.places_chat_bert_model_version, + device=settings.places_chat_bert_device, + minimum_token_confidence=( + settings.places_chat_bert_min_token_confidence + ), + ) + if settings.places_chat_intent_provider == "bert" else None ) return DeterministicPlaceChatIntentParser( taxonomy=taxonomy, activity_classifier=activity_classifier, + contextual_extractor=contextual_extractor, ) @@ -156,7 +204,7 @@ def get_place_anchor_resolver() -> MainApiPlaceAnchorResolver | MockPlaceAnchorR def get_hybrid_place_chat_retriever() -> HybridContentPlaceChatRetriever: settings = get_settings() return HybridContentPlaceChatRetriever( - embedding_provider=get_embedding_provider(), + embedding_provider=get_place_embedding_provider(), place_repository=get_place_repository(), minimum_content_score=settings.places_chat_min_content_score, k1=settings.bm25_k1, @@ -179,4 +227,14 @@ def get_chat_place_recommendations_use_case() -> ChatPlaceRecommendationsUseCase llm_enabled=settings.places_chat_llm_enabled, anchor_ambiguity_delta=settings.places_chat_ambiguity_delta, minimum_intent_confidence=settings.places_chat_intent_min_confidence, + minimum_hypothesis_confidence=( + settings.places_chat_hypothesis_min_confidence + ), + maximum_hypothesis_gap=settings.places_chat_hypothesis_max_gap, + nearby_place_provider=( + get_nearby_place_provider() + if settings.vector_store_provider != "mock" + else None + ), + default_radius_meters=settings.places_chat_default_radius_meters, ) diff --git a/app/modules/places/api/internal_chat_schemas.py b/app/modules/places/api/internal_chat_schemas.py index e8f3fec3955eef63bd12a93cfe16596694105a49..9e03d385406e063709fd2e3c5a555083a363d5ce 100644 --- a/app/modules/places/api/internal_chat_schemas.py +++ b/app/modules/places/api/internal_chat_schemas.py @@ -65,7 +65,7 @@ class PendingClarificationOptionStateSchema(BaseModel): model_config = ConfigDict(extra="forbid") id: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$") - value: str = Field(..., min_length=1, max_length=160) + value: str = Field(..., min_length=1, max_length=500) label: str = Field(..., min_length=1, max_length=160) place_id: str | None = Field(default=None, max_length=100) attributes: list[str] = Field(default_factory=list, max_length=30) @@ -114,7 +114,7 @@ class PendingClarificationStateSchema(BaseModel): class ConversationStateSchema(BaseModel): model_config = ConfigDict(extra="forbid") - target_category: str | None = Field(default=None, max_length=80) + target_category: str | None = Field(default=None, max_length=500) hard_filters: dict[str, Any] = Field(default_factory=dict) soft_preferences: list[str] = Field(default_factory=list, max_length=30) exclusions: list[str] = Field(default_factory=list, max_length=30) @@ -240,6 +240,7 @@ class InternalPlaceChatResponse(BaseModel): ranking_version: str taxonomy_version: str trace_id: str + uncertainty: dict[str, Any] metadata: dict[str, Any] = Field(default_factory=dict) @@ -247,6 +248,16 @@ def internal_chat_result_to_schema( result: ChatPlaceRecommendationsResult, ) -> InternalPlaceChatResponse: directive = result.location_directive + hypotheses = list(result.category_hypotheses) + margin = ( + max( + 0.0, + float(hypotheses[0]["probability"]) + - float(hypotheses[1]["probability"]), + ) + if len(hypotheses) >= 2 + else None + ) return InternalPlaceChatResponse( action=result.action, message=result.message, @@ -292,9 +303,35 @@ def internal_chat_result_to_schema( ranking_version=result.ranking_version, taxonomy_version=result.taxonomy_version, trace_id=result.trace_id, + uncertainty={ + "decision": ( + "review" + if result.action == "recommendations" and result.unresolved + else { + "recommendations": "auto", + "clarification": "clarify", + "no_match": "abstain", + }[result.action] + ), + "reason": ( + result.unresolved[0] + if result.unresolved + else ( + "sufficient_evidence" + if result.action == "recommendations" + else "catalog_exhausted" + ) + ), + "top_probability": round(result.intent_confidence, 6), + "category_hypotheses": hypotheses, + "category_margin": round(margin, 6) if margin is not None else None, + "calibration_version": "uncalibrated-shadow-v1", + }, metadata={ "used_llm": result.used_llm, "guard_reason": result.guard_reason, "category_source": result.category_source, + "raw_category_phrase": result.raw_category_phrase, + "intent_model_version": result.intent_model_version, }, ) diff --git a/app/modules/places/api/router.py b/app/modules/places/api/router.py index decc25d5a538e85afc950ebfaff2c8be056ef563..d733ba73bcec21575843eeb6384c4120b852dfb4 100644 --- a/app/modules/places/api/router.py +++ b/app/modules/places/api/router.py @@ -1,16 +1,24 @@ -from fastapi import APIRouter, Depends, Query +import asyncio +from dataclasses import replace + +from fastapi import APIRouter, Depends, HTTPException, Query from app.modules.places.api.dependencies import ( get_chat_places_use_case, + get_chat_place_recommendations_use_case, get_evaluate_place_search_use_case, get_recommend_places_use_case, get_search_places_use_case, ) +from app.modules.places.api.internal_chat_schemas import ( + internal_chat_result_to_schema, +) from app.modules.places.api.schemas import ( PlaceChatRequest, PlaceChatResponse, PlaceRecommendationRequest, PlaceRecommendationResponse, + PlaceResultSchema, PlaceSearchMetricsResponse, PlaceSearchRequest, PlaceSearchResponse, @@ -19,12 +27,19 @@ from app.modules.places.api.schemas import ( search_metrics_result_to_schema, ) from app.modules.places.application.use_cases.chat_places import ChatPlacesUseCase +from app.modules.places.application.use_cases.chat_place_recommendations import ( + ChatPlaceRecommendationsUseCase, +) +from app.modules.places.domain.chat_intent import ConversationState +from app.modules.places.domain.errors import ClarificationStateMismatchError from app.modules.places.application.use_cases.evaluate_place_search import ( EvaluatePlaceSearchUseCase, ) from app.modules.places.application.use_cases.recommend_places import RecommendPlacesUseCase from app.modules.places.application.use_cases.search_places import SearchPlacesUseCase from app.shared.security.rate_limit import rate_limit_placeholder +from app.shared.config.settings import get_settings +from app.shared.tracing import new_response_id router = APIRouter( prefix="/places", @@ -85,12 +100,163 @@ async def recommend_places( ) -@router.post("/chat", response_model=PlaceChatResponse) +@router.post( + "/chat", + response_model=PlaceChatResponse, + response_model_exclude_none=True, +) async def chat_places( payload: PlaceChatRequest, - use_case: ChatPlacesUseCase = Depends(get_chat_places_use_case), + legacy_use_case: ChatPlacesUseCase = Depends(get_chat_places_use_case), + semantic_use_case: ChatPlaceRecommendationsUseCase = Depends( + get_chat_place_recommendations_use_case + ), ) -> PlaceChatResponse: - result = await use_case.execute( + settings = get_settings() + use_semantic_chat = settings.places_chat_v2_enabled and any( + ( + payload.conversation_id is not None, + payload.conversation_state is not None, + payload.clarification_choice is not None, + payload.user_location is not None, + ) + ) + if use_semantic_chat: + state = ( + payload.conversation_state.to_domain() + if payload.conversation_state + else ConversationState() + ) + if ( + state.taxonomy_version is not None + and state.taxonomy_version != settings.places_chat_taxonomy_version + ): + raise HTTPException( + status_code=409, + detail="Conversation taxonomy version is incompatible", + ) + request_filters = payload.to_domain_filters() + hard_filters = dict(state.hard_filters) + for key, value in { + "city": request_filters.city, + "state": request_filters.state, + "price_range": request_filters.price_range, + "occasion": request_filters.occasion, + }.items(): + if value is not None: + hard_filters[key] = value + state = replace( + state, + target_category=state.target_category or request_filters.category, + hard_filters=hard_filters, + city=state.city or request_filters.city, + state=state.state or request_filters.state, + ) + location = payload.user_location + candidate_limit = ( + payload.candidate_limit or settings.places_chat_candidate_limit + ) + if candidate_limit > settings.places_chat_candidate_limit: + raise HTTPException( + status_code=422, + detail=( + "candidate_limit exceeds the configured service maximum of " + f"{settings.places_chat_candidate_limit}" + ), + ) + try: + async with asyncio.timeout(settings.request_timeout_seconds): + semantic_result = await semantic_use_case.execute( + message=payload.message, + state=state, + user_latitude=location.lat if location else None, + user_longitude=location.lng if location else None, + candidate_limit=candidate_limit, + result_limit=payload.limit, + clarification_choice=( + payload.clarification_choice.to_domain() + if payload.clarification_choice + else None + ), + ) + except ClarificationStateMismatchError as exc: + raise HTTPException( + status_code=409, + detail="Clarification choice does not match the current state", + ) from exc + except TimeoutError as exc: + raise HTTPException( + status_code=503, + detail="Places chat timed out", + ) from exc + structured = internal_chat_result_to_schema(semantic_result) + decision = ( + "review" + if semantic_result.action == "recommendations" + and semantic_result.unresolved + else { + "recommendations": "auto", + "clarification": "clarify", + "no_match": "abstain", + }[semantic_result.action] + ) + reason = ( + semantic_result.unresolved[0] + if semantic_result.unresolved + else ( + "sufficient_evidence" + if semantic_result.action == "recommendations" + else "catalog_exhausted" + ) + ) + return PlaceChatResponse( + response_id=new_response_id(), + nlp_trace_id=semantic_result.trace_id, + action=semantic_result.action, + message=semantic_result.message, + places=[ + PlaceResultSchema( + id=candidate.place_id, + name=candidate.name, + score=round(candidate.content_score, 4), + category=candidate.category, + city=candidate.metadata.get("city"), + state=candidate.metadata.get("state"), + metadata={ + **candidate.metadata, + "semantic_score": candidate.semantic_score, + "lexical_score": candidate.lexical_score, + "match_level": candidate.match_level, + "matched_reasons": list(candidate.matched_reasons), + }, + ) + for candidate in semantic_result.candidates[: payload.limit] + ], + state_patch=semantic_result.state_patch, + location_directive=structured.location_directive, + clarification=structured.clarification, + unresolved=list(semantic_result.unresolved), + intent_confidence=semantic_result.intent_confidence, + ranking_version=semantic_result.ranking_version, + taxonomy_version=semantic_result.taxonomy_version, + uncertainty={ + **structured.uncertainty, + "decision": decision, + "reason": reason, + }, + metadata={ + **structured.metadata, + "pipeline": "places-chat-semantic-v2", + "conversation_id": ( + str(payload.conversation_id) + if payload.conversation_id is not None + else None + ), + "turn": payload.turn, + }, + ) + + result = await legacy_use_case.execute( message=payload.message, filters=payload.to_domain_filters(), limit=payload.limit, diff --git a/app/modules/places/api/schemas.py b/app/modules/places/api/schemas.py index 4214cbf5e6455f998650eed4f4b051c01d0f082d..19fde1d2ce16c67937663bdfbca2855cda761103 100644 --- a/app/modules/places/api/schemas.py +++ b/app/modules/places/api/schemas.py @@ -1,10 +1,18 @@ -from typing import Any +from typing import Any, Literal +from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, model_validator from app.modules.places.application.use_cases.evaluate_place_search import ( EvaluatePlaceSearchResult, ) +from app.modules.places.api.internal_chat_schemas import ( + ClarificationChoiceSchema, + ClarificationSchema, + ConversationStateSchema, + PlaceChatLocationDirectiveSchema, + UserLocationSchema, +) from app.modules.places.domain.models import PlaceCandidate, PlaceFilters from app.modules.places.domain.search_metrics import ( SearchEngineMetrics, @@ -64,6 +72,12 @@ class PlaceChatRequest(BaseModel): state: str | None = Field(default=None, max_length=80) filters: PlaceFiltersSchema = Field(default_factory=PlaceFiltersSchema) limit: int = Field(default=5, ge=1, le=8) + conversation_id: UUID | None = None + turn: int = Field(default=1, ge=1) + conversation_state: ConversationStateSchema | None = None + clarification_choice: ClarificationChoiceSchema | None = None + user_location: UserLocationSchema | None = None + candidate_limit: int | None = Field(default=None, ge=1, le=40) def to_domain_filters(self) -> PlaceFilters: return PlaceFilters( @@ -168,6 +182,15 @@ class PlaceChatResponse(BaseModel): message: str places: list[PlaceResultSchema] metadata: dict[str, Any] + action: Literal["recommendations", "clarification", "no_match"] | None = None + state_patch: dict[str, Any] | None = None + location_directive: PlaceChatLocationDirectiveSchema | None = None + clarification: ClarificationSchema | None = None + unresolved: list[str] | None = None + intent_confidence: float | None = Field(default=None, ge=0, le=1) + ranking_version: str | None = None + taxonomy_version: str | None = None + uncertainty: dict[str, Any] | None = None def place_to_schema(place: PlaceCandidate) -> PlaceResultSchema: diff --git a/app/modules/places/application/use_cases/chat_place_recommendations.py b/app/modules/places/application/use_cases/chat_place_recommendations.py index 6ee6fd1c552399d6a9c632938c273bf7dc709428..68567d638b411f8a3ae3a031370d6866fe59e931 100644 --- a/app/modules/places/application/use_cases/chat_place_recommendations.py +++ b/app/modules/places/application/use_cases/chat_place_recommendations.py @@ -1,3 +1,4 @@ +import asyncio from dataclasses import dataclass, replace import logging import math @@ -7,6 +8,9 @@ from app.modules.places.application.ports.chat_retriever import ( HybridPlaceChatRetriever, ) from app.modules.places.application.ports.intent_parser import PlaceChatIntentParser +from app.modules.places.application.ports.nearby_place_provider import ( + NearbyPlaceProvider, +) from app.modules.places.application.ports.place_anchor_resolver import ( PlaceAnchorResolver, ) @@ -36,6 +40,13 @@ from app.shared.tracing import new_trace_id logger = logging.getLogger(__name__) +_NON_CATEGORY_ALTERNATIVE_KEYS = { + "location_scope", + "reference_entity", + "target_results", + "user_current_location", +} + @dataclass(frozen=True) class ChatPlaceRecommendationsResult: @@ -53,6 +64,9 @@ class ChatPlaceRecommendationsResult: category_source: str = "unresolved" used_llm: bool = False guard_reason: str | None = None + category_hypotheses: tuple[dict[str, Any], ...] = () + raw_category_phrase: str | None = None + intent_model_version: str = "deterministic-open-v2" class ChatPlaceRecommendationsUseCase: @@ -68,7 +82,21 @@ class ChatPlaceRecommendationsUseCase: llm_enabled: bool = True, anchor_ambiguity_delta: float = 0.15, minimum_intent_confidence: float = 0.70, + minimum_hypothesis_confidence: float = 0.60, + maximum_hypothesis_gap: float = 0.15, + nearby_place_provider: NearbyPlaceProvider | None = None, + default_radius_meters: int = 5_000, ) -> None: + if not 0.0 <= minimum_intent_confidence <= 1.0: + raise ValueError("minimum_intent_confidence must be between zero and one") + if not 0.0 <= minimum_hypothesis_confidence <= 1.0: + raise ValueError( + "minimum_hypothesis_confidence must be between zero and one" + ) + if not 0.0 <= maximum_hypothesis_gap <= 1.0: + raise ValueError("maximum_hypothesis_gap must be between zero and one") + if not 1 <= default_radius_meters <= 50_000: + raise ValueError("default_radius_meters must be between 1 and 50000") self._intent_parser = intent_parser self._anchor_resolver = anchor_resolver self._retriever = retriever @@ -79,44 +107,37 @@ class ChatPlaceRecommendationsUseCase: self._llm_enabled = llm_enabled self._anchor_ambiguity_delta = anchor_ambiguity_delta self._minimum_intent_confidence = minimum_intent_confidence + self._minimum_hypothesis_confidence = minimum_hypothesis_confidence + self._maximum_hypothesis_gap = maximum_hypothesis_gap + self._nearby_place_provider = nearby_place_provider + self._default_radius_meters = default_radius_meters async def execute( self, message: str, state: ConversationState, - user_latitude: float, - user_longitude: float, + user_latitude: float | None, + user_longitude: float | None, candidate_limit: int, result_limit: int, clarification_choice: ClarificationChoice | None = None, ) -> ChatPlaceRecommendationsResult: - del user_latitude, user_longitude + if (user_latitude is None) != (user_longitude is None): + raise ValueError("user_latitude and user_longitude must be provided together") + has_user_location = user_latitude is not None trace_id = new_trace_id() - intent = self._intent_parser.parse( - message=message, - state=state, - has_user_location=True, - clarification_choice=clarification_choice, + # Both the open-vocabulary classifier and the optional BERT extractor + # are CPU-bound and load lazily. Keep their first inference off the + # async request loop. + intent = await asyncio.to_thread( + self._intent_parser.parse, + message, + state, + has_user_location, + clarification_choice, ) if intent.action == "clarification": return self._clarification_result(intent, trace_id) - if intent.confidence < self._minimum_intent_confidence: - categories = tuple( - dict.fromkeys( - ( - intent.target_category or "restaurant", - "restaurant", - "cafe", - "park", - ) - ) - ) - clarified = self._with_pending_clarification( - intent, - new_category_clarification(categories, kind="intent_category"), - unresolved=("intent_confidence",), - ) - return self._clarification_result(clarified, trace_id) resolved = await self._resolve_entities(intent, state) if isinstance(resolved, ChatPlaceRecommendationsResult): @@ -127,13 +148,129 @@ class ChatPlaceRecommendationsUseCase: directive = self._location_directive(intent) if directive.source == "unresolved": + region = state.city or state.state + if not region: + return self._result( + action="no_match", + message="No pude identificar una ubicacion util para esta busqueda.", + intent=intent, + directive=directive, + candidates=(), + unresolved=("location",), + trace_id=trace_id, + ) + directive = PlaceChatLocationDirective( + source="state_anchor", + scope="target_results", + anchor_text=region, + ) + + geographic_latitude = ( + user_latitude + if directive.source == "user_current" + else intent.location.latitude + ) + geographic_longitude = ( + user_longitude + if directive.source == "user_current" + else intent.location.longitude + ) + if ( + directive.source in {"user_current", "explicit_anchor", "state_anchor"} + and self._nearby_place_provider is not None + and geographic_latitude is not None + and geographic_longitude is not None + ): + nearby_ids = await self._nearby_place_provider.get_nearby_place_ids( + latitude=geographic_latitude, + longitude=geographic_longitude, + radius_meters=( + directive.radius_meters or self._default_radius_meters + ), + ) + if not nearby_ids: + return self._result( + action="no_match", + message="No encontre lugares cercanos dentro del radio solicitado.", + intent=intent, + directive=directive, + candidates=(), + unresolved=(), + trace_id=trace_id, + ) + intent = replace( + intent, + hard_filters={ + **intent.hard_filters, + "place_ids": tuple(sorted(nearby_ids)), + }, + ) + + if intent.confidence < self._minimum_intent_confidence: + evidence_candidates = tuple( + await self._retriever.retrieve( + intent=intent, + limit=min(candidate_limit, 12), + ) + ) + pending = ( + self._category_clarification_from_hypotheses(intent) + or self._category_clarification_from_candidates(evidence_candidates) + ) + if pending is not None: + clarified = self._with_pending_clarification( + intent, + pending, + unresolved=("intent_confidence",), + ) + return self._clarification_result(clarified, trace_id) + + sufficient_candidates = tuple( + candidate + for candidate in evidence_candidates + if candidate.metadata.get("retrieval_diagnostics", {}).get( + "meets_minimum_content_score" + ) + ) + if evidence_candidates: + review_candidates = ( + sufficient_candidates or evidence_candidates + ) + final_message, used_llm, guard_reason = await self._compose_message( + intent=intent, + candidates=review_candidates[:result_limit], + state=state, + ) + return self._result( + action="recommendations", + message=final_message, + intent=intent, + directive=directive, + candidates=review_candidates, + unresolved=tuple( + dict.fromkeys( + ( + *intent.unresolved, + "intent_confidence", + *( + () + if sufficient_candidates + else ("retrieval_evidence",) + ), + ) + ) + ), + trace_id=trace_id, + used_llm=used_llm, + guard_reason=guard_reason, + ) return self._result( action="no_match", - message="No pude identificar una ubicacion util para esta busqueda.", + message=self._uncertain_intent_message(intent, evidence_candidates), intent=intent, directive=directive, candidates=(), - unresolved=(), + unresolved=("intent_confidence",), trace_id=trace_id, ) @@ -159,13 +296,18 @@ class ChatPlaceRecommendationsUseCase: candidates=candidates[:result_limit], state=state, ) + has_sufficient_evidence = any( + self._meets_content_threshold(candidate) for candidate in candidates + ) return self._result( action="recommendations", message=final_message, intent=intent, directive=directive, candidates=candidates, - unresolved=(), + unresolved=( + () if has_sufficient_evidence else ("retrieval_evidence",) + ), trace_id=trace_id, used_llm=used_llm, guard_reason=guard_reason, @@ -222,7 +364,12 @@ class ChatPlaceRecommendationsUseCase: ) return self._clarification_result(clarified, "") anchor = anchors[0] - resolved_location = replace(location, resolved_place_id=anchor.place_id) + resolved_location = replace( + location, + resolved_place_id=anchor.place_id, + latitude=anchor.latitude, + longitude=anchor.longitude, + ) explicit = ExplicitTargetLocation( anchor_text=location.anchor_text, place_id=anchor.place_id, @@ -238,6 +385,36 @@ class ChatPlaceRecommendationsUseCase: explicit_target_location=explicit, ), ) + elif ( + location.anchor_text + and location.resolved_place_id + and (location.latitude is None or location.longitude is None) + ): + anchors = list( + await self._anchor_resolver.resolve( + text=location.anchor_text, + city=state.city, + state=state.state, + limit=5, + ) + ) + selected = next( + ( + anchor + for anchor in anchors + if anchor.place_id == location.resolved_place_id + ), + None, + ) + if selected is not None: + resolved_intent = replace( + resolved_intent, + location=replace( + location, + latitude=selected.latitude, + longitude=selected.longitude, + ), + ) reference = resolved_intent.reference if reference and reference.entity and not reference.place_id: @@ -415,13 +592,138 @@ class ChatPlaceRecommendationsUseCase: strict_radius=location.strict_radius, ) + def _category_clarification_from_hypotheses( + self, + intent: ParsedPlaceChatIntent, + ) -> PendingClarification | None: + scores: dict[str, float] = {} + labels: dict[str, str] = {} + if intent.target_category: + scores[intent.target_category] = intent.confidence + + for alternative in intent.alternatives: + key = alternative.key.strip() + if not key or key in _NON_CATEGORY_ALTERNATIVE_KEYS: + continue + scores[key] = max(scores.get(key, 0.0), alternative.confidence) + if alternative.description.strip(): + labels[key] = alternative.description.strip() + + ranked = sorted(scores.items(), key=lambda item: (-item[1], item[0])) + if not ranked or ranked[0][1] < self._minimum_hypothesis_confidence: + return None + top_score = ranked[0][1] + ordered = tuple( + category + for category, score in ranked + if score >= self._minimum_hypothesis_confidence + and top_score - score <= self._maximum_hypothesis_gap + )[:5] + if len(ordered) < 2: + return None + return new_category_clarification( + ordered, + kind="intent_category", + labels=labels, + ) + + @staticmethod + def _category_clarification_from_candidates( + candidates: Sequence[PlaceChatCandidate], + ) -> PendingClarification | None: + counts: dict[str, int] = {} + best_scores: dict[str, float] = {} + for candidate in candidates: + diagnostics = candidate.metadata.get("retrieval_diagnostics", {}) + if diagnostics.get("meets_minimum_content_score") is False: + continue + category = (candidate.category or "").strip() + if not category: + continue + counts[category] = counts.get(category, 0) + 1 + best_scores[category] = max( + best_scores.get(category, 0.0), + candidate.content_score, + ) + ordered = tuple( + category + for category, _ in sorted( + best_scores.items(), + key=lambda item: (-item[1], -counts[item[0]], item[0]), + )[:3] + ) + if len(ordered) < 2: + return None + labels = { + category: ( + f"{category.replace('_', ' ').title()} " + f"({counts[category]} opciones encontradas)" + ) + for category in ordered + } + return new_category_clarification( + ordered, + kind="intent_category", + labels=labels, + ) + + @staticmethod + def _uncertain_intent_message( + intent: ParsedPlaceChatIntent, + candidates: Sequence[PlaceChatCandidate], + ) -> str: + categories = tuple( + dict.fromkeys( + candidate.category.replace("_", " ").strip().title() + for candidate in candidates + if candidate.category and candidate.category.strip() + ) + )[:3] + if len(categories) > 1: + evidence = ", ".join(categories[:-1]) + f" y {categories[-1]}" + return ( + f"Encontre senales relacionadas con {evidence}, pero no pude " + "determinar con suficiente confianza cual describe tu plan. " + "Cuentame que actividad quieres hacer." + ) + if categories: + return ( + f"La busqueda apunta a {categories[0]}, pero la intencion sigue " + "siendo ambigua. Cuentame que actividad quieres hacer para afinarla." + ) + if intent.soft_preferences: + preferences = ", ".join( + preference.replace("_", " ") + for preference in intent.soft_preferences[:3] + ) + return ( + f"Entendi que buscas algo {preferences}, pero no pude determinar " + "con suficiente confianza el tipo de lugar. Describe la actividad " + "que tienes en mente." + ) + return ( + "No pude determinar con suficiente confianza el tipo de lugar. " + "Describe la actividad o el plan que tienes en mente." + ) + async def _compose_message( self, intent: ParsedPlaceChatIntent, candidates: Sequence[PlaceChatCandidate], state: ConversationState, ) -> tuple[str, bool, str | None]: - fallback = self._template_message(intent, candidates) + has_sufficient_evidence = any( + self._meets_content_threshold(candidate) for candidate in candidates + ) + response_mode = ( + "low_confidence" + if ( + intent.confidence < self._minimum_intent_confidence + or not has_sufficient_evidence + ) + else "confident" + ) + fallback = self._template_message(intent, candidates, response_mode) if not self._llm_enabled: return fallback, False, "llm_disabled" try: @@ -432,7 +734,7 @@ class ChatPlaceRecommendationsUseCase: ), region=state.city or state.state, places=[self._candidate_context(candidate) for candidate in candidates], - response_mode="confident", + response_mode=response_mode, ) if any( candidate.name.casefold() in result.message.casefold() @@ -443,6 +745,7 @@ class ChatPlaceRecommendationsUseCase: guarded = self._output_guard.validate( message=result.message, allowed_place_names=[candidate.name for candidate in candidates], + response_mode=response_mode, ) if guarded.used_fallback: return fallback, False, guarded.reason @@ -454,7 +757,14 @@ class ChatPlaceRecommendationsUseCase: def _template_message( intent: ParsedPlaceChatIntent, candidates: Sequence[PlaceChatCandidate], + response_mode: str = "confident", ) -> str: + if response_mode == "low_confidence": + return ( + "Encontre opciones semanticamente relacionadas, aunque la evidencia " + "todavia es debil. Revisalas como sugerencias y ajusta tu busqueda " + "si no representan el plan que tienes en mente." + ) exact = sum(candidate.match_level == "exact" for candidate in candidates) family = sum(candidate.match_level == "family" for candidate in candidates) if exact: @@ -464,6 +774,13 @@ class ChatPlaceRecommendationsUseCase: category = intent.target_category or "lugar" return f"Encontre opciones de {category} que pueden encajar con tu solicitud." + @staticmethod + def _meets_content_threshold(candidate: PlaceChatCandidate) -> bool: + diagnostics = candidate.metadata.get("retrieval_diagnostics", {}) + if "meets_minimum_content_score" in diagnostics: + return bool(diagnostics["meets_minimum_content_score"]) + return candidate.content_score > 0.0 + @staticmethod def _candidate_context(candidate: PlaceChatCandidate) -> dict[str, Any]: return { @@ -501,17 +818,34 @@ class ChatPlaceRecommendationsUseCase: unresolved: tuple[str, ...], ) -> ParsedPlaceChatIntent: clarification = to_public_clarification(pending) + patch = intent.state_patch return replace( intent, action="clarification", - semantic_query="", confidence=min(intent.confidence, 0.75), state_patch=replace( - intent.state_patch, + patch, + target_category=( + patch.target_category or intent.target_category + ), + hard_filters=( + patch.hard_filters + if patch.hard_filters is not None + else (dict(intent.hard_filters) if intent.hard_filters else None) + ), + soft_preferences=( + patch.soft_preferences + if patch.soft_preferences is not None + else (intent.soft_preferences or None) + ), + exclusions=( + patch.exclusions + if patch.exclusions is not None + else (intent.exclusions or None) + ), pending_clarification=pending, ), clarification=clarification, - alternatives=(), unresolved=unresolved, clarification_message=clarification.prompt, ) @@ -545,6 +879,17 @@ class ChatPlaceRecommendationsUseCase: category_source=intent.category_source, used_llm=used_llm, guard_reason=guard_reason, + category_hypotheses=tuple( + { + "id": alternative.key, + "label": alternative.description, + "probability": round(alternative.confidence, 6), + } + for alternative in intent.alternatives + if alternative.key not in _NON_CATEGORY_ALTERNATIVE_KEYS + )[:5], + raw_category_phrase=intent.raw_category_phrase, + intent_model_version=intent.intent_model_version, ) if trace_id: self._log_result(result, intent) diff --git a/app/modules/places/application/use_cases/chat_places.py b/app/modules/places/application/use_cases/chat_places.py index d5629bee7040c23a406cd89673a1abe180ae50e2..7cb2c24530dcaba78704c8ddad8f200df295f59b 100644 --- a/app/modules/places/application/use_cases/chat_places.py +++ b/app/modules/places/application/use_cases/chat_places.py @@ -42,7 +42,12 @@ class ChatPlacesUseCase: filters=filters, limit=limit, ) - places = search_result.places + response_mode = search_result.metrics.match_quality + places = ( + [] + if response_mode == "no_match" + else [place for place in search_result.places if place.score > 0] + ) context_places = [place.to_llm_context() for place in places] llm_provider = self._llm_provider.provider_name @@ -55,18 +60,23 @@ class ChatPlacesUseCase: user_intent=search_result.normalized_query, region=filters.city or filters.state, places=context_places, + response_mode=response_mode, ) llm_provider = llm_result.provider llm_model = llm_result.model guarded = self._output_guard.validate( message=llm_result.message, allowed_place_names=[place.name for place in places], + response_mode=response_mode, ) used_llm = not guarded.used_fallback guard_reason = guarded.reason final_message = guarded.message except Exception as exc: - guarded = self._output_guard.fallback(reason=exc.__class__.__name__) + guarded = self._output_guard.fallback( + reason=exc.__class__.__name__, + response_mode=response_mode, + ) final_message = guarded.message guard_reason = guarded.reason @@ -80,6 +90,7 @@ class ChatPlacesUseCase: "llm_model": llm_model, "used_llm": used_llm, "guard_reason": guard_reason, + "response_mode": response_mode, "places_used_as_context": [place.id for place in places], "timestamp": datetime.now(UTC).isoformat(), }, diff --git a/app/modules/places/application/use_cases/search_places.py b/app/modules/places/application/use_cases/search_places.py index 5bf6603de6cc3560bf084f545ed95398ee4c7b9c..7d36b2aff5158d5982971ab8e2d4a0e44ec046e9 100644 --- a/app/modules/places/application/use_cases/search_places.py +++ b/app/modules/places/application/use_cases/search_places.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, replace +import asyncio import json from typing import Sequence @@ -84,7 +85,10 @@ class SearchPlacesUseCase: place_ids=tuple(sorted(nearby_ids)), ) - query_embedding = self._embedding_provider.embed_text(normalized_query) + query_embedding = await asyncio.to_thread( + self._embedding_provider.embed_text, + normalized_query, + ) candidates = await self._place_repository.search( embedding=query_embedding, filters=effective_filters, diff --git a/app/modules/places/domain/chat_intent.py b/app/modules/places/domain/chat_intent.py index 23aff602a835fb545ceb1fd1becb3434e9bf3020..dd7607bb1b8b6b2f42bf42bc664f73c31b7946f2 100644 --- a/app/modules/places/domain/chat_intent.py +++ b/app/modules/places/domain/chat_intent.py @@ -113,6 +113,8 @@ class LocationIntent: resolved_place_id: str | None = None radius_meters: int | None = None strict_radius: bool = False + latitude: float | None = None + longitude: float | None = None @dataclass(frozen=True) @@ -127,6 +129,8 @@ class PlaceCategoryInference: category: str confidence: float source: Literal["lexical_activity", "semantic_activity"] + category_values: tuple[str, ...] = () + label: str | None = None @dataclass(frozen=True) @@ -220,6 +224,8 @@ class ParsedPlaceChatIntent: alternatives: tuple[IntentAlternative, ...] = () unresolved: tuple[str, ...] = () clarification_message: str | None = None + raw_category_phrase: str | None = None + intent_model_version: str = "deterministic-open-v2" @dataclass(frozen=True) diff --git a/app/modules/places/domain/clarifications.py b/app/modules/places/domain/clarifications.py index b01739feaa9b4493225944ce347e48c2ed9a7a41..156983bf72bee788681a0481e5ae252677588b26 100644 --- a/app/modules/places/domain/clarifications.py +++ b/app/modules/places/domain/clarifications.py @@ -1,4 +1,6 @@ -from collections.abc import Sequence +from collections.abc import Mapping, Sequence +import re +import unicodedata from uuid import uuid4 from app.modules.places.domain.chat_intent import ( @@ -11,36 +13,33 @@ from app.modules.places.domain.chat_intent import ( ) -_CATEGORY_LABELS = { - "restaurant": "Restaurantes", - "cafe": "Cafeterias", - "park": "Parques", - "nightlife": "Fiesta y vida nocturna", - "sports": "Ejercicio y deporte", - "cinema": "Cines", - "shopping": "Compras", - "lodging": "Hospedaje", -} - - def new_category_clarification( categories: Sequence[str], *, kind: ClarificationKind = "target_category", + labels: Mapping[str, str] | None = None, ) -> PendingClarification: unique = tuple(dict.fromkeys(category for category in categories if category))[:5] if len(unique) < 2: raise ValueError("a category clarification requires at least two options") + option_labels = labels or {} + option_ids = _category_option_ids(unique) return PendingClarification( clarification_id=str(uuid4()), kind=kind, options=tuple( PendingClarificationOption( - option_id=category, + option_id=option_id, value=category, - label=_CATEGORY_LABELS.get(category, category.replace("_", " ").title()), + label=_bounded_text( + option_labels.get( + category, + category.replace("_", " ").title(), + ), + 160, + ), ) - for category in unique + for category, option_id in zip(unique, option_ids) ), ) @@ -156,3 +155,27 @@ def _bounded_text(value: str, maximum: int) -> str: if len(value) <= maximum: return value return value[: maximum - 1].rstrip() + "…" + + +def _category_option_ids(values: Sequence[str]) -> tuple[str, ...]: + seen: set[str] = set() + option_ids: list[str] = [] + for index, value in enumerate(values, start=1): + option_id = _category_option_id(value, index) + if option_id in seen: + suffix = f"_{index}" + option_id = option_id[: 64 - len(suffix)].rstrip("_-") + suffix + seen.add(option_id) + option_ids.append(option_id) + return tuple(option_ids) + + +def _category_option_id(value: str, index: int) -> str: + if re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", value): + return value + ascii_value = unicodedata.normalize("NFKD", value).encode( + "ascii", "ignore" + ).decode("ascii") + slug = re.sub(r"[^a-zA-Z0-9_-]+", "_", ascii_value).strip("_-").lower() + slug = slug[:54].rstrip("_-") or "category" + return f"{slug}_{index}" diff --git a/app/modules/places/infrastructure/aws_pgvector_place_repository.py b/app/modules/places/infrastructure/aws_pgvector_place_repository.py index 81dbf81ab2972b4fae11bee0cb986b91b939a488..196c76bc3b113c0debbd02943f3f25b66046de42 100644 --- a/app/modules/places/infrastructure/aws_pgvector_place_repository.py +++ b/app/modules/places/infrastructure/aws_pgvector_place_repository.py @@ -9,8 +9,15 @@ from app.shared.vector_store.models import VectorMatch class AwsPgvectorPlaceRepository(PlaceVectorRepository): source_name = "pgvector" - def __init__(self, vector_client: AwsPgvectorClient) -> None: + def __init__( + self, + vector_client: AwsPgvectorClient, + match_function: str = "match_places", + hybrid_function: str | None = None, + ) -> None: self._vector_client = vector_client + self._match_function = match_function + self._hybrid_function = hybrid_function async def search( self, @@ -24,12 +31,39 @@ class AwsPgvectorPlaceRepository(PlaceVectorRepository): embedding=embedding, filters=metadata_filter, limit=limit, + function_name=self._match_function, + ) + return [_match_to_candidate(match) for match in matches] + + async def search_hybrid( + self, + query_text: str, + embedding: list[float], + filters: PlaceFilters, + limit: int, + ) -> Sequence[PlaceCandidate]: + """Use the versioned SQL hybrid contract when it is configured.""" + + if not self._hybrid_function: + return await self.search(embedding=embedding, filters=filters, limit=limit) + metadata_filter = filters.as_metadata_filter() + metadata_filter["is_active"] = True + matches = await self._vector_client.search_places_hybrid( + query_text=query_text, + embedding=embedding, + filters=metadata_filter, + limit=limit, + function_name=self._hybrid_function, ) return [_match_to_candidate(match) for match in matches] def _match_to_candidate(match: VectorMatch) -> PlaceCandidate: metadata = dict(match.metadata) + if match.semantic_score is not None: + metadata["semantic_score"] = match.semantic_score + if match.lexical_score is not None: + metadata["lexical_score"] = match.lexical_score return PlaceCandidate( id=match.id, name=str(metadata.get("name") or match.id), diff --git a/app/modules/places/infrastructure/bert_intent_extractor.py b/app/modules/places/infrastructure/bert_intent_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..e15992ef75d7011179a8af445458a14f89275da8 --- /dev/null +++ b/app/modules/places/infrastructure/bert_intent_extractor.py @@ -0,0 +1,595 @@ +"""Lazy BERT token-classification adapter for open place-chat slots. + +The extractor deliberately returns raw concepts instead of canonical place +categories. Category alignment belongs to the semantic catalog/retrieval +stage; doing it here would recreate a closed taxonomy in the model adapter. + +The default loader imports ``transformers`` only on the first non-empty call. +Tests and alternative serving runtimes can inject a callable classifier or a +loader, so importing this module never requires the optional ML dependency. +""" + +from __future__ import annotations + +import math +import threading +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Literal, Protocol, TypeAlias, cast + + +SlotType = Literal[ + "CATEGORY", + "PREFERENCE", + "EXCLUSION", + "LOCATION", + "REFERENCE", + "RADIUS", +] +SpanPolarity = Literal["positive", "negative", "neutral"] +IOBPrefix = Literal["B", "I"] + +_SLOT_TYPES: frozenset[str] = frozenset( + { + "CATEGORY", + "PREFERENCE", + "EXCLUSION", + "LOCATION", + "REFERENCE", + "RADIUS", + } +) +_POLARITIES: frozenset[str] = frozenset({"positive", "negative", "neutral"}) + + +class BertIntentExtractionError(RuntimeError): + """Base error raised by the contextual intent extractor.""" + + +class BertIntentModelLoadError(BertIntentExtractionError): + """The configured token-classification model could not be loaded.""" + + +class BertIntentInferenceError(BertIntentExtractionError): + """The loaded model failed while processing a message.""" + + +class BertIntentOutputError(BertIntentExtractionError): + """The model returned an invalid token-classification payload.""" + + +@dataclass(frozen=True) +class SlotLabelDefinition: + """Maps one model label to a domain slot and its semantic polarity. + + Mapping is intentionally configuration-driven. For example, a model can + expose ``B-AMENITY`` and map ``AMENITY`` to a positive ``PREFERENCE``, or + expose ``B-NEGATIVE_AMENITY`` and map it to a negative ``PREFERENCE``. + """ + + slot_type: SlotType + polarity: SpanPolarity = "neutral" + + def __post_init__(self) -> None: + slot_type = str(self.slot_type).strip().upper() + polarity = str(self.polarity).strip().lower() + if slot_type not in _SLOT_TYPES: + raise ValueError( + "slot_type must be one of " + ", ".join(sorted(_SLOT_TYPES)) + ) + if polarity not in _POLARITIES: + raise ValueError( + "polarity must be one of " + ", ".join(sorted(_POLARITIES)) + ) + object.__setattr__(self, "slot_type", cast(SlotType, slot_type)) + object.__setattr__(self, "polarity", cast(SpanPolarity, polarity)) + + +@dataclass(frozen=True) +class IntentSpan: + """One contextual slot using character offsets into the original message.""" + + slot_type: SlotType + text: str + start: int + end: int + polarity: SpanPolarity + confidence: float + token_count: int = 1 + + def __post_init__(self) -> None: + if self.slot_type not in _SLOT_TYPES: + raise ValueError(f"unsupported slot_type: {self.slot_type!r}") + if self.polarity not in _POLARITIES: + raise ValueError(f"unsupported polarity: {self.polarity!r}") + if self.start < 0 or self.end <= self.start: + raise ValueError("span offsets must satisfy 0 <= start < end") + if not isinstance(self.text, str) or not self.text: + raise ValueError("span text must not be empty") + if not math.isfinite(self.confidence) or not 0.0 <= self.confidence <= 1.0: + raise ValueError("span confidence must be finite and between 0 and 1") + if self.token_count <= 0: + raise ValueError("span token_count must be positive") + + +@dataclass(frozen=True) +class IntentFrame: + """Open contextual interpretation of one unmodified user message.""" + + raw_text: str + spans: tuple[IntentSpan, ...] + confidence: float + model_name: str + model_version: str + + def __post_init__(self) -> None: + if not isinstance(self.raw_text, str): + raise TypeError("raw_text must be str") + if not math.isfinite(self.confidence) or not 0.0 <= self.confidence <= 1.0: + raise ValueError("frame confidence must be finite and between 0 and 1") + for span in self.spans: + if span.end > len(self.raw_text): + raise ValueError("span extends beyond raw_text") + if self.raw_text[span.start : span.end] != span.text: + raise ValueError("span text must match raw_text character offsets") + + def by_type(self, slot_type: SlotType) -> tuple[IntentSpan, ...]: + """Return all spans of a type without normalizing their raw values.""" + + return tuple(span for span in self.spans if span.slot_type == slot_type) + + @property + def categories(self) -> tuple[IntentSpan, ...]: + return self.by_type("CATEGORY") + + @property + def preferences(self) -> tuple[IntentSpan, ...]: + return tuple( + span + for span in self.spans + if span.slot_type == "PREFERENCE" and span.polarity == "positive" + ) + + @property + def exclusions(self) -> tuple[IntentSpan, ...]: + return tuple( + span + for span in self.spans + if span.slot_type == "EXCLUSION" or span.polarity == "negative" + ) + + +class TokenClassifier(Protocol): + """Minimal shape shared by a Hugging Face pipeline and test doubles.""" + + def __call__(self, text: str) -> Sequence[Mapping[str, Any]]: + """Return token rows containing an IOB label, score, start and end.""" + + +TokenClassifierLoader: TypeAlias = Callable[ + [str, str | None, int | str | None], TokenClassifier +] +LabelDefinitionInput: TypeAlias = ( + SlotLabelDefinition | SlotType | tuple[SlotType, SpanPolarity] +) + + +DEFAULT_LABEL_DEFINITIONS: Mapping[str, SlotLabelDefinition] = { + "CATEGORY": SlotLabelDefinition("CATEGORY", "positive"), + "PREFERENCE": SlotLabelDefinition("PREFERENCE", "positive"), + "EXCLUSION": SlotLabelDefinition("EXCLUSION", "negative"), + "LOCATION": SlotLabelDefinition("LOCATION", "neutral"), + "REFERENCE": SlotLabelDefinition("REFERENCE", "neutral"), + "RADIUS": SlotLabelDefinition("RADIUS", "neutral"), +} + + +@dataclass(frozen=True) +class _TokenPrediction: + prefix: IOBPrefix + definition: SlotLabelDefinition + start: int + end: int + score: float + order: int + + +@dataclass +class _SpanBuilder: + definition: SlotLabelDefinition + start: int + end: int + weighted_score: float + score_weight: int + token_count: int + last_order: int + + @classmethod + def from_token(cls, token: _TokenPrediction) -> _SpanBuilder: + weight = max(1, token.end - token.start) + return cls( + definition=token.definition, + start=token.start, + end=token.end, + weighted_score=token.score * weight, + score_weight=weight, + token_count=1, + last_order=token.order, + ) + + def append(self, token: _TokenPrediction) -> None: + weight = max(1, token.end - token.start) + self.end = max(self.end, token.end) + self.weighted_score += token.score * weight + self.score_weight += weight + self.token_count += 1 + self.last_order = token.order + + def build(self, raw_text: str) -> IntentSpan: + return IntentSpan( + slot_type=self.definition.slot_type, + text=raw_text[self.start : self.end], + start=self.start, + end=self.end, + polarity=self.definition.polarity, + confidence=self.weighted_score / self.score_weight, + token_count=self.token_count, + ) + + +class BertPlaceIntentExtractor: + """Extract contextual, open-value slots with a fine-tuned BERT model. + + ``label_definitions`` is merged over the standard six slot labels. Keys + refer to model labels *without* the IOB prefix. Unknown labels are ignored, + which lets one model expose additional tasks without coupling Places to + them. Values can be ``SlotLabelDefinition``, a canonical slot string, or a + ``(slot, polarity)`` tuple. + """ + + def __init__( + self, + model_name_or_path: str, + *, + model_version: str | None = None, + device: int | str | None = None, + label_definitions: Mapping[str, LabelDefinitionInput] | None = None, + minimum_token_confidence: float = 0.0, + classifier: TokenClassifier | None = None, + model_loader: TokenClassifierLoader | None = None, + ) -> None: + model_name = _required_text(model_name_or_path, "model_name_or_path") + if not math.isfinite(minimum_token_confidence) or not ( + 0.0 <= minimum_token_confidence <= 1.0 + ): + raise ValueError( + "minimum_token_confidence must be finite and between 0 and 1" + ) + if classifier is not None and model_loader is not None: + raise ValueError("provide classifier or model_loader, not both") + if classifier is not None and not callable(classifier): + raise TypeError("classifier must be callable") + + definitions = dict(DEFAULT_LABEL_DEFINITIONS) + for raw_label, raw_definition in (label_definitions or {}).items(): + label = _normalized_model_label(raw_label) + definitions[label] = _coerce_label_definition(raw_definition) + + self._model_name = model_name + self._model_version = ( + _required_text(model_version, "model_version") + if model_version is not None + else "unspecified" + ) + self._revision = model_version + self._device = device + self._definitions = definitions + self._minimum_token_confidence = minimum_token_confidence + self._classifier = classifier + self._model_loader = model_loader or _load_transformers_classifier + self._load_lock = threading.Lock() + self._inference_lock = threading.Lock() + + @property + def is_loaded(self) -> bool: + return self._classifier is not None + + @property + def model_name(self) -> str: + return self._model_name + + @property + def model_version(self) -> str: + return self._model_version + + def extract(self, text: str) -> IntentFrame: + """Extract an immutable frame while preserving ``text`` byte-for-byte.""" + + if not isinstance(text, str): + raise TypeError(f"text must be str, got {type(text).__name__}") + if not text.strip(): + return IntentFrame( + raw_text=text, + spans=(), + confidence=0.0, + model_name=self._model_name, + model_version=self._model_version, + ) + + classifier = self._ensure_classifier() + try: + # Hugging Face pipelines and GPU modules are not guaranteed to be + # safe under concurrent calls. Parsing already runs in a worker + # thread, so serializing one model does not block the event loop. + with self._inference_lock: + raw_predictions = classifier(text) + except Exception as exc: + raise BertIntentInferenceError( + f"token classification failed for {self._model_name!r}: {exc}" + ) from exc + + predictions = self._prepare_predictions(raw_predictions, text) + spans = _decode_iob(predictions, text) + return IntentFrame( + raw_text=text, + spans=spans, + confidence=_frame_confidence(spans), + model_name=self._model_name, + model_version=self._model_version, + ) + + def _ensure_classifier(self) -> TokenClassifier: + classifier = self._classifier + if classifier is not None: + return classifier + + with self._load_lock: + classifier = self._classifier + if classifier is not None: + return classifier + try: + classifier = self._model_loader( + self._model_name, + self._revision, + self._device, + ) + except BertIntentModelLoadError: + raise + except Exception as exc: + raise BertIntentModelLoadError( + f"could not load token-classification model " + f"{self._model_name!r}: {exc}" + ) from exc + if not callable(classifier): + raise BertIntentModelLoadError( + f"loader for {self._model_name!r} did not return a callable" + ) + self._classifier = classifier + return classifier + + def _prepare_predictions( + self, + raw_predictions: Sequence[Mapping[str, Any]], + text: str, + ) -> tuple[_TokenPrediction, ...]: + if isinstance(raw_predictions, (str, bytes, Mapping)) or not isinstance( + raw_predictions, Sequence + ): + raise BertIntentOutputError( + "token classifier output must be a sequence of mappings" + ) + + predictions: list[_TokenPrediction] = [] + for order, row in enumerate(raw_predictions): + if not isinstance(row, Mapping): + raise BertIntentOutputError( + f"token classifier row {order} must be a mapping" + ) + label_value = row.get("entity", row.get("entity_group", row.get("label"))) + if not isinstance(label_value, str): + raise BertIntentOutputError( + f"token classifier row {order} has no string entity label" + ) + prefix, base_label = _split_iob_label(label_value) + if base_label is None: + continue + definition = self._definitions.get(base_label) + if definition is None: + continue + + score = _finite_score(row.get("score"), order) + if score < self._minimum_token_confidence: + continue + start = _integer_offset(row.get("start"), "start", order) + end = _integer_offset(row.get("end"), "end", order) + if start < 0 or end <= start or end > len(text): + raise BertIntentOutputError( + f"token classifier row {order} has invalid offsets " + f"start={start}, end={end}, text_length={len(text)}" + ) + token_order = _token_order(row.get("index"), order) + predictions.append( + _TokenPrediction( + prefix=prefix, + definition=definition, + start=start, + end=end, + score=score, + order=token_order, + ) + ) + return tuple( + sorted( + predictions, + key=lambda token: (token.start, token.end, token.order), + ) + ) + + +def _decode_iob( + predictions: Sequence[_TokenPrediction], + raw_text: str, +) -> tuple[IntentSpan, ...]: + spans: list[IntentSpan] = [] + current: _SpanBuilder | None = None + + for token in predictions: + continues_current = bool( + token.prefix == "I" + and current is not None + and current.definition == token.definition + and token.start >= current.end + and token.order == current.last_order + 1 + ) + if continues_current: + assert current is not None + current.append(token) + continue + + if current is not None: + spans.append(current.build(raw_text)) + # A stray I-label begins a recoverable new span instead of losing the + # user's concept because of one imperfect model transition. + current = _SpanBuilder.from_token(token) + + if current is not None: + spans.append(current.build(raw_text)) + return tuple(spans) + + +def _frame_confidence(spans: Sequence[IntentSpan]) -> float: + if not spans: + return 0.0 + weights = [max(1, span.end - span.start) for span in spans] + return sum( + span.confidence * weight for span, weight in zip(spans, weights) + ) / sum(weights) + + +def _split_iob_label(raw_label: str) -> tuple[IOBPrefix, str | None]: + label = raw_label.strip().upper() + if not label: + raise BertIntentOutputError("token classifier returned an empty label") + if label == "O": + return "B", None + if len(label) > 2 and label[0] in {"B", "I"} and label[1] in {"-", "_"}: + return cast(IOBPrefix, label[0]), _normalized_model_label(label[2:]) + # Aggregated Hugging Face pipelines may return CATEGORY instead of + # B-CATEGORY. Treat an unprefixed label as one complete span. + return "B", _normalized_model_label(label) + + +def _coerce_label_definition(value: LabelDefinitionInput) -> SlotLabelDefinition: + if isinstance(value, SlotLabelDefinition): + return value + if isinstance(value, str): + slot_type = value.strip().upper() + default_definition = DEFAULT_LABEL_DEFINITIONS.get(slot_type) + if default_definition is not None: + return default_definition + return SlotLabelDefinition(cast(SlotType, slot_type)) + if isinstance(value, tuple) and len(value) == 2: + return SlotLabelDefinition( + cast(SlotType, value[0]), + cast(SpanPolarity, value[1]), + ) + raise TypeError( + "label definition must be SlotLabelDefinition, a slot string, or a " + "(slot, polarity) tuple" + ) + + +def _normalized_model_label(value: Any) -> str: + if not isinstance(value, str): + raise TypeError(f"model label must be str, got {type(value).__name__}") + label = value.strip().upper() + if not label: + raise ValueError("model label must not be empty") + return label + + +def _required_text(value: Any, field_name: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{field_name} must be str, got {type(value).__name__}") + cleaned = value.strip() + if not cleaned: + raise ValueError(f"{field_name} must not be empty") + return cleaned + + +def _finite_score(value: Any, row: int) -> float: + if isinstance(value, bool): + raise BertIntentOutputError(f"token classifier row {row} has invalid score") + try: + score = float(value) + except (TypeError, ValueError) as exc: + raise BertIntentOutputError( + f"token classifier row {row} has no numeric score" + ) from exc + if not math.isfinite(score) or not 0.0 <= score <= 1.0: + raise BertIntentOutputError( + f"token classifier row {row} score must be finite and between 0 and 1" + ) + return score + + +def _integer_offset(value: Any, name: str, row: int) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise BertIntentOutputError( + f"token classifier row {row} has no integer {name} offset" + ) + return value + + +def _token_order(value: Any, fallback: int) -> int: + """Preserve Hugging Face token adjacency after ignored labels are removed.""" + + if value is None: + return fallback + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise BertIntentOutputError( + "token classifier output has an invalid token index" + ) + return value + + +def _load_transformers_classifier( + model_name_or_path: str, + revision: str | None, + device: int | str | None, +) -> TokenClassifier: + """Load the optional Hugging Face implementation on first inference.""" + + try: + from transformers import ( # type: ignore[import-not-found] + AutoModelForTokenClassification, + AutoTokenizer, + pipeline, + ) + except ImportError as exc: + raise BertIntentModelLoadError( + "transformers is required to load the BERT intent extractor; " + "install it or inject classifier/model_loader" + ) from exc + + pretrained_kwargs: dict[str, Any] = {"trust_remote_code": False} + if revision is not None: + pretrained_kwargs["revision"] = revision + tokenizer = AutoTokenizer.from_pretrained( + model_name_or_path, + **pretrained_kwargs, + ) + model = AutoModelForTokenClassification.from_pretrained( + model_name_or_path, + **pretrained_kwargs, + ) + pipeline_kwargs: dict[str, Any] = { + "task": "token-classification", + "model": model, + "tokenizer": tokenizer, + "aggregation_strategy": "none", + "ignore_labels": ["O"], + } + if device is not None: + pipeline_kwargs["device"] = device + return cast(TokenClassifier, pipeline(**pipeline_kwargs)) diff --git a/app/modules/places/infrastructure/deterministic_intent_parser.py b/app/modules/places/infrastructure/deterministic_intent_parser.py index a83ba326a3edf8c5d787ca9ae957f55e09313f81..d27a2d8d16049647ee132fee3db2ed7f34af3c8e 100644 --- a/app/modules/places/infrastructure/deterministic_intent_parser.py +++ b/app/modules/places/infrastructure/deterministic_intent_parser.py @@ -1,6 +1,7 @@ from dataclasses import dataclass, replace from functools import lru_cache import json +import logging from pathlib import Path import re from typing import Any, Iterable @@ -28,6 +29,12 @@ from app.modules.places.domain.chat_intent import ( PlaceReference, ) from app.modules.places.domain.errors import ClarificationStateMismatchError +from app.modules.places.infrastructure.bert_intent_extractor import ( + BertIntentExtractionError, + BertPlaceIntentExtractor, + IntentFrame, + IntentSpan, +) from app.shared.nlp.preprocessing.text import ( prepare_for_embedding, tokenize_for_embeddings, @@ -50,9 +57,15 @@ _EXCLUSION_PATTERN = re.compile( r"\b(?:sin|excepto|evita(?:r)?|no\s+quiero|que\s+no\s+(?:sea|tenga))\s+" r"(?P.+?)(?=" r"\s+cerca\s+(?:de\s+la|del|de)\b|[,;]|" - r"\s+(?:pero|aunque)\b|\s+y\s+(?:con|que|quiero|busco)\b|$)" + r"\s+(?:pero|aunque|con)\b|\s+y\s+(?:con|que|quiero|busco)\b|$)" ) _CURRENT_LOCATION_VALUES = {"mi", "aqui", "donde estoy", "mi ubicacion"} +_DETERMINISTIC_INTENT_VERSION = "deterministic-open-v2" +_RADIUS_VALUE_PATTERN = re.compile( + r"(?P\d+(?:[.,]\d+)?)\s*" + r"(?Pkm|kilometros?|m|metros?)\b" +) +_LOGGER = logging.getLogger(__name__) _GENERIC_REQUEST_TOKENS = { "dame", "favor", @@ -171,14 +184,29 @@ class PlaceChatTaxonomy: ) +@dataclass(frozen=True) +class _ContextualSignals: + frame: IntentFrame | None + category_phrases: tuple[str, ...] = () + preferences: tuple[str, ...] = () + exclusions: tuple[str, ...] = () + location: str | None = None + reference: str | None = None + radius_meters: int | None = None + category_confidence: float | None = None + intent_model_version: str = _DETERMINISTIC_INTENT_VERSION + + class DeterministicPlaceChatIntentParser: def __init__( self, taxonomy: PlaceChatTaxonomy | None = None, activity_classifier: PlaceActivityClassifier | None = None, + contextual_extractor: BertPlaceIntentExtractor | None = None, ) -> None: self._taxonomy = taxonomy or load_place_chat_taxonomy() self._activity_classifier = activity_classifier + self._contextual_extractor = contextual_extractor def parse( self, @@ -188,19 +216,30 @@ class DeterministicPlaceChatIntentParser: clarification_choice: ClarificationChoice | None = None, ) -> ParsedPlaceChatIntent: normalized = prepare_for_embedding(message) - target_clause = self._target_clause(normalized) if clarification_choice is not None: return self._resolve_structured_clarification( choice=clarification_choice, state=state, has_user_location=has_user_location, ) + contextual = self._contextual_signals(message) + # Token classification is multi-label and a valid frame can still be + # incomplete. Strip both model-detected context spans and any remaining + # high-precision legacy context before building the retrieval query. The + # category itself stays model/open-vocabulary first; taxonomy aliases are + # deliberately not restored for a successful BERT frame. + target_clause = self._target_clause( + _remove_contextual_spans(normalized, contextual.frame) + ) if state.pending_clarification: pending = ensure_legacy_pending_options(state.pending_clarification) state = replace(state, pending_clarification=pending) - categories = self._matched_categories(target_clause) + categories = self._contextual_categories(contextual, target_clause) inferred = ( - self._inferred_activity_category(target_clause) + self._inferred_activity_category( + target_clause, + allow_lexical_defaults=contextual.frame is None, + ) if not categories else None ) @@ -220,15 +259,25 @@ class DeterministicPlaceChatIntentParser: clear_pending_clarification=True, ), ) - return self._resolve_pending_clarification( - normalized=normalized, - state=state, - has_user_location=has_user_location, + return replace( + self._resolve_pending_clarification( + normalized=normalized, + state=state, + has_user_location=has_user_location, + ), + intent_model_version=contextual.intent_model_version, ) - reference_text = self._extract_reference(normalized) - location_text = self._extract_location(normalized) - radius_meters = self._extract_radius_meters(normalized) + # Fuse slots independently. A CATEGORY-only frame must not erase a + # LOCATION, RADIUS, REFERENCE, preference, or exclusion that the model + # omitted. These narrow extractors are rollout fallbacks, not gates. + reference_text = contextual.reference or self._extract_reference(normalized) + location_text = contextual.location or self._extract_location(normalized) + radius_meters = ( + contextual.radius_meters + if contextual.radius_meters is not None + else self._extract_radius_meters(normalized) + ) excluded_texts = tuple( match.group("value").strip(" ,.;") for match in _EXCLUSION_PATTERN.finditer(normalized) @@ -237,22 +286,33 @@ class DeterministicPlaceChatIntentParser: # Category words inside references, exclusions, or geographic anchors # do not describe the requested result type. In "cafeteria cerca del # parque", only cafe is a hard category. - categories = self._matched_categories(target_clause) + categories = self._contextual_categories(contextual, target_clause) if len(categories) > 1: pending = new_category_clarification(categories) - return self._clarification( - pending=pending, - unresolved=("target_category",), - state=state, - has_user_location=has_user_location, + return replace( + self._clarification( + pending=pending, + unresolved=("target_category",), + state=state, + has_user_location=has_user_location, + ), + intent_model_version=contextual.intent_model_version, ) explicit_category = categories[0] if categories else None inferred = ( - self._inferred_activity_category(target_clause) + self._inferred_activity_category( + target_clause, + allow_lexical_defaults=contextual.frame is None, + ) if explicit_category is None else None ) + category_alternatives = ( + self._ranked_activity_alternatives(target_clause) + if explicit_category is None + else () + ) inferred_category = inferred.category if inferred else None requested_category = explicit_category or inferred_category target_category = requested_category or state.target_category @@ -273,11 +333,36 @@ class DeterministicPlaceChatIntentParser: if location_text: location_text = _RADIUS_PATTERN.sub("", location_text).strip(" ,.;") - detected_preferences = self._matched_preferences(normalized) - explicit_exclusions = _ordered_unique( - exclusion + legacy_preference_text = normalized if not contextual.preferences else "" + detected_preferences = _ordered_unique( + ( + *contextual.preferences, + *self._matched_preferences(legacy_preference_text), + ) + ) + legacy_excluded_texts = tuple( + excluded_text for excluded_text in excluded_texts - for exclusion in self._matched_preferences(excluded_text) + if not any( + _contains_phrase( + prepare_for_embedding(excluded_text), + prepare_for_embedding(contextual_exclusion), + ) + for contextual_exclusion in contextual.exclusions + ) + ) + explicit_exclusions = _ordered_unique( + ( + *contextual.exclusions, + *( + exclusion + for excluded_text in legacy_excluded_texts + for exclusion in ( + self._matched_preferences(excluded_text) + or (prepare_for_embedding(excluded_text),) + ) + ), + ) ) positive_preferences = tuple( preference @@ -318,25 +403,45 @@ class DeterministicPlaceChatIntentParser: else inherited_reference ) hard_filters = dict(state.hard_filters) + if state.city and "city" not in hard_filters: + hard_filters["city"] = state.city + if state.state and "state" not in hard_filters: + hard_filters["state"] = state.state if "economico" in merged_preferences or _contains_any( target_clause, ("mas barato", "economico", "barato"), ): hard_filters["price_preference"] = "lower" - if target_category is None: - pending = new_category_clarification( - ("restaurant", "cafe", "park", "nightlife") - ) - return self._clarification( - pending=pending, - unresolved=("target_category",), - state=state, - has_user_location=has_user_location, + # An unresolved category is not a reason to stop retrieval. Preserve the + # user's open-ended concept in ``semantic_query`` and let dense/lexical + # retrieval provide evidence. A downstream decision policy may still ask + # a clarification, but its options must come from model hypotheses or real + # candidates rather than a fixed menu. + category = ( + self._taxonomy.category(target_category) + if target_category is not None + else None + ) + inferred_category_values = ( + tuple(inferred.category_values) + if inferred is not None + and inferred.category == target_category + and inferred.category_values + else () + ) + rehydrated_category_values = self._activity_category_values( + target_category + ) + category_values = ( + category.storage_values + if category is not None + else ( + inferred_category_values + or rehydrated_category_values + or ((target_category,) if target_category else ()) ) - - category = self._taxonomy.category(target_category) - category_values = category.storage_values if category else (target_category,) + ) compatible_category_values = ( category.compatible_storage_values if category else () ) @@ -345,24 +450,27 @@ class DeterministicPlaceChatIntentParser: if ( reference_text and location_text - and location_text not in _CURRENT_LOCATION_VALUES + and prepare_for_embedding(location_text) not in _CURRENT_LOCATION_VALUES ): - return self._location_scope_clarification( - target_category=target_category, - category_values=category_values, - compatible_category_values=compatible_category_values, - category_evidence_terms=category_evidence_terms, - explicit_category=requested_category, - category_source=category_source, - hard_filters=hard_filters, - preferences=merged_preferences, - exclusions=merged_exclusions, - reference=reference, - reference_text=reference_text, - location_text=location_text, - radius_meters=radius_meters, - state=state, - has_user_location=has_user_location, + return replace( + self._location_scope_clarification( + target_category=target_category, + category_values=category_values, + compatible_category_values=compatible_category_values, + category_evidence_terms=category_evidence_terms, + explicit_category=requested_category, + category_source=category_source, + hard_filters=hard_filters, + preferences=merged_preferences, + exclusions=merged_exclusions, + reference=reference, + reference_text=reference_text, + location_text=location_text, + radius_meters=radius_meters, + state=state, + has_user_location=has_user_location, + ), + intent_model_version=contextual.intent_model_version, ) location, explicit_location = self._location_intent( @@ -383,14 +491,18 @@ class DeterministicPlaceChatIntentParser: ) category_query_term = self._category_query_term(target_clause, category) semantic_parts = [ - target_category, + target_category or "", category_query_term, semantic_target, *merged_preferences, ] if reference and reference.entity: semantic_parts.append(reference.entity) - semantic_query = " ".join(_ordered_unique(semantic_parts)).strip() + semantic_query = ( + " ".join(_ordered_unique(semantic_parts)).strip() + or target_clause + or normalized + ) patch = ConversationStatePatch( target_category=requested_category, @@ -408,10 +520,17 @@ class DeterministicPlaceChatIntentParser: clear_reference=category_changed and reference_text is None, taxonomy_version=self._taxonomy.version, ) - if explicit_category: + if explicit_category and contextual.category_phrases: + confidence = contextual.category_confidence or contextual.frame.confidence + elif explicit_category: confidence = 0.96 elif inferred_category: confidence = inferred.confidence if inferred else 0.88 + elif target_category is None: + # This confidence describes category resolution, not whether the + # query is searchable. Keeping it below the automatic-decision band + # allows candidate-derived clarification without suppressing recall. + confidence = 0.55 else: confidence = 0.84 if reference_text: @@ -431,6 +550,18 @@ class DeterministicPlaceChatIntentParser: compatible_category_values=compatible_category_values, category_evidence_terms=category_evidence_terms, category_source=category_source, + alternatives=category_alternatives, + unresolved=("target_category",) if target_category is None else (), + raw_category_phrase=( + contextual.category_phrases[0] + if contextual.category_phrases + else ( + semantic_target or target_clause + if explicit_category is None + else explicit_category + ) + ), + intent_model_version=contextual.intent_model_version, ) def _location_scope_clarification( @@ -543,8 +674,10 @@ class DeterministicPlaceChatIntentParser: base_state = replace(state, pending_clarification=None) if pending.kind in ("target_category", "intent_category"): if self._taxonomy.category(selected.value) is None: - raise ClarificationStateMismatchError( - "clarification category is not supported" + return self._resolve_open_category_choice( + value=selected.value, + state=base_state, + has_user_location=has_user_location, ) resolved = self.parse( message=self._category_message(selected.value), @@ -780,6 +913,61 @@ class DeterministicPlaceChatIntentParser: return definition.aliases[0] return category or "" + def _resolve_open_category_choice( + self, + value: str, + state: ConversationState, + has_user_location: bool, + ) -> ParsedPlaceChatIntent: + category = " ".join(value.split()).strip() + if not category: + raise ClarificationStateMismatchError( + "clarification category must not be empty" + ) + hard_filters = dict(state.hard_filters) + if state.city and "city" not in hard_filters: + hard_filters["city"] = state.city + if state.state and "state" not in hard_filters: + hard_filters["state"] = state.state + location, _ = self._location_intent( + location_text=None, + radius_meters=None, + state=state, + has_user_location=has_user_location, + ) + semantic_parts = [category, *state.soft_preferences] + if state.reference and state.reference.entity: + semantic_parts.append(state.reference.entity) + category_values = _ordered_unique( + (category, *self._activity_category_values(category)) + ) + return ParsedPlaceChatIntent( + action="recommendations", + target_category=category, + category_values=category_values, + hard_filters=hard_filters, + soft_preferences=state.soft_preferences, + exclusions=state.exclusions, + reference=state.reference, + location=location, + semantic_query=" ".join(_ordered_unique(semantic_parts)).strip(), + confidence=1.0, + state_patch=ConversationStatePatch( + target_category=category, + hard_filters=( + hard_filters if hard_filters != state.hard_filters else None + ), + clear_pending_clarification=True, + taxonomy_version=self._taxonomy.version, + ), + category_evidence_terms=(category,), + category_source="explicit", + raw_category_phrase=category, + intent_model_version=( + f"{_DETERMINISTIC_INTENT_VERSION}+dynamic-clarification-v1" + ), + ) + def _location_intent( self, location_text: str | None, @@ -787,7 +975,10 @@ class DeterministicPlaceChatIntentParser: state: ConversationState, has_user_location: bool, ) -> tuple[LocationIntent, ExplicitTargetLocation | None]: - if location_text and location_text in _CURRENT_LOCATION_VALUES: + if ( + location_text + and prepare_for_embedding(location_text) in _CURRENT_LOCATION_VALUES + ): return ( LocationIntent( scope="user_current_location", @@ -831,11 +1022,123 @@ class DeterministicPlaceChatIntentParser: LocationIntent( scope="user_current_location", source="user_current", + radius_meters=radius_meters, + strict_radius=radius_meters is not None, ), None, ) return (LocationIntent(scope="unresolved", source="none"), None) + def _contextual_signals(self, message: str) -> _ContextualSignals: + extractor = self._contextual_extractor + if extractor is None: + return _ContextualSignals(frame=None) + + try: + frame = extractor.extract(message) + except BertIntentExtractionError as exc: + configured_version = getattr( + extractor, + "model_version", + "unspecified", + ) + _LOGGER.warning( + "BERT place-intent extraction failed; using deterministic " + "fallback (model_version=%s): %s", + configured_version, + exc, + ) + return _ContextualSignals( + frame=None, + intent_model_version=( + f"{_DETERMINISTIC_INTENT_VERSION}+" + f"bert-fallback:{configured_version}" + ), + ) + + category_spans = tuple( + span + for span in frame.by_type("CATEGORY") + if span.polarity != "negative" + ) + positive_preferences = tuple( + span + for span in frame.by_type("PREFERENCE") + if span.polarity == "positive" + ) + negative_spans = tuple( + span + for span in frame.spans + if span.slot_type == "EXCLUSION" or span.polarity == "negative" + ) + locations = frame.by_type("LOCATION") + references = frame.by_type("REFERENCE") + radii = frame.by_type("RADIUS") + version = ( + f"bert-token:{frame.model_name}@{frame.model_version}+" + f"{_DETERMINISTIC_INTENT_VERSION}" + ) + return _ContextualSignals( + frame=frame, + category_phrases=_span_values(category_spans), + preferences=_span_values(positive_preferences), + exclusions=_span_values(negative_spans), + location=_first_span_value(locations), + reference=_first_span_value(references), + radius_meters=( + _radius_meters_from_text(radii[0].text) if radii else None + ), + category_confidence=( + max(span.confidence for span in category_spans) + if category_spans + else None + ), + intent_model_version=version, + ) + + def _contextual_categories( + self, + contextual: _ContextualSignals, + target_clause: str, + ) -> tuple[str, ...]: + if not contextual.category_phrases: + return ( + self._matched_categories(target_clause) + if contextual.frame is None + else () + ) + + categories: list[str] = [] + for phrase in contextual.category_phrases: + aligned = ( + self._activity_classifier.classify(phrase) + if self._activity_classifier is not None + else None + ) + # Semantic alignment is optional and abstaining. Unknown values + # pass through unchanged; no lexical alias table is consulted once + # the contextual model has supplied a category span. + categories.append(aligned.category if aligned is not None else phrase) + return _ordered_unique(categories) + + def _activity_category_values( + self, + target_category: str | None, + ) -> tuple[str, ...]: + """Rehydrate an open catalog concept on conversation continuations.""" + + if target_category is None or self._activity_classifier is None: + return () + get_concept = getattr(self._activity_classifier, "get_concept", None) + if callable(get_concept): + concept = get_concept(target_category) + if concept is not None: + return tuple(getattr(concept, "storage_values", ()) or ()) + for concept in getattr(self._activity_classifier, "concepts", ()): + if getattr(concept, "id", None) == target_category: + return tuple(getattr(concept, "storage_values", ()) or ()) + return () + def _matched_categories(self, normalized: str) -> tuple[str, ...]: return _ordered_unique( category.canonical @@ -864,20 +1167,44 @@ class DeterministicPlaceChatIntentParser: def _inferred_activity_category( self, normalized: str, + *, + allow_lexical_defaults: bool = True, ) -> PlaceCategoryInference | None: - for category, patterns in _ACTIVITY_CATEGORY_DEFAULTS: - if _contains_any(normalized, patterns): - return PlaceCategoryInference( - category=category, - confidence=0.88, - source="lexical_activity", - ) + # Prefer the injected semantic/open-vocabulary model. Lexical activity + # rules remain a compatibility fallback during rollout, never a gate. + if self._activity_classifier is not None: + inferred = self._activity_classifier.classify(normalized) + if inferred is not None: + return inferred + if allow_lexical_defaults: + for category, patterns in _ACTIVITY_CATEGORY_DEFAULTS: + if _contains_any(normalized, patterns): + return PlaceCategoryInference( + category=category, + confidence=0.88, + source="lexical_activity", + ) + return None + + def _ranked_activity_alternatives( + self, + normalized: str, + ) -> tuple[IntentAlternative, ...]: if self._activity_classifier is None: - return None - inferred = self._activity_classifier.classify(normalized) - if inferred is None or self._taxonomy.category(inferred.category) is None: - return None - return inferred + return () + rank = getattr(self._activity_classifier, "rank", None) + if not callable(rank): + return () + matches = rank(normalized, limit=5) + return tuple( + IntentAlternative( + key=str(match.concept_id), + description=str(match.label), + confidence=max(0.0, min(1.0, (float(match.score) + 1.0) / 2.0)), + ) + for match in matches + if getattr(match, "concept_id", None) + ) @staticmethod def _target_clause(normalized: str) -> str: @@ -1023,6 +1350,50 @@ def _contains_phrase(text: str, phrase: str) -> bool: return bool(re.search(rf"(? str: + if frame is None: + return target_clause + cleaned = target_clause + for span in frame.spans: + if ( + span.slot_type not in {"EXCLUSION", "LOCATION", "REFERENCE", "RADIUS"} + and span.polarity != "negative" + ): + continue + normalized_value = prepare_for_embedding(span.text) + if normalized_value: + cleaned = re.sub( + rf"(? tuple[str, ...]: + return _ordered_unique( + " ".join(span.text.split()).strip(" ,.;") for span in spans + ) + + +def _first_span_value(spans: tuple[IntentSpan, ...]) -> str | None: + values = _span_values(spans[:1]) + return values[0] if values else None + + +def _radius_meters_from_text(value: str) -> int | None: + match = _RADIUS_VALUE_PATTERN.search(prepare_for_embedding(value)) + if match is None: + return None + distance = float(match.group("value").replace(",", ".")) + if match.group("unit").startswith(("km", "kilometro")): + distance *= 1000 + return max(1, min(50_000, int(round(distance)))) + + def _contains_any(text: str, values: tuple[str, ...]) -> bool: return any( _contains_phrase(text, prepare_for_embedding(value)) diff --git a/app/modules/places/infrastructure/hybrid_chat_retriever.py b/app/modules/places/infrastructure/hybrid_chat_retriever.py index 601834f9e6b281a6d0b041fdea9bf6d097d1ee78..4e81c20c72caaea040693d38e5eee3079679cd1c 100644 --- a/app/modules/places/infrastructure/hybrid_chat_retriever.py +++ b/app/modules/places/infrastructure/hybrid_chat_retriever.py @@ -1,5 +1,7 @@ +import asyncio import json import math +import re from typing import Sequence from app.modules.places.application.ports.place_repository import PlaceVectorRepository @@ -23,7 +25,12 @@ _EXACT_ENTITY_PREFERENCES = {"hello_kitty"} class HybridContentPlaceChatRetriever: - """Fuse semantic, lexical and facet signals over a bounded vector candidate pool.""" + """Fuse semantic, lexical and facet signals over a bounded vector candidate pool. + + Categories are deliberately treated as ranking evidence, not repository + filters. This keeps open-vocabulary queries useful even when the parser + cannot map the user's wording to a canonical category. + """ def __init__( self, @@ -50,7 +57,7 @@ class HybridContentPlaceChatRetriever: intent: ParsedPlaceChatIntent, limit: int, ) -> Sequence[PlaceChatCandidate]: - if intent.action != "recommendations" or not intent.target_category: + if intent.action != "recommendations": return [] if limit < 1: return [] @@ -62,36 +69,40 @@ class HybridContentPlaceChatRetriever: if cached is not None: return cached - embedding = self._embedding_provider.embed_text(semantic_query) - repository_categories = tuple( - dict.fromkeys( - ( - *intent.category_values, - *intent.compatible_category_values, - ) - ) + embedding = await asyncio.to_thread( + self._embedding_provider.embed_text, + semantic_query, ) filters = PlaceFilters( - city=None, - state=None, - categories=repository_categories, + city=_optional_string(intent.hard_filters.get("city")), + state=_optional_string(intent.hard_filters.get("state")), + # A canonical category is a hypothesis, not a hard constraint. A + # category filter here would prevent semantic retrieval from ever + # seeing useful places whose source taxonomy differs from ours. + categories=None, price_range=_optional_string(intent.hard_filters.get("price_range")), occasion=_optional_string(intent.hard_filters.get("occasion")), + place_ids=( + tuple( + str(place_id) + for place_id in intent.hard_filters.get("place_ids", ()) + if str(place_id).strip() + ) + if intent.hard_filters.get("place_ids") is not None + else None + ), is_active=True, ) + candidate_pool_limit = min(max(limit * 8, 40), 120) raw_candidates = list( - await self._place_repository.search( + await self._search_repository( + query_text=intent.semantic_query.strip(), embedding=embedding, filters=filters, - limit=min(max(limit * 3, limit), 120), + limit=candidate_pool_limit, ) ) - candidates = [ - candidate - for candidate in raw_candidates - if self._matches_hard_category(candidate, intent) - and not self._matches_exclusions(candidate, intent.exclusions) - ] + candidates = raw_candidates if not candidates: return [] @@ -111,26 +122,68 @@ class HybridContentPlaceChatRetriever: b=self._b, ) lexical = normalize_bm25(raw_lexical) - semantic = _unit_score(candidate.score) + repository_semantic = _optional_finite_score( + candidate.metadata.get("semantic_score") + ) + repository_lexical = _optional_finite_score( + candidate.metadata.get("lexical_score") + ) + has_repository_channel_scores = ( + repository_semantic is not None or repository_lexical is not None + ) + semantic = _unit_score( + repository_semantic + if has_repository_channel_scores + else candidate.score + ) + if repository_lexical is not None: + lexical = max(lexical, normalize_bm25(repository_lexical)) + category_score, category_match, category_reason = ( + self._category_affinity(candidate, intent) + ) theme_score, match_level, reasons = self._facet_match( candidate, intent, document_tokens, + category_score=category_score, + category_reason=category_reason, ) - content_score = self._weights.score( + base_content_score = self._weights.score( semantic_score=semantic, lexical_score=lexical, theme_or_reference_score=theme_score, ) - requires_content_evidence = bool( - intent.soft_preferences - or (intent.reference and intent.reference.entity) + exclusion_affinity, exclusion_matches = self._exclusion_affinity( + candidate, + intent.exclusions, ) - if ( - requires_content_evidence - and content_score < self._minimum_content_score - ): - continue + # Exclusions are negative ranking evidence, not a boolean gate. A + # source description may mention an excluded concept in a negated + # form ("sin ruido"), and removing that row would invert intent. + content_score = max( + 0.0, + base_content_score - 0.35 * exclusion_affinity, + ) + # Keep weak candidates so short or novel queries do not collapse to + # zero results. The old minimum remains a quality diagnostic for + # downstream confidence/clarification policy, rather than a gate. + metadata = dict(candidate.metadata) + metadata["retrieval_diagnostics"] = { + "category_affinity": round(category_score, 6), + "category_match": category_match, + "exclusion_affinity": round(exclusion_affinity, 6), + "exclusion_matches": list(exclusion_matches), + "content_quality": ( + "sufficient" + if content_score >= self._minimum_content_score + else "weak" + ), + "meets_minimum_content_score": ( + content_score >= self._minimum_content_score + ), + "minimum_content_score": self._minimum_content_score, + "query_token_count": len(query_tokens), + } ranked.append( PlaceChatCandidate( place_id=candidate.id, @@ -141,16 +194,16 @@ class HybridContentPlaceChatRetriever: lexical_score=lexical, match_level=match_level, matched_reasons=reasons, - metadata=dict(candidate.metadata), + metadata=metadata, ) ) ranked.sort( key=lambda item: ( - _match_level_priority(item.match_level), -item.content_score, -item.semantic_score, -item.lexical_score, + _match_level_priority(item.match_level), item.place_id, ) ) @@ -168,6 +221,34 @@ class HybridContentPlaceChatRetriever: self._cache.set(cache_key, result) return result + async def _search_repository( + self, + query_text: str, + embedding: list[float], + filters: PlaceFilters, + limit: int, + ) -> Sequence[PlaceCandidate]: + """Prefer an optional independent dense+lexical repository search. + + ``PlaceVectorRepository`` intentionally keeps its existing contract. + Repositories can opt into hybrid candidate generation duck-typically, + while mocks and current adapters continue through ``search``. + """ + + hybrid_search = getattr(self._place_repository, "search_hybrid", None) + if callable(hybrid_search): + return await hybrid_search( + query_text=query_text, + embedding=embedding, + filters=filters, + limit=limit, + ) + return await self._place_repository.search( + embedding=embedding, + filters=filters, + limit=limit, + ) + @staticmethod def _cache_key( intent: ParsedPlaceChatIntent, @@ -176,6 +257,7 @@ class HybridContentPlaceChatRetriever: ) -> str: payload = { "query": semantic_query, + "target_category": intent.target_category, "categories": intent.category_values, "compatible_categories": intent.compatible_category_values, "category_evidence_terms": intent.category_evidence_terms, @@ -191,59 +273,88 @@ class HybridContentPlaceChatRetriever: ) @staticmethod - def _matches_hard_category( + def _category_affinity( candidate: PlaceCandidate, intent: ParsedPlaceChatIntent, - ) -> bool: - if not candidate.category: - return False - actual = prepare_for_embedding(candidate.category).replace(" ", "_") + ) -> tuple[float, str, str | None]: + actual = _normalized_category(candidate.category) exact = { - prepare_for_embedding(value).replace(" ", "_") - for value in intent.category_values + normalized + for value in (intent.target_category, *intent.category_values) + if (normalized := _normalized_category(value)) } - if actual in exact: - return True compatible = { - prepare_for_embedding(value).replace(" ", "_") + normalized for value in intent.compatible_category_values + if (normalized := _normalized_category(value)) } - if actual not in compatible: - return False - document_tokens = _category_evidence_tokens(candidate) - return any( - evidence_tokens and evidence_tokens <= document_tokens + has_category_hypothesis = bool(exact or compatible) + if not has_category_hypothesis: + return 0.0, "not_requested", None + + reason = intent.target_category or next(iter(intent.category_values), None) + if actual and actual in exact: + return 1.0, "exact", reason or candidate.category + + candidate_evidence_tokens = _category_evidence_tokens(candidate) + has_specific_evidence = any( + term_tokens and term_tokens <= candidate_evidence_tokens for term in intent.category_evidence_terms - if (evidence_tokens := set(tokenize(term))) + if (term_tokens := set(tokenize(term))) ) + if actual and actual in compatible: + if has_specific_evidence: + return 0.80, "compatible_with_evidence", reason + return 0.35, "compatible", reason + if has_specific_evidence: + return 0.65, "textual_evidence", reason + return 0.0, "none", None @staticmethod - def _matches_exclusions( + def _exclusion_affinity( candidate: PlaceCandidate, exclusions: tuple[str, ...], - ) -> bool: + ) -> tuple[float, tuple[str, ...]]: if not exclusions: - return False - document_tokens = set(place_tokens(candidate)) - return any( - exclusion_tokens - and exclusion_tokens <= document_tokens - for exclusion in exclusions - if ( - exclusion_tokens := set( - tokenize(exclusion.replace("_", " ")) + return 0.0, () + evidence = prepare_for_embedding( + " ".join( + value + for value in ( + candidate.name, + candidate.category or "", + candidate.document or "", + _as_evidence_text(candidate.metadata.get("tags")), + _as_evidence_text(candidate.metadata.get("short_description")), ) + if value ) ) + document_tokens = set(tokenize(evidence)) + matches: list[str] = [] + for exclusion in exclusions: + normalized = prepare_for_embedding(exclusion.replace("_", " ")) + exclusion_tokens = set(tokenize(normalized)) + if not exclusion_tokens or not exclusion_tokens <= document_tokens: + continue + if _is_negated_in_evidence(evidence, normalized): + continue + matches.append(exclusion) + unique_matches = tuple(dict.fromkeys(matches)) + return len(unique_matches) / len(exclusions), unique_matches @staticmethod def _facet_match( candidate: PlaceCandidate, intent: ParsedPlaceChatIntent, document_tokens: list[str], + category_score: float = 0.0, + category_reason: str | None = None, ) -> tuple[float, str, tuple[str, ...]]: token_set = set(document_tokens) - reasons: list[str] = [intent.target_category or "place"] + reasons: list[str] = [] + if category_score > 0 and category_reason: + reasons.append(category_reason) matched_preferences = 0 exact_preference = False for preference in intent.soft_preferences: @@ -268,11 +379,16 @@ class HybridContentPlaceChatRetriever: if reference_exact: reasons.append(intent.reference.entity) - if reference_exact or exact_preference: - return 1.0, "exact", tuple(dict.fromkeys(reasons)) - if matched_preferences: - return preference_score, "family", tuple(dict.fromkeys(reasons)) - return 0.0, "broad", tuple(dict.fromkeys(reasons)) + facet_score = max( + category_score, + preference_score, + 1.0 if reference_exact else 0.0, + ) + if reference_exact or exact_preference or category_score >= 1.0: + return facet_score, "exact", tuple(dict.fromkeys(reasons)) + if matched_preferences or category_score >= 0.50: + return facet_score, "family", tuple(dict.fromkeys(reasons)) + return facet_score, "broad", tuple(dict.fromkeys(reasons)) def _match_level_priority(level: str) -> int: @@ -286,6 +402,12 @@ def _optional_string(value: object) -> str | None: return normalized or None +def _normalized_category(value: object) -> str: + if value is None: + return "" + return prepare_for_embedding(str(value)).replace(" ", "_") + + def _category_evidence_tokens(candidate: PlaceCandidate) -> set[str]: # Do not inspect candidate.document here: indexed documents intentionally # contain broad category profiles (for example, all `entertainment` records @@ -310,6 +432,22 @@ def _as_evidence_text(value: object) -> str: return str(value) +def _is_negated_in_evidence(evidence: str, concept: str) -> bool: + """Detect common source-side negation without business-category rules.""" + + escaped = re.escape(concept).replace(r"\ ", r"\s+") + negation = ( + r"(?:sin|libre\s+de|no\s+(?:hay|tiene|ofrece)|" + r"evita(?:r)?|prohibid[oa]s?)" + ) + return bool( + re.search( + rf"\b{negation}\s+(?:\w+\s+){{0,2}}{escaped}\b", + evidence, + ) + ) + + def _unit_score(value: object) -> float: try: score = float(value) @@ -318,3 +456,13 @@ def _unit_score(value: object) -> float: if not math.isfinite(score): return 0.0 return max(0.0, min(1.0, score)) + + +def _optional_finite_score(value: object) -> float | None: + if value is None: + return None + try: + score = float(value) + except (TypeError, ValueError): + return None + return score if math.isfinite(score) else None diff --git a/app/modules/places/infrastructure/open_vocabulary_category_classifier.py b/app/modules/places/infrastructure/open_vocabulary_category_classifier.py new file mode 100644 index 0000000000000000000000000000000000000000..c39f2315a25dd53fec891c3b404dbc47097b09de --- /dev/null +++ b/app/modules/places/infrastructure/open_vocabulary_category_classifier.py @@ -0,0 +1,368 @@ +"""Open-vocabulary place category alignment over a dynamic concept catalog.""" + +from __future__ import annotations + +import math +import threading +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +from app.modules.places.domain.chat_intent import PlaceCategoryInference +from app.shared.nlp.embeddings.base import EmbeddingProvider + + +@dataclass(frozen=True) +class PlaceCategoryConcept: + """A category concept supplied by configuration, a database, or an API.""" + + id: str + label: str + description: str + examples: tuple[str, ...] = () + storage_values: tuple[str, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "id", _required_text(self.id, "id")) + object.__setattr__(self, "label", _required_text(self.label, "label")) + object.__setattr__( + self, + "description", + _required_text(self.description, "description"), + ) + object.__setattr__( + self, + "examples", + _clean_text_values(self.examples, "examples"), + ) + object.__setattr__( + self, + "storage_values", + _clean_text_values(self.storage_values, "storage_values"), + ) + + +@dataclass(frozen=True) +class PlaceCategoryMatch: + """One semantic catalog match, including separation from the next match.""" + + concept_id: str + label: str + description: str + storage_values: tuple[str, ...] + score: float + margin: float + + +@dataclass(frozen=True) +class _IndexedConcept: + concept: PlaceCategoryConcept + vectors: tuple[tuple[float, ...], ...] + + +ConceptInput = PlaceCategoryConcept | Mapping[str, Any] + + +class OpenVocabularyPlaceCategoryClassifier: + """Ranks injected concepts without a closed classifier label head. + + Concept vectors are built on first use, so constructing this component does + not force a transformer model to load during application startup. ``rank`` + always exposes evidence; ``classify`` applies confidence thresholds and is + compatible with ``PlaceActivityClassifier``. + """ + + def __init__( + self, + concepts: Sequence[ConceptInput], + embedding_provider: EmbeddingProvider, + *, + concept_embedding_provider: EmbeddingProvider | None = None, + minimum_similarity: float = 0.44, + minimum_margin: float = 0.04, + ) -> None: + if not -1.0 <= minimum_similarity <= 1.0: + raise ValueError("minimum_similarity must be between -1 and 1") + if not 0.0 <= minimum_margin <= 2.0: + raise ValueError("minimum_margin must be between 0 and 2") + + prepared_concepts = tuple(_coerce_concept(concept) for concept in concepts) + duplicate_ids = _duplicate_concept_ids(prepared_concepts) + if duplicate_ids: + raise ValueError( + "concept ids must be unique (case-insensitive): " + + ", ".join(duplicate_ids) + ) + + self._concepts = prepared_concepts + self._concepts_by_id = { + concept.id.casefold(): concept for concept in prepared_concepts + } + self._embedding_provider = embedding_provider + self._concept_embedding_provider = ( + concept_embedding_provider or embedding_provider + ) + self._minimum_similarity = minimum_similarity + self._minimum_margin = minimum_margin + self._index: tuple[_IndexedConcept, ...] | None = None + self._embedding_dimension: int | None = None + self._index_lock = threading.Lock() + + @property + def concepts(self) -> tuple[PlaceCategoryConcept, ...]: + return self._concepts + + @property + def is_indexed(self) -> bool: + return self._index is not None + + def get_concept(self, concept_id: str) -> PlaceCategoryConcept | None: + """Resolve persisted concept IDs without rerunning classification.""" + + if not isinstance(concept_id, str): + return None + return self._concepts_by_id.get(concept_id.strip().casefold()) + + def rank(self, text: str, limit: int = 3) -> tuple[PlaceCategoryMatch, ...]: + """Return semantic top-k matches without suppressing low-confidence rows.""" + + if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0: + raise ValueError("limit must be a positive integer") + if not isinstance(text, str): + raise TypeError(f"text must be str, got {type(text).__name__}") + query_text = text.strip() + if not query_text or not self._concepts: + return () + + query_vector = _validated_vector( + self._embedding_provider.embed_text(query_text), + context="query", + ) + if query_vector is None: + return () + + index = self._ensure_index() + if not index: + return () + if self._embedding_dimension != len(query_vector): + raise ValueError( + "query and concept embedding dimensions differ: " + f"query={len(query_vector)}, concepts={self._embedding_dimension}" + ) + + scores = sorted( + ( + ( + entry, + max( + _cosine_similarity(query_vector, prototype) + for prototype in entry.vectors + ), + ) + for entry in index + if entry.vectors + ), + key=lambda item: (-item[1], item[0].concept.id.casefold()), + ) + matches: list[PlaceCategoryMatch] = [] + for position, (entry, score) in enumerate(scores[:limit]): + next_score = scores[position + 1][1] if position + 1 < len(scores) else -1.0 + concept = entry.concept + matches.append( + PlaceCategoryMatch( + concept_id=concept.id, + label=concept.label, + description=concept.description, + storage_values=concept.storage_values, + score=score, + margin=max(0.0, score - next_score), + ) + ) + return tuple(matches) + + def classify(self, text: str) -> PlaceCategoryInference | None: + """Return the best concept only when its score and margin are sufficient.""" + + matches = self.rank(text, limit=2) + if not matches: + return None + best = matches[0] + if ( + best.score < self._minimum_similarity + or best.margin < self._minimum_margin + ): + return None + + semantic_confidence = (best.score + 1.0) / 2.0 + separation_confidence = min(1.0, best.margin / 0.5) + confidence = max( + 0.0, + min(0.99, semantic_confidence * 0.85 + separation_confidence * 0.15), + ) + return PlaceCategoryInference( + category=best.concept_id, + confidence=confidence, + source="semantic_activity", + category_values=best.storage_values, + label=best.label, + ) + + def _ensure_index(self) -> tuple[_IndexedConcept, ...]: + index = self._index + if index is not None: + return index + + with self._index_lock: + index = self._index + if index is not None: + return index + + texts: list[str] = [] + owners: list[int] = [] + for concept_index, concept in enumerate(self._concepts): + for semantic_text in _semantic_texts(concept): + texts.append(semantic_text) + owners.append(concept_index) + + raw_vectors = self._concept_embedding_provider.embed_batch(texts) + if len(raw_vectors) != len(texts): + raise ValueError( + "embedding provider returned an unexpected number of concept " + f"vectors: returned={len(raw_vectors)}, expected={len(texts)}" + ) + + vectors_by_concept: list[list[tuple[float, ...]]] = [ + [] for _ in self._concepts + ] + embedding_dimension: int | None = None + for text_index, (owner, raw_vector) in enumerate( + zip(owners, raw_vectors) + ): + vector = _validated_vector( + raw_vector, + context=f"concept text {text_index}", + ) + if vector is None: + continue + if embedding_dimension is None: + embedding_dimension = len(vector) + elif len(vector) != embedding_dimension: + raise ValueError( + "concept embedding dimensions differ: " + f"expected={embedding_dimension}, returned={len(vector)}, " + f"text_index={text_index}" + ) + vectors_by_concept[owner].append(vector) + + index = tuple( + _IndexedConcept(concept=concept, vectors=tuple(vectors)) + for concept, vectors in zip(self._concepts, vectors_by_concept) + if vectors + ) + self._embedding_dimension = embedding_dimension + self._index = index + return index + + +def _coerce_concept(value: ConceptInput) -> PlaceCategoryConcept: + if isinstance(value, PlaceCategoryConcept): + return value + if not isinstance(value, Mapping): + raise TypeError( + "concept must be PlaceCategoryConcept or a mapping, got " + f"{type(value).__name__}" + ) + missing = [key for key in ("id", "label", "description") if key not in value] + if missing: + raise ValueError("concept is missing required fields: " + ", ".join(missing)) + return PlaceCategoryConcept( + id=value["id"], + label=value["label"], + description=value["description"], + examples=_value_sequence(value.get("examples", ()), "examples"), + storage_values=_value_sequence( + value.get("storage_values", ()), "storage_values" + ), + ) + + +def _value_sequence(value: Any, field_name: str) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str): + return (value,) + if not isinstance(value, Sequence): + raise TypeError(f"{field_name} must be a string or sequence of strings") + return tuple(value) + + +def _required_text(value: Any, field_name: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{field_name} must be str, got {type(value).__name__}") + cleaned = " ".join(value.split()) + if not cleaned: + raise ValueError(f"{field_name} must not be empty") + return cleaned + + +def _clean_text_values(values: Sequence[str], field_name: str) -> tuple[str, ...]: + cleaned: list[str] = [] + seen: set[str] = set() + for index, value in enumerate(values): + item = _required_text(value, f"{field_name}[{index}]") + key = item.casefold() + if key not in seen: + seen.add(key) + cleaned.append(item) + return tuple(cleaned) + + +def _duplicate_concept_ids( + concepts: Sequence[PlaceCategoryConcept], +) -> tuple[str, ...]: + seen: set[str] = set() + duplicates: list[str] = [] + for concept in concepts: + key = concept.id.casefold() + if key in seen: + duplicates.append(concept.id) + seen.add(key) + return tuple(duplicates) + + +def _semantic_texts(concept: PlaceCategoryConcept) -> tuple[str, ...]: + candidates = ( + f"{concept.label}. {concept.description}", + concept.label, + concept.description, + *concept.examples, + ) + return _clean_text_values(candidates, "semantic_texts") + + +def _validated_vector( + raw_vector: Sequence[float], + *, + context: str, +) -> tuple[float, ...] | None: + try: + vector = tuple(float(value) for value in raw_vector) + except (TypeError, ValueError) as exc: + raise ValueError(f"embedding for {context} is not numeric") from exc + if not vector or not any(value != 0.0 for value in vector): + return None + if not all(math.isfinite(value) for value in vector): + raise ValueError(f"embedding for {context} contains NaN or infinity") + return vector + + +def _cosine_similarity( + left: Sequence[float], + right: Sequence[float], +) -> float: + dot = sum(a * b for a, b in zip(left, right)) + left_norm = math.sqrt(sum(value * value for value in left)) + right_norm = math.sqrt(sum(value * value for value in right)) + if left_norm == 0.0 or right_norm == 0.0: + return -1.0 + return max(-1.0, min(1.0, dot / (left_norm * right_norm))) diff --git a/app/modules/places/infrastructure/place_category_catalog.py b/app/modules/places/infrastructure/place_category_catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..cc7c9a08b7ad47d6694251bd7b3c6076177d99e5 --- /dev/null +++ b/app/modules/places/infrastructure/place_category_catalog.py @@ -0,0 +1,87 @@ +"""Load an open-vocabulary place concept catalog from data, not code rules.""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +from app.modules.places.infrastructure.open_vocabulary_category_classifier import ( + PlaceCategoryConcept, +) +from app.modules.places.infrastructure.place_semantic_document import PlaceTag +from app.shared.nlp.preprocessing.text import prepare_for_embedding + + +def load_place_category_concepts( + catalog_path: str | None, + *, + fallback_tags: Iterable[PlaceTag] = (), +) -> tuple[PlaceCategoryConcept, ...]: + """Load concepts exported by the source system, or derive them from tag data. + + The external catalog format is ``{"concepts": [...]}`` (a bare list is also + accepted). It can be refreshed independently of an application deployment. + The bundled tag inventory is only a backwards-compatible data fallback; no + category names, aliases or linguistic rules live in this module. + """ + + if catalog_path: + path = Path(catalog_path).expanduser() + if not path.is_file(): + raise FileNotFoundError(f"Places category catalog not found at {path}") + payload = json.loads(path.read_text(encoding="utf-8")) + records = payload.get("concepts") if isinstance(payload, Mapping) else payload + if not isinstance(records, list): + raise ValueError("Places category catalog must contain a concepts list") + return tuple(_concept_from_record(record) for record in records) + + return _concepts_from_tags(fallback_tags) + + +def _concepts_from_tags(tags: Iterable[PlaceTag]) -> tuple[PlaceCategoryConcept, ...]: + concepts: list[PlaceCategoryConcept] = [] + seen: set[str] = set() + for tag in tags: + if prepare_for_embedding(tag.category).replace(" ", "_") != "place_category": + continue + normalized = prepare_for_embedding(tag.name) + concept_id = normalized.replace(" ", "_") + if not concept_id or concept_id in seen: + continue + seen.add(concept_id) + concepts.append( + PlaceCategoryConcept( + id=concept_id, + label=tag.name, + description=f"Tipo de lugar registrado: {tag.name}", + storage_values=tuple(dict.fromkeys((tag.name, normalized, concept_id))), + ) + ) + return tuple(concepts) + + +def _concept_from_record(record: Any) -> PlaceCategoryConcept: + if not isinstance(record, Mapping): + raise ValueError("Each Places category concept must be an object") + try: + return PlaceCategoryConcept( + id=str(record["id"]), + label=str(record["label"]), + description=str(record["description"]), + examples=_text_tuple(record.get("examples")), + storage_values=_text_tuple(record.get("storage_values")), + ) + except KeyError as exc: + raise ValueError(f"Missing Places category concept field: {exc.args[0]}") from exc + + +def _text_tuple(value: Any) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str): + return (value,) + if not isinstance(value, list): + raise ValueError("Concept examples and storage_values must be lists") + return tuple(str(item) for item in value) diff --git a/app/modules/places/infrastructure/place_semantic_document.py b/app/modules/places/infrastructure/place_semantic_document.py index 56eb22602ff0169c433e4171b356305b138cb0a6..4ff28d1703f0af366e721424bc3b8cc5edabc457 100644 --- a/app/modules/places/infrastructure/place_semantic_document.py +++ b/app/modules/places/infrastructure/place_semantic_document.py @@ -4,42 +4,16 @@ import json from pathlib import Path from typing import Any -from app.shared.nlp.embeddings.weighted_document import build_weighted_document from app.shared.nlp.preprocessing.text import clean_text PLACE_SEMANTIC_FIELD_WEIGHTS = { - "tags": 6, - "category": 4, - "description": 3, + "tags": 1, + "category": 1, + "description": 1, "name": 1, } -PLACE_SEMANTIC_DOCUMENT_VERSION = "weighted-tags-v2" - - -# Broad categories from the main API are expanded into Spanish intent terms so -# sparse OSM records still have a useful semantic anchor. -CATEGORY_SEMANTIC_PROFILES = { - "restaurant": "restaurant restaurante comida gastronomia comer cena almuerzo desayuno", - "cafe": "cafe cafeteria bebidas desayuno postres conversar", - "bar": "bar bebidas cocteles cerveza amigos musica noche", - "nightlife": "nightlife vida nocturna noche baile musica bar fiesta", - "shopping": "shopping compras tiendas ropa calzado productos mercado centro comercial", - "lodging": "lodging alojamiento hotel hospedaje hostal dormir turismo viaje", - "park": "park parque naturaleza caminar paseo aire libre mascotas ejercicio", - "culture": "culture cultura museo arte historia biblioteca exposicion lectura", - "tourism": "tourism turismo atraccion visitar explorar paseo historia", - "sports": "sports deporte ejercicio entrenamiento gimnasio actividad fisica", - "community": "community comunidad convivencia reuniones centro comunitario actividades", - "family": "family familia ninos juegos convivencia actividades familiares", - "entertainment": "entertainment entretenimiento diversion juegos cine actividades", - "cinema": "cinema cine pelicula estreno sala de cine entretenimiento", - "library": "library biblioteca libros lectura estudio cultura", - "bakery": "bakery panaderia pan pasteleria reposteria", - "ice_cream": "ice cream heladeria helado postres dessert", - "market": "market mercado tianguis compras productos locales", - "outdoors": "outdoors aire libre mirador sendero naturaleza aventura", -} +PLACE_SEMANTIC_DOCUMENT_VERSION = "structured-place-v3" @dataclass(frozen=True) @@ -63,26 +37,28 @@ def build_place_semantic_document( description: str, resolved_tags: ResolvedPlaceTags, ) -> str: - tags_text = " ".join(resolved_tags.names) - return build_weighted_document( - [ - (name, PLACE_SEMANTIC_FIELD_WEIGHTS["name"]), - ( - semantic_category_text(category), - PLACE_SEMANTIC_FIELD_WEIGHTS["category"], - ), - (description, PLACE_SEMANTIC_FIELD_WEIGHTS["description"]), - (tags_text, PLACE_SEMANTIC_FIELD_WEIGHTS["tags"]), - ] + # Transformer encoders use context and sentence structure; repeating tokens + # to simulate weights (the old FastText strategy) distorts that context. + # Keep every source value once and expose its role explicitly instead. + fields = ( + ("Nombre", clean_text(name)), + ("Tipo registrado", semantic_category_text(category)), + ("Descripcion", clean_text(description)), + ("Etiquetas", " ".join(resolved_tags.names)), + ("Familias de etiquetas", " ".join(resolved_tags.categories)), ) + return " ".join( + f"{label}: {value}." + for label, value in fields + if value + ).strip() def semantic_category_text(category: Any) -> str: raw_category = clean_text(str(category or "")).casefold().replace("_", " ") if not raw_category: return "" - profile_key = raw_category.replace(" ", "_") - return CATEGORY_SEMANTIC_PROFILES.get(profile_key, raw_category) + return raw_category def resolve_place_tags(value: Any) -> ResolvedPlaceTags: diff --git a/app/modules/places/infrastructure/semantic_activity_classifier.py b/app/modules/places/infrastructure/semantic_activity_classifier.py index 959ac186f1c49563f0d248d51d54dd98cde8b163..4711526d1e6aa31fe7b419498c33e2c6fbc08c74 100644 --- a/app/modules/places/infrastructure/semantic_activity_classifier.py +++ b/app/modules/places/infrastructure/semantic_activity_classifier.py @@ -1,204 +1,56 @@ -import math -from typing import Sequence +"""Backward-compatible facade for the data-driven category aligner. -from app.modules.places.domain.chat_intent import PlaceCategoryInference -from app.shared.nlp.embeddings.base import EmbeddingProvider -from app.shared.nlp.preprocessing.text import tokenize_for_embeddings +The previous implementation embedded a fixed category/prototype dictionary in +Python. This facade keeps the import path for callers while requiring concepts +to come from configuration, the source API, or a catalog export. +""" +from __future__ import annotations -_CATEGORY_PROTOTYPES: dict[str, tuple[str, ...]] = { - "restaurant": ( - "comer comida restaurante", - "hambre tacos pizza sushi", - "desayunar almorzar cenar", - "antojo platillo cocina", - ), - "cafe": ( - "cafe cafeteria capuchino espresso", - "tomar cafe merendar conversar", - "trabajar laptop cafe", - ), - "park": ( - "parque picnic caminar relajarse", - "juegos infantiles areas verdes", - "pasear familia cesped", - ), - "bar": ( - "cerveza cocteles tragos bar", - "beber copas cantina", - ), - "nightlife": ( - "bailar fiesta discoteca antro", - "fiesta musica vida nocturna", - ), - "culture": ( - "museo arte exposicion cultura", - "historia galeria centro cultural", - ), - "shopping": ( - "comprar tiendas centro comercial", - "ropa regalos compras", - ), - "sports": ( - "ejercicio entrenar gimnasio deporte", - "futbol cancha nadar fitness", - ), - "bakery": ( - "pan pasteles panaderia reposteria", - "comprar pan pastel", - ), - "ice_cream": ( - "helado postre heladeria dulce", - "comer helado nieve", - ), - "cinema": ( - "pelicula cine estreno", - "ver pelicula pantalla", - ), - "library": ( - "leer estudiar libros biblioteca", - "lectura investigacion biblioteca", - ), - "market": ( - "mercado tianguis productos locales", - "puestos comprar alimentos mercado", - ), - "outdoors": ( - "senderismo naturaleza montana mirador", - "aventura aire libre paisaje", - ), - "lodging": ( - "dormir hotel hospedaje alojamiento", - "pasar noche hostal", - ), -} +from collections.abc import Mapping, Sequence +from typing import Any -_GENERIC_SINGLE_TOKEN_INTENTS = { - "divertirme", - "pasear", - "relajarme", - "salir", -} +from app.modules.places.domain.chat_intent import PlaceCategoryInference +from app.modules.places.infrastructure.open_vocabulary_category_classifier import ( + OpenVocabularyPlaceCategoryClassifier, + PlaceCategoryConcept, + PlaceCategoryMatch, +) +from app.shared.nlp.embeddings.base import EmbeddingProvider class SemanticPlaceActivityClassifier: - """Nearest-prototype classifier over the already-loaded local embeddings.""" + """Compatibility adapter with no built-in categories or lexical rules.""" def __init__( self, embedding_provider: EmbeddingProvider, + concepts: Sequence[PlaceCategoryConcept | Mapping[str, Any]], + *, + concept_embedding_provider: EmbeddingProvider | None = None, minimum_similarity: float = 0.44, minimum_margin: float = 0.04, - window_size: int = 8, + window_size: int | None = None, ) -> None: - if not -1.0 <= minimum_similarity <= 1.0: - raise ValueError("minimum_similarity must be between -1 and 1") - if not 0.0 <= minimum_margin <= 2.0: - raise ValueError("minimum_margin must be between 0 and 2") - if window_size < 2: + if window_size is not None and window_size < 2: raise ValueError("window_size must be at least 2") - - self._embedding_provider = embedding_provider - self._minimum_similarity = minimum_similarity - self._minimum_margin = minimum_margin - self._window_size = window_size - self._prototype_vectors = { - category: tuple( - vector - for vector in embedding_provider.embed_batch(list(prototypes)) - if _has_magnitude(vector) - ) - for category, prototypes in _CATEGORY_PROTOTYPES.items() - } - - def classify(self, text: str) -> PlaceCategoryInference | None: - tokens = tokenize_for_embeddings(text) - if not tokens: - return None - if len(tokens) == 1 and tokens[0] in _GENERIC_SINGLE_TOKEN_INTENTS: - return None - - query_vectors = tuple( - vector - for vector in self._embedding_provider.embed_batch( - self._query_segments(tokens) - ) - if _has_magnitude(vector) + self._delegate = OpenVocabularyPlaceCategoryClassifier( + concepts=concepts, + embedding_provider=embedding_provider, + concept_embedding_provider=concept_embedding_provider, + minimum_similarity=minimum_similarity, + minimum_margin=minimum_margin, ) - if not query_vectors: - return None - scores = sorted( - ( - ( - category, - max( - _cosine_similarity(query, prototype) - for query in query_vectors - for prototype in prototypes - ), - ) - for category, prototypes in self._prototype_vectors.items() - if prototypes - ), - key=lambda item: (-item[1], item[0]), - ) - if not scores: - return None + @property + def concepts(self) -> tuple[PlaceCategoryConcept, ...]: + return self._delegate.concepts - category, best_score = scores[0] - second_score = scores[1][1] if len(scores) > 1 else -1.0 - margin = best_score - second_score - if ( - best_score < self._minimum_similarity - or margin < self._minimum_margin - ): - return None - - confidence = min( - 0.92, - max( - 0.74, - 0.74 - + (best_score - self._minimum_similarity) * 0.30 - + min(margin, 0.20) * 0.35, - ), - ) - return PlaceCategoryInference( - category=category, - confidence=confidence, - source="semantic_activity", - ) - - def _query_segments(self, tokens: list[str]) -> list[str]: - full_text = " ".join(tokens) - if len(tokens) <= self._window_size: - return [full_text] - - segments = [full_text] - stride = max(2, self._window_size // 2) - for start in range(0, len(tokens), stride): - window = tokens[start : start + self._window_size] - if len(window) >= 2: - segments.append(" ".join(window)) - if start + self._window_size >= len(tokens): - break - return list(dict.fromkeys(segments)) - - -def _has_magnitude(vector: Sequence[float]) -> bool: - return any(float(value) != 0.0 for value in vector) + def get_concept(self, concept_id: str) -> PlaceCategoryConcept | None: + return self._delegate.get_concept(concept_id) + def classify(self, text: str) -> PlaceCategoryInference | None: + return self._delegate.classify(text) -def _cosine_similarity( - left: Sequence[float], - right: Sequence[float], -) -> float: - if len(left) != len(right) or not left: - return -1.0 - dot = sum(float(a) * float(b) for a, b in zip(left, right)) - left_norm = math.sqrt(sum(float(value) ** 2 for value in left)) - right_norm = math.sqrt(sum(float(value) ** 2 for value in right)) - if left_norm == 0.0 or right_norm == 0.0: - return -1.0 - return max(-1.0, min(1.0, dot / (left_norm * right_norm))) + def rank(self, text: str, limit: int = 3) -> tuple[PlaceCategoryMatch, ...]: + return self._delegate.rank(text, limit=limit) diff --git a/app/modules/places/infrastructure/semantic_place_ranker.py b/app/modules/places/infrastructure/semantic_place_ranker.py index 80d1fa03e6ed587bfe96e8b8a76faaec70afb1b3..83d76e75d2a3757f0e8fe557d87360a30728cd6b 100644 --- a/app/modules/places/infrastructure/semantic_place_ranker.py +++ b/app/modules/places/infrastructure/semantic_place_ranker.py @@ -10,11 +10,16 @@ from app.modules.places.infrastructure.place_semantic_document import ( class SemanticPlaceRanker(PlaceRanker): """Preserve the cosine-similarity order returned by PGVector.""" - engine_name = "fasttext_mean_embeddings" + engine_name = "semantic_embeddings" score_metric = "cosine_similarity" field_weights = PLACE_SEMANTIC_FIELD_WEIGHTS - def __init__(self, dimension: int = 300) -> None: + def __init__( + self, + dimension: int = 300, + model_name: str = "semantic_embeddings", + ) -> None: + self.engine_name = model_name self.ranking_parameters = {"dimension": float(dimension)} def rank( diff --git a/app/shared/config/settings.py b/app/shared/config/settings.py index 95c159ec0e88d21356d1d130727897876329e4df..4350016f05ef681daebde58bebab2745d121f702 100644 --- a/app/shared/config/settings.py +++ b/app/shared/config/settings.py @@ -167,6 +167,82 @@ class Settings(BaseSettings): alias="FASTTEXT_AUTO_DOWNLOAD", ) + # Places can migrate independently from posts, global search and feed + # embeddings. Defaults preserve the current FastText contract; enabling a + # Sentence-Transformer is an explicit, reversible deployment choice. + places_embedding_provider: str = Field( + default="fasttext", + alias="PLACES_EMBEDDING_PROVIDER", + ) + places_embedding_dimension: int = Field( + default=300, + gt=0, + alias="PLACES_EMBEDDING_DIMENSION", + ) + places_embedding_model: str = Field( + default="facebook/fasttext-es-vectors", + min_length=1, + alias="PLACES_EMBEDDING_MODEL", + ) + places_embedding_version: str = Field( + default="common-crawl-300-v1", + min_length=1, + alias="PLACES_EMBEDDING_VERSION", + ) + places_embedding_query_prefix: str = Field( + default="", + alias="PLACES_EMBEDDING_QUERY_PREFIX", + ) + places_embedding_passage_prefix: str = Field( + default="", + alias="PLACES_EMBEDDING_PASSAGE_PREFIX", + ) + places_embedding_batch_size: int = Field( + default=32, + ge=1, + le=512, + alias="PLACES_EMBEDDING_BATCH_SIZE", + ) + places_embedding_device: str | None = Field( + default=None, + alias="PLACES_EMBEDDING_DEVICE", + ) + places_category_catalog_path: str | None = Field( + default=None, + alias="PLACES_CATEGORY_CATALOG_PATH", + ) + places_category_min_similarity: float = Field( + default=0.44, + ge=-1.0, + le=1.0, + alias="PLACES_CATEGORY_MIN_SIMILARITY", + ) + places_category_min_margin: float = Field( + default=0.04, + ge=0.0, + le=2.0, + alias="PLACES_CATEGORY_MIN_MARGIN", + ) + places_pgvector_match_function: str = Field( + default="match_places", + min_length=1, + alias="PLACES_PGVECTOR_MATCH_FUNCTION", + ) + places_pgvector_hybrid_function: str | None = Field( + default=None, + alias="PLACES_PGVECTOR_HYBRID_FUNCTION", + ) + places_pgvector_upsert_function: str = Field( + default="upsert_place_embedding", + min_length=1, + alias="PLACES_PGVECTOR_UPSERT_FUNCTION", + ) + places_pgvector_hash_function: str = Field( + default="get_place_content_hashes", + min_length=1, + alias="PLACES_PGVECTOR_HASH_FUNCTION", + ) + bm25_k1: float = Field(default=1.5, gt=0, alias="BM25_K1") bm25_b: float = Field(default=0.75, ge=0, le=1, alias="BM25_B") bm25_relevance_threshold: float = Field( @@ -218,6 +294,24 @@ class Settings(BaseSettings): le=1.0, alias="PLACES_CHAT_AMBIGUITY_DELTA", ) + places_chat_hypothesis_min_confidence: float = Field( + default=0.60, + ge=0.0, + le=1.0, + alias="PLACES_CHAT_HYPOTHESIS_MIN_CONFIDENCE", + ) + places_chat_hypothesis_max_gap: float = Field( + default=0.15, + ge=0.0, + le=1.0, + alias="PLACES_CHAT_HYPOTHESIS_MAX_GAP", + ) + places_chat_default_radius_meters: int = Field( + default=5_000, + ge=1, + le=50_000, + alias="PLACES_CHAT_DEFAULT_RADIUS_METERS", + ) places_chat_ranking_version: str = Field( default="places-chat-v2", min_length=1, @@ -230,6 +324,31 @@ class Settings(BaseSettings): max_length=64, alias="PLACES_CHAT_TAXONOMY_VERSION", ) + # Contextual intent extraction is opt-in. ``disabled`` is accepted as an + # operational alias for the deterministic-only path so deployments can + # explicitly turn the optional model off without changing code. + places_chat_intent_provider: str = Field( + default="deterministic", + alias="PLACES_CHAT_INTENT_PROVIDER", + ) + places_chat_bert_model_path: str | None = Field( + default=None, + alias="PLACES_CHAT_BERT_MODEL_PATH", + ) + places_chat_bert_model_version: str | None = Field( + default=None, + alias="PLACES_CHAT_BERT_MODEL_VERSION", + ) + places_chat_bert_device: str | None = Field( + default=None, + alias="PLACES_CHAT_BERT_DEVICE", + ) + places_chat_bert_min_token_confidence: float = Field( + default=0.60, + ge=0.0, + le=1.0, + alias="PLACES_CHAT_BERT_MIN_TOKEN_CONFIDENCE", + ) log_level: str = Field(default="INFO", alias="LOG_LEVEL") request_timeout_seconds: int = Field( @@ -291,6 +410,61 @@ class Settings(BaseSettings): @model_validator(mode="after") def validate_post_feed_security(self) -> "Settings": self.vector_store_provider = self.vector_store_provider.strip().lower() + self.places_embedding_provider = self.places_embedding_provider.strip().lower() + if self.places_embedding_device is not None: + self.places_embedding_device = ( + self.places_embedding_device.strip() or None + ) + if self.places_category_catalog_path is not None: + self.places_category_catalog_path = ( + self.places_category_catalog_path.strip() or None + ) + if self.places_pgvector_hybrid_function is not None: + self.places_pgvector_hybrid_function = ( + self.places_pgvector_hybrid_function.strip() or None + ) + if self.places_embedding_provider not in { + "fasttext", + "mock", + "sentence_transformer", + "bert", + }: + raise ValueError( + "PLACES_EMBEDDING_PROVIDER debe ser fasttext, mock, " + "sentence_transformer o bert" + ) + self.places_chat_intent_provider = ( + self.places_chat_intent_provider.strip().lower() + ) + if self.places_chat_intent_provider not in { + "disabled", + "deterministic", + "bert", + }: + raise ValueError( + "PLACES_CHAT_INTENT_PROVIDER debe ser disabled, " + "deterministic o bert" + ) + if self.places_chat_bert_model_path is not None: + self.places_chat_bert_model_path = ( + self.places_chat_bert_model_path.strip() or None + ) + if self.places_chat_bert_model_version is not None: + self.places_chat_bert_model_version = ( + self.places_chat_bert_model_version.strip() or None + ) + if self.places_chat_bert_device is not None: + self.places_chat_bert_device = ( + self.places_chat_bert_device.strip() or None + ) + if ( + self.places_chat_intent_provider == "bert" + and self.places_chat_bert_model_path is None + ): + raise ValueError( + "PLACES_CHAT_BERT_MODEL_PATH es obligatorio cuando " + "PLACES_CHAT_INTENT_PROVIDER=bert" + ) allowed_resources = {"places", "posts", "users", "clubs", "groups", "events"} for resource_type, thresholds in self.global_search_resource_thresholds.items(): if resource_type not in allowed_resources: diff --git a/app/shared/dependencies.py b/app/shared/dependencies.py index bfd8fde5b5d6c6ea295986aaa2c6f38c7732ffe6..7dde40de265e9a4cb5a1b7f446b5481d2da75726 100644 --- a/app/shared/dependencies.py +++ b/app/shared/dependencies.py @@ -4,7 +4,10 @@ from app.shared.cache.memory import SimpleTTLCache from app.shared.config.settings import get_settings from app.shared.nlp.embeddings.base import EmbeddingProvider from app.shared.nlp.embeddings.cached import CachedEmbeddingProvider -from app.shared.nlp.embeddings.factory import create_embedding_provider +from app.shared.nlp.embeddings.factory import ( + create_embedding_provider, + create_place_embedding_provider, +) from app.shared.nlp.llm.base import LLMProvider from app.shared.nlp.llm.groq_llama import GroqLlamaProvider from app.shared.nlp.llm.mock import MockLLMProvider @@ -19,6 +22,32 @@ def get_embedding_provider() -> EmbeddingProvider: ) +@lru_cache +def get_place_embedding_provider() -> EmbeddingProvider: + """Return the query encoder dedicated to Places. + + It intentionally has a separate cache and configuration so a 768-dimensional + BERT rollout cannot invalidate or break the global 300-dimensional indexes. + """ + + settings = get_settings() + return CachedEmbeddingProvider( + provider=create_place_embedding_provider(settings, text_role="query"), + cache=SimpleTTLCache(default_ttl_seconds=settings.embedding_cache_ttl_seconds), + ) + + +@lru_cache +def get_place_passage_embedding_provider() -> EmbeddingProvider: + """Return the Places passage encoder (for E5-style asymmetric models).""" + + settings = get_settings() + return CachedEmbeddingProvider( + provider=create_place_embedding_provider(settings, text_role="passage"), + cache=SimpleTTLCache(default_ttl_seconds=settings.embedding_cache_ttl_seconds), + ) + + @lru_cache def get_llm_provider() -> LLMProvider: settings = get_settings() diff --git a/app/shared/nlp/embeddings/cached.py b/app/shared/nlp/embeddings/cached.py index 7e3e5458fbf27020bf42a04749624b286d20ac47..a147372774824607ca21a1b071170ae4002729a4 100644 --- a/app/shared/nlp/embeddings/cached.py +++ b/app/shared/nlp/embeddings/cached.py @@ -22,4 +22,32 @@ class CachedEmbeddingProvider(EmbeddingProvider): return embedding def embed_batch(self, texts: list[str]) -> list[list[float]]: - return [self.embed_text(text) for text in texts] + if not texts: + return [] + + results: list[list[float] | None] = [None] * len(texts) + missing_positions: dict[str, list[int]] = {} + for index, text in enumerate(texts): + cache_key = f"embedding:{text}" + cached = self._cache.get(cache_key) + if cached is not None: + results[index] = cached + else: + missing_positions.setdefault(text, []).append(index) + + missing_texts = list(missing_positions) + if missing_texts: + generated = self._provider.embed_batch(missing_texts) + if len(generated) != len(missing_texts): + raise ValueError( + "Embedding provider returned an unexpected batch size: " + f"returned={len(generated)}, expected={len(missing_texts)}" + ) + for text, embedding in zip(missing_texts, generated): + self._cache.set(f"embedding:{text}", embedding) + for index in missing_positions[text]: + results[index] = embedding + + if any(result is None for result in results): + raise RuntimeError("Embedding batch cache left unresolved positions") + return [result for result in results if result is not None] diff --git a/app/shared/nlp/embeddings/factory.py b/app/shared/nlp/embeddings/factory.py index 9489d9d2b794a6a583a45d8271b90754778ea765..907c1e47e8c78f0a28ff8f0c6abaf751f7da5d64 100644 --- a/app/shared/nlp/embeddings/factory.py +++ b/app/shared/nlp/embeddings/factory.py @@ -2,6 +2,9 @@ from app.shared.config.settings import Settings from app.shared.nlp.embeddings.base import EmbeddingProvider from app.shared.nlp.embeddings.fasttext import FastTextEmbeddingProvider from app.shared.nlp.embeddings.mock import MockEmbeddingProvider +from app.shared.nlp.embeddings.sentence_transformer import ( + SentenceTransformerEmbeddingProvider, +) def create_embedding_provider(settings: Settings) -> EmbeddingProvider: @@ -17,3 +20,50 @@ def create_embedding_provider(settings: Settings) -> EmbeddingProvider: if provider == "mock": return MockEmbeddingProvider(dimension=settings.embedding_dimension) raise ValueError(f"Unsupported EMBEDDING_PROVIDER: {settings.embedding_provider}") + + +def create_place_embedding_provider( + settings: Settings, + *, + text_role: str = "query", +) -> EmbeddingProvider: + """Create the Places-only embedding provider. + + Keeping this separate from ``create_embedding_provider`` prevents a Places + migration from changing post, feed or global-search vector dimensions. + ``text_role`` selects the query/passage prefix required by retrieval models + such as E5 while sharing the same model weights. + """ + + if text_role not in {"query", "passage"}: + raise ValueError("text_role must be 'query' or 'passage'") + + provider = settings.places_embedding_provider.casefold() + if provider == "fasttext": + return FastTextEmbeddingProvider( + model_path=settings.fasttext_model_path, + expected_dimension=settings.places_embedding_dimension, + repo_id=settings.fasttext_model_repo_id, + filename=settings.fasttext_model_filename, + auto_download=settings.fasttext_auto_download, + ) + if provider == "mock": + return MockEmbeddingProvider(dimension=settings.places_embedding_dimension) + if provider in {"sentence_transformer", "bert"}: + prefix = ( + settings.places_embedding_query_prefix + if text_role == "query" + else settings.places_embedding_passage_prefix + ) + return SentenceTransformerEmbeddingProvider( + model_name_or_path=settings.places_embedding_model, + expected_dimension=settings.places_embedding_dimension, + batch_size=settings.places_embedding_batch_size, + device=settings.places_embedding_device, + text_prefix=prefix, + normalize_embeddings=True, + ) + raise ValueError( + "Unsupported PLACES_EMBEDDING_PROVIDER: " + f"{settings.places_embedding_provider}" + ) diff --git a/app/shared/nlp/embeddings/sentence_transformer.py b/app/shared/nlp/embeddings/sentence_transformer.py new file mode 100644 index 0000000000000000000000000000000000000000..0f4790091ac99b28a3fc0387ca24804c52efca52 --- /dev/null +++ b/app/shared/nlp/embeddings/sentence_transformer.py @@ -0,0 +1,288 @@ +"""Lazy Sentence-Transformer embeddings with strict output validation. + +The optional ``sentence-transformers`` dependency is intentionally imported only +when the first non-empty text is embedded. This keeps application startup and +test discovery independent from heavyweight model loading. +""" + +from __future__ import annotations + +import math +import threading +from collections.abc import Callable, Sequence +from typing import Any, Protocol + +from app.shared.nlp.embeddings.base import EmbeddingProvider + + +class SentenceTransformerModel(Protocol): + """Small portion of the SentenceTransformer API used by this provider.""" + + def encode(self, sentences: Sequence[str], **kwargs: Any) -> Any: ... + + def get_sentence_embedding_dimension(self) -> int | None: ... + + +ModelLoader = Callable[[str, str | None], SentenceTransformerModel] + + +_SHARED_MODELS: dict[tuple[str, str | None], SentenceTransformerModel] = {} +_SHARED_MODELS_LOCK = threading.Lock() +_MODEL_INFERENCE_LOCKS: dict[int, threading.Lock] = {} + + +class SentenceTransformerEmbeddingError(RuntimeError): + """Base error raised when a sentence embedding cannot be produced safely.""" + + +class SentenceTransformerModelLoadError(SentenceTransformerEmbeddingError): + """Raised when the configured model or its runtime cannot be loaded.""" + + +class SentenceTransformerInferenceError(SentenceTransformerEmbeddingError): + """Raised when model inference fails.""" + + +class SentenceTransformerDimensionError(ValueError): + """Raised when a model returns a vector with an unexpected dimension.""" + + +class SentenceTransformerEmbeddingProvider(EmbeddingProvider): + """Batch Sentence-Transformer provider with lazy, thread-safe model loading. + + ``text_prefix`` supports retrieval models such as E5, whose query and + passage encoders use the same weights but require different input prefixes. + Configure separate provider instances for queries and documents when those + prefixes differ. + """ + + def __init__( + self, + model_name_or_path: str, + expected_dimension: int, + *, + batch_size: int = 32, + device: str | None = None, + text_prefix: str = "", + normalize_embeddings: bool = True, + model_loader: ModelLoader | None = None, + ) -> None: + if not model_name_or_path.strip(): + raise ValueError("model_name_or_path must not be empty") + if expected_dimension <= 0: + raise ValueError("expected_dimension must be greater than zero") + if batch_size <= 0: + raise ValueError("batch_size must be greater than zero") + + self.model_name_or_path = model_name_or_path + self.dimension = int(expected_dimension) + self.batch_size = int(batch_size) + self.device = device + self.text_prefix = text_prefix + self.normalize_embeddings = normalize_embeddings + + self._model_loader = model_loader or _load_sentence_transformer + self._model: SentenceTransformerModel | None = None + self._model_lock = threading.Lock() + + @property + def is_loaded(self) -> bool: + """Whether the heavyweight model has already been initialized.""" + + return self._model is not None + + def embed_text(self, text: str) -> list[float]: + return self.embed_batch([text])[0] + + def embed_batch(self, texts: list[str]) -> list[list[float]]: + if not texts: + return [] + + prepared: list[str] = [] + positions: list[int] = [] + for index, text in enumerate(texts): + if not isinstance(text, str): + raise TypeError( + f"texts[{index}] must be str, got {type(text).__name__}" + ) + stripped = text.strip() + if stripped: + positions.append(index) + prepared.append(f"{self.text_prefix}{stripped}") + + embeddings = [[0.0] * self.dimension for _ in texts] + if not prepared: + return embeddings + + model = self._get_model() + try: + with _inference_lock_for(model): + raw_embeddings = model.encode( + prepared, + batch_size=self.batch_size, + convert_to_numpy=True, + normalize_embeddings=False, + show_progress_bar=False, + ) + except Exception as exc: + raise SentenceTransformerInferenceError( + "Sentence-Transformer inference failed for " + f"model {self.model_name_or_path!r}: {exc}" + ) from exc + + vectors = self._validate_and_convert(raw_embeddings, len(prepared)) + for position, vector in zip(positions, vectors): + embeddings[position] = vector + return embeddings + + def _get_model(self) -> SentenceTransformerModel: + model = self._model + if model is not None: + return model + + with self._model_lock: + model = self._model + if model is not None: + return model + try: + model = self._model_loader(self.model_name_or_path, self.device) + except SentenceTransformerEmbeddingError: + raise + except Exception as exc: + raise SentenceTransformerModelLoadError( + "Could not load Sentence-Transformer model " + f"{self.model_name_or_path!r}: {exc}" + ) from exc + + self._validate_model_dimension(model) + self._model = model + return model + + def _validate_model_dimension(self, model: SentenceTransformerModel) -> None: + dimension_getter = getattr( + model, "get_sentence_embedding_dimension", None + ) + if not callable(dimension_getter): + return + try: + reported_dimension = dimension_getter() + except Exception as exc: + raise SentenceTransformerModelLoadError( + "Could not inspect the embedding dimension for " + f"model {self.model_name_or_path!r}: {exc}" + ) from exc + if reported_dimension is None: + return + if int(reported_dimension) != self.dimension: + raise SentenceTransformerDimensionError( + "Sentence-Transformer model dimension does not match the " + "configured embedding dimension: " + f"model={reported_dimension}, configured={self.dimension}, " + f"name={self.model_name_or_path!r}" + ) + + def _validate_and_convert( + self, + raw_embeddings: Any, + expected_count: int, + ) -> list[list[float]]: + converted = ( + raw_embeddings.tolist() + if hasattr(raw_embeddings, "tolist") + else raw_embeddings + ) + try: + rows = list(converted) + except (TypeError, ValueError) as exc: + raise SentenceTransformerInferenceError( + "Sentence-Transformer returned a non-iterable embedding result" + ) from exc + + # Some compatible runtimes collapse a one-item batch to one dimension. + if expected_count == 1 and rows and _is_scalar(rows[0]): + rows = [rows] + if len(rows) != expected_count: + raise SentenceTransformerInferenceError( + "Sentence-Transformer returned an unexpected number of vectors: " + f"returned={len(rows)}, expected={expected_count}" + ) + + vectors: list[list[float]] = [] + for row_index, row in enumerate(rows): + values_source = row.tolist() if hasattr(row, "tolist") else row + try: + values = [float(value) for value in values_source] + except (TypeError, ValueError) as exc: + raise SentenceTransformerInferenceError( + "Sentence-Transformer returned a non-numeric vector at " + f"batch index {row_index}" + ) from exc + if len(values) != self.dimension: + raise SentenceTransformerDimensionError( + "Sentence-Transformer returned an unexpected vector " + f"dimension at batch index {row_index}: " + f"returned={len(values)}, expected={self.dimension}" + ) + if not all(math.isfinite(value) for value in values): + raise SentenceTransformerInferenceError( + "Sentence-Transformer returned NaN or infinity at " + f"batch index {row_index}" + ) + if self.normalize_embeddings: + values = _l2_normalize_nonzero(values, row_index) + vectors.append(values) + return vectors + + +def _load_sentence_transformer( + model_name_or_path: str, + device: str | None, +) -> SentenceTransformerModel: + cache_key = (model_name_or_path, device) + cached = _SHARED_MODELS.get(cache_key) + if cached is not None: + return cached + with _SHARED_MODELS_LOCK: + cached = _SHARED_MODELS.get(cache_key) + if cached is not None: + return cached + try: + from sentence_transformers import SentenceTransformer + except ImportError as exc: + raise SentenceTransformerModelLoadError( + "sentence-transformers is required for BERT embeddings. " + "Install the project's optional Sentence-Transformer dependencies." + ) from exc + + try: + model = SentenceTransformer(model_name_or_path, device=device) + except Exception as exc: + raise SentenceTransformerModelLoadError( + "Could not initialize Sentence-Transformer model " + f"{model_name_or_path!r}: {exc}" + ) from exc + _SHARED_MODELS[cache_key] = model + return model + + +def _is_scalar(value: Any) -> bool: + return isinstance(value, (int, float, complex)) + + +def _inference_lock_for(model: SentenceTransformerModel) -> threading.Lock: + model_id = id(model) + with _SHARED_MODELS_LOCK: + return _MODEL_INFERENCE_LOCKS.setdefault(model_id, threading.Lock()) + + +def _l2_normalize_nonzero( + vector: list[float], + row_index: int, +) -> list[float]: + norm = math.sqrt(sum(value * value for value in vector)) + if norm == 0: + raise SentenceTransformerInferenceError( + "Sentence-Transformer returned a zero-norm vector for non-empty " + f"text at batch index {row_index}" + ) + return [value / norm for value in vector] diff --git a/app/shared/vector_store/aws_pgvector.py b/app/shared/vector_store/aws_pgvector.py index 2d9824da92d3f3aec50380a34589de5e899b4a03..151455f7a57676c2744a788422e0de346a3ae8c9 100644 --- a/app/shared/vector_store/aws_pgvector.py +++ b/app/shared/vector_store/aws_pgvector.py @@ -53,14 +53,48 @@ class AwsPgvectorClient: embedding: list[float], filters: dict[str, Any], limit: int, + function_name: str = "match_places", ) -> list[VectorMatch]: return await self._match( - function_name="match_places", + function_name=function_name, embedding=embedding, filters=filters, limit=limit, ) + async def search_places_hybrid( + self, + query_text: str, + embedding: list[float], + filters: dict[str, Any], + limit: int, + function_name: str, + ) -> list[VectorMatch]: + """Search a versioned Places index using independent dense and lexical pools.""" + + query = ( + f"SELECT * FROM {quote_identifier(function_name)}(" + "$1::text, $2::vector, $3::integer, $4::jsonb)" + ) + try: + async with self.connection() as connection: + rows = await connection.fetch( + query, + query_text, + vector_literal(embedding), + limit, + json.dumps(filters, ensure_ascii=False), + ) + except asyncpg.exceptions.UndefinedFunctionError as exc: + raise AppError( + "Places hybrid SQL contract is missing. Run the versioned " + "Places semantic embedding migration and grant EXECUTE to the " + "reader role.", + code="pgvector_places_hybrid_contract_missing", + status_code=503, + ) from exc + return [_row_to_vector_match(row) for row in rows] + async def match_posts( self, embedding: list[float], @@ -108,6 +142,7 @@ class AwsPgvectorClient: async def check_read_contract(self) -> dict[str, Any]: """Check that read-only pgvector functions are visible and executable.""" + signatures = _configured_read_contract_signatures(self._settings) try: async with self.connection() as connection: vector_row = await connection.fetchrow( @@ -120,10 +155,10 @@ class AwsPgvectorClient: "exists": False, "executable": False, } - for function_name, signature in READ_CONTRACT_SIGNATURES.items() + for function_name, signature in signatures.items() } if vector_available: - for function_name, signature in READ_CONTRACT_SIGNATURES.items(): + for function_name, signature in signatures.items(): row = await connection.fetchrow( """ SELECT @@ -169,9 +204,13 @@ class AwsPgvectorClient: "functions": functions, } - async def fetch_place_content_hashes(self, ids: Iterable[str]) -> dict[str, str]: + async def fetch_place_content_hashes( + self, + ids: Iterable[str], + function_name: str = "get_place_content_hashes", + ) -> dict[str, str]: return await self._fetch_content_hashes( - function_name="get_place_content_hashes", + function_name=function_name, ids=ids, ) @@ -205,10 +244,15 @@ class AwsPgvectorClient: async def upsert_place_embeddings( self, records: list[VectorUpsertRecord], + function_name: str = "upsert_place_embedding", + embedding_model: str | None = None, + embedding_version: str | None = None, ) -> None: await self._upsert_records( - function_name="upsert_place_embedding", + function_name=function_name, records=records, + embedding_model=embedding_model, + embedding_version=embedding_version, ) async def upsert_post_embeddings( @@ -323,6 +367,8 @@ class AwsPgvectorClient: self, function_name: str, records: list[VectorUpsertRecord], + embedding_model: str | None = None, + embedding_version: str | None = None, ) -> None: if not records: return @@ -346,8 +392,8 @@ class AwsPgvectorClient: json.dumps(record.metadata, ensure_ascii=False), vector_literal(record.embedding), record.content_hash, - self._settings.embedding_model, - self._settings.embedding_version, + embedding_model or self._settings.embedding_model, + embedding_version or self._settings.embedding_version, record.is_active, ) for record in records @@ -425,6 +471,18 @@ def _read_contract_is_ready( ) +def _configured_read_contract_signatures( + settings: Settings, +) -> dict[str, str]: + signatures = dict(READ_CONTRACT_SIGNATURES) + match_name = settings.places_pgvector_match_function + signatures[match_name] = f"{match_name}(vector, integer, jsonb)" + hybrid_name = settings.places_pgvector_hybrid_function + if hybrid_name: + signatures[hybrid_name] = f"{hybrid_name}(text, vector, integer, jsonb)" + return signatures + + def _credentials_for_role(settings: Settings, role: str) -> tuple[str | None, str | None]: if role == "writer": return ( diff --git a/requirements-training.txt b/requirements-training.txt new file mode 100644 index 0000000000000000000000000000000000000000..7e111ef06c5311192d0e32854181c3faf2a1c341 --- /dev/null +++ b/requirements-training.txt @@ -0,0 +1,3 @@ +-r requirements.txt +accelerate>=0.33,<2.0 +torch>=2.1,<3.0 diff --git a/requirements.txt b/requirements.txt index 420f94198f63543926b6d708c084934786ebd5f3..e31ef2e0215ae2cfe04d16eeb4782952bdf8c905 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,5 +10,7 @@ huggingface-hub>=0.34,<2.0 numpy>=1.26,<2.0 scipy>=1.10,<1.15 scikit-learn>=1.5,<1.7 +sentence-transformers>=3.0,<6.0 +transformers>=4.44,<5.0 pytest>=8.0,<9.0 pytest-asyncio>=0.23,<1.0 diff --git a/scripts/train_place_intent_bert.py b/scripts/train_place_intent_bert.py new file mode 100644 index 0000000000000000000000000000000000000000..18839a1701edcb7541589a10e93c6d97bbff37b8 --- /dev/null +++ b/scripts/train_place_intent_bert.py @@ -0,0 +1,501 @@ +"""Fine-tune a Hugging Face token classifier for open place-chat slots. + +The input files are JSONL. Each non-empty line has this shape:: + + { + "text": "Quiero donas artesanales sin ruido", + "spans": [ + {"start": 7, "end": 25, "slot": "CATEGORY"}, + {"start": 30, "end": 35, "slot": "EXCLUSION"} + ] + } + +Offsets use Python's half-open character convention: ``text[start:end]``. +Only domain-independent slot types are accepted. Category values remain raw +text, so this training path does not recreate a closed business taxonomy. + +Imports for Transformers and PyTorch are deliberately lazy. Importing this +module, validating data, and running ``--help`` do not require the optional ML +runtime. +""" + +from __future__ import annotations + +import argparse +import inspect +import json +import math +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping, Sequence + + +SLOT_TYPES: tuple[str, ...] = ( + "CATEGORY", + "PREFERENCE", + "EXCLUSION", + "LOCATION", + "REFERENCE", + "RADIUS", +) +LABELS: tuple[str, ...] = ( + "O", + *(label for slot in SLOT_TYPES for label in (f"B-{slot}", f"I-{slot}")), +) +LABEL_TO_ID: Mapping[str, int] = {label: index for index, label in enumerate(LABELS)} +ID_TO_LABEL: Mapping[int, str] = {index: label for label, index in LABEL_TO_ID.items()} +IGNORED_LABEL_ID = -100 + + +@dataclass(frozen=True) +class LabeledSpan: + """One validated, open-value slot annotation.""" + + start: int + end: int + slot: str + + +@dataclass(frozen=True) +class IntentTrainingExample: + """One validated token-classification example.""" + + text: str + spans: tuple[LabeledSpan, ...] + + +def validate_training_example( + payload: Any, + *, + context: str = "example", +) -> IntentTrainingExample: + """Validate one JSON-compatible example and normalize slot casing. + + Spans must be disjoint. This is stricter than silently selecting one label + for an overlapping token and makes annotation errors fail before training. + """ + + if not isinstance(payload, Mapping): + raise ValueError(f"{context}: expected a JSON object") + + text = payload.get("text") + if not isinstance(text, str) or not text.strip(): + raise ValueError(f"{context}: text must be a non-empty string") + + raw_spans = payload.get("spans") + if not isinstance(raw_spans, list): + raise ValueError(f"{context}: spans must be a list") + + spans: list[LabeledSpan] = [] + for index, raw_span in enumerate(raw_spans): + span_context = f"{context}, span {index}" + if not isinstance(raw_span, Mapping): + raise ValueError(f"{span_context}: expected a JSON object") + + start = _integer_offset(raw_span.get("start"), "start", span_context) + end = _integer_offset(raw_span.get("end"), "end", span_context) + if start < 0 or end <= start or end > len(text): + raise ValueError( + f"{span_context}: offsets must satisfy " + f"0 <= start < end <= {len(text)}; got start={start}, end={end}" + ) + if not text[start:end].strip(): + raise ValueError(f"{span_context}: annotated text cannot be blank") + + raw_slot = raw_span.get("slot", raw_span.get("label")) + if not isinstance(raw_slot, str) or not raw_slot.strip(): + raise ValueError(f"{span_context}: slot must be a non-empty string") + slot = raw_slot.strip().upper() + if slot not in SLOT_TYPES: + raise ValueError( + f"{span_context}: unsupported slot {raw_slot!r}; expected one of " + + ", ".join(SLOT_TYPES) + ) + spans.append(LabeledSpan(start=start, end=end, slot=slot)) + + spans.sort(key=lambda span: (span.start, span.end, span.slot)) + for previous, current in zip(spans, spans[1:]): + if current.start < previous.end: + raise ValueError( + f"{context}: overlapping spans " + f"[{previous.start}, {previous.end}) and " + f"[{current.start}, {current.end})" + ) + + return IntentTrainingExample(text=text, spans=tuple(spans)) + + +def read_jsonl(path: Path) -> list[IntentTrainingExample]: + """Read and validate a UTF-8 JSONL dataset with contextual errors.""" + + if not path.is_file(): + raise FileNotFoundError(f"Dataset not found: {path}") + + examples: list[IntentTrainingExample] = [] + with path.open("r", encoding="utf-8") as source: + for line_number, raw_line in enumerate(source, start=1): + if not raw_line.strip(): + continue + try: + payload = json.loads(raw_line) + except json.JSONDecodeError as exc: + raise ValueError( + f"{path}, line {line_number}: invalid JSON: {exc.msg}" + ) from exc + examples.append( + validate_training_example( + payload, + context=f"{path}, line {line_number}", + ) + ) + + if not examples: + raise ValueError(f"Dataset has no examples: {path}") + return examples + + +def align_spans_to_token_offsets( + text: str, + spans: Sequence[LabeledSpan], + token_offsets: Sequence[Sequence[int]], + *, + label_to_id: Mapping[str, int] = LABEL_TO_ID, +) -> list[int]: + """Align character spans to tokenizer offsets using IOB labels. + + Any token with a non-empty intersection with a span receives that slot. + This handles subword tokenizers while preserving character-level source + annotations. Special/padding tokens, represented by ``(0, 0)``, receive + the standard ``-100`` ignore label. + + Every annotated span must be covered by at least one token. Consequently, + truncation cannot silently turn a positive span into ``O``. + """ + + if not isinstance(text, str): + raise TypeError("text must be str") + + _validate_label_mapping(label_to_id) + normalized_spans = tuple(spans) + _validate_span_objects(text, normalized_spans) + + seen_span_indexes: set[int] = set() + labels: list[int] = [] + previous_token_start = -1 + for token_index, raw_offset in enumerate(token_offsets): + token_start, token_end = _token_offset(raw_offset, token_index, len(text)) + if token_start == token_end == 0: + labels.append(IGNORED_LABEL_ID) + continue + if token_start < previous_token_start: + raise ValueError("token offsets must be ordered by start position") + previous_token_start = token_start + + matching = [ + span_index + for span_index, span in enumerate(normalized_spans) + if token_start < span.end and span.start < token_end + ] + if len(matching) > 1: + raise ValueError( + f"token {token_index} [{token_start}, {token_end}) intersects " + "multiple annotated spans" + ) + if not matching: + labels.append(label_to_id["O"]) + continue + + span_index = matching[0] + span = normalized_spans[span_index] + prefix = "I" if span_index in seen_span_indexes else "B" + labels.append(label_to_id[f"{prefix}-{span.slot}"]) + seen_span_indexes.add(span_index) + + missing = [ + span + for span_index, span in enumerate(normalized_spans) + if span_index not in seen_span_indexes + ] + if missing: + details = ", ".join( + f"{span.slot}[{span.start}, {span.end})" for span in missing + ) + raise ValueError( + "annotated spans were not covered by tokenizer offsets " + f"(possibly truncated): {details}" + ) + return labels + + +def encode_examples( + examples: Sequence[IntentTrainingExample], + tokenizer: Any, + *, + max_length: int, +) -> list[dict[str, Any]]: + """Tokenize and align examples without depending on a dataset library.""" + + if isinstance(max_length, bool) or not isinstance(max_length, int) or max_length <= 0: + raise ValueError("max_length must be a positive integer") + + encoded_examples: list[dict[str, Any]] = [] + for example_index, example in enumerate(examples): + encoding = tokenizer( + example.text, + truncation=True, + max_length=max_length, + return_offsets_mapping=True, + ) + if not isinstance(encoding, Mapping): + raise TypeError( + f"tokenizer output for example {example_index} must be a mapping" + ) + if "offset_mapping" not in encoding: + raise ValueError( + "tokenizer did not return offset_mapping; a fast tokenizer is required" + ) + + offsets = encoding["offset_mapping"] + if not isinstance(offsets, Sequence) or isinstance(offsets, (str, bytes)): + raise ValueError( + f"tokenizer offset_mapping for example {example_index} " + "must be a sequence" + ) + feature = { + key: value for key, value in encoding.items() if key != "offset_mapping" + } + feature["labels"] = align_spans_to_token_offsets( + example.text, + example.spans, + offsets, + ) + input_ids = feature.get("input_ids") + if not isinstance(input_ids, Sequence) or isinstance(input_ids, (str, bytes)): + raise ValueError( + f"tokenizer output for example {example_index} has no input_ids sequence" + ) + if len(input_ids) != len(feature["labels"]): + raise ValueError( + f"tokenizer output for example {example_index} has mismatched " + "input_ids and offset_mapping lengths" + ) + encoded_examples.append(feature) + return encoded_examples + + +def main(argv: Sequence[str] | None = None) -> None: + args = build_parser().parse_args(argv) + _run_training(args) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--train-file", required=True, help="Training JSONL path") + parser.add_argument( + "--validation-file", + help="Optional validation JSONL path", + ) + parser.add_argument("--output-dir", required=True) + parser.add_argument( + "--base-model", + default="dccuchile/bert-base-spanish-wwm-cased", + help="Hugging Face model id or local model directory", + ) + parser.add_argument("--epochs", type=_positive_float, default=3.0) + parser.add_argument("--batch-size", type=_positive_int, default=16) + parser.add_argument("--learning-rate", type=_positive_float, default=2e-5) + parser.add_argument("--max-length", type=_positive_int, default=256) + parser.add_argument("--seed", type=int, default=42) + return parser + + +def _run_training(args: argparse.Namespace) -> None: + # Optional heavyweight imports stay behind argument parsing so ``--help`` + # remains available in API and CI environments without the ML toolchain. + try: + import transformers + from transformers import ( + AutoModelForTokenClassification, + AutoTokenizer, + DataCollatorForTokenClassification, + Trainer, + TrainingArguments, + set_seed, + ) + except ImportError as exc: + raise RuntimeError( + "Training requires transformers with its PyTorch backend. " + "Install the optional ML dependencies before running this script." + ) from exc + + train_path = Path(args.train_file) + validation_path = Path(args.validation_file) if args.validation_file else None + train_examples = read_jsonl(train_path) + validation_examples = read_jsonl(validation_path) if validation_path else [] + + set_seed(args.seed) + tokenizer = AutoTokenizer.from_pretrained(args.base_model, use_fast=True) + if not getattr(tokenizer, "is_fast", False): + raise RuntimeError( + "The selected model does not provide a fast tokenizer with offsets" + ) + + encoded_train = encode_examples( + train_examples, + tokenizer, + max_length=args.max_length, + ) + encoded_validation = encode_examples( + validation_examples, + tokenizer, + max_length=args.max_length, + ) + + model = AutoModelForTokenClassification.from_pretrained( + args.base_model, + num_labels=len(LABELS), + label2id=dict(LABEL_TO_ID), + id2label=dict(ID_TO_LABEL), + ignore_mismatched_sizes=True, + ) + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + has_validation = bool(encoded_validation) + strategy = "epoch" if has_validation else "no" + training_kwargs: dict[str, Any] = { + "output_dir": str(output_dir), + "num_train_epochs": args.epochs, + "per_device_train_batch_size": args.batch_size, + "per_device_eval_batch_size": args.batch_size, + "learning_rate": args.learning_rate, + "seed": args.seed, + "data_seed": args.seed, + "save_strategy": "epoch", + "logging_strategy": "steps", + "logging_steps": 25, + "report_to": [], + "load_best_model_at_end": has_validation, + } + parameter_names = inspect.signature(TrainingArguments.__init__).parameters + strategy_parameter = ( + "eval_strategy" if "eval_strategy" in parameter_names else "evaluation_strategy" + ) + training_kwargs[strategy_parameter] = strategy + + trainer = Trainer( + model=model, + args=TrainingArguments(**training_kwargs), + train_dataset=encoded_train, + eval_dataset=encoded_validation if has_validation else None, + data_collator=DataCollatorForTokenClassification(tokenizer=tokenizer), + tokenizer=tokenizer, + ) + train_result = trainer.train() + trainer.save_model(str(output_dir)) + tokenizer.save_pretrained(str(output_dir)) + + manifest = { + "schema_version": 1, + "task": "token-classification", + "architecture": "bert-open-place-intent", + "base_model": args.base_model, + "slots": list(SLOT_TYPES), + "labels": list(LABELS), + "label_to_id": dict(LABEL_TO_ID), + "training_examples": len(train_examples), + "validation_examples": len(validation_examples), + "epochs": args.epochs, + "batch_size": args.batch_size, + "learning_rate": args.learning_rate, + "max_length": args.max_length, + "seed": args.seed, + "train_loss": _finite_or_none(getattr(train_result, "training_loss", None)), + "transformers_version": getattr(transformers, "__version__", "unknown"), + "trained_at": datetime.now(timezone.utc).isoformat(), + } + (output_dir / "place_intent_training_manifest.json").write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + +def _integer_offset(value: Any, field: str, context: str) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{context}: {field} must be an integer") + return value + + +def _validate_span_objects(text: str, spans: Sequence[LabeledSpan]) -> None: + previous: LabeledSpan | None = None + for index, span in enumerate(spans): + if not isinstance(span, LabeledSpan): + raise TypeError(f"span {index} must be LabeledSpan") + if span.slot not in SLOT_TYPES: + raise ValueError(f"span {index} has unsupported slot {span.slot!r}") + if span.start < 0 or span.end <= span.start or span.end > len(text): + raise ValueError(f"span {index} has invalid offsets") + if previous is not None and span.start < previous.end: + raise ValueError("spans must be sorted and non-overlapping") + previous = span + + +def _validate_label_mapping(label_to_id: Mapping[str, int]) -> None: + missing = [label for label in LABELS if label not in label_to_id] + if missing: + raise ValueError("label_to_id is missing labels: " + ", ".join(missing)) + + +def _token_offset( + raw_offset: Sequence[int], + token_index: int, + text_length: int, +) -> tuple[int, int]: + if ( + isinstance(raw_offset, (str, bytes)) + or not isinstance(raw_offset, Sequence) + or len(raw_offset) != 2 + ): + raise ValueError(f"token offset {token_index} must contain start and end") + start, end = raw_offset + if ( + isinstance(start, bool) + or isinstance(end, bool) + or not isinstance(start, int) + or not isinstance(end, int) + ): + raise ValueError(f"token offset {token_index} must contain integers") + if start == end == 0: + return 0, 0 + if start < 0 or end <= start or end > text_length: + raise ValueError( + f"token offset {token_index} is invalid for text length {text_length}" + ) + return start, end + + +def _positive_int(value: str) -> int: + parsed = int(value) + if parsed <= 0: + raise argparse.ArgumentTypeError("must be greater than zero") + return parsed + + +def _positive_float(value: str) -> float: + parsed = float(value) + if not math.isfinite(parsed) or parsed <= 0: + raise argparse.ArgumentTypeError("must be a finite value greater than zero") + return parsed + + +def _finite_or_none(value: Any) -> float | None: + try: + parsed = float(value) + except (TypeError, ValueError): + return None + return parsed if math.isfinite(parsed) else None + + +if __name__ == "__main__": + main() diff --git a/scripts/train_place_retriever.py b/scripts/train_place_retriever.py new file mode 100644 index 0000000000000000000000000000000000000000..35b3d0990a822448a808d7734b985e4ba2b8296b --- /dev/null +++ b/scripts/train_place_retriever.py @@ -0,0 +1,147 @@ +"""Fine-tune a bi-encoder for open-vocabulary place retrieval. + +Input is JSONL with at least ``query`` and ``positive``. ``hard_negatives`` may +contain semantically close but incorrect place documents. The script keeps all +runtime imports lazy so the API can still run with FastText during rollout. +""" + +from __future__ import annotations + +import argparse +import json +import random +from pathlib import Path +from typing import Any + + +def main() -> None: + args = _parse_args() + random.seed(args.seed) + + try: + from sentence_transformers import InputExample, SentenceTransformer, losses + from torch.utils.data import DataLoader + except ImportError as exc: + raise RuntimeError( + "Install sentence-transformers and its PyTorch runtime before training" + ) from exc + + rows = _read_training_rows(Path(args.train_file)) + examples = [ + InputExample( + texts=[ + f"query: {row['query']}", + f"passage: {row['positive']}", + *( + f"passage: {negative}" + for negative in row["hard_negatives"][: args.max_hard_negatives] + ), + ] + ) + for row in rows + ] + random.shuffle(examples) + + model = SentenceTransformer(args.base_model, device=args.device) + data_loader = DataLoader( + examples, + shuffle=True, + batch_size=args.batch_size, + drop_last=len(examples) >= args.batch_size, + ) + cached_loss = getattr(losses, "CachedMultipleNegativesRankingLoss", None) + if cached_loss is not None: + train_loss = cached_loss(model, mini_batch_size=args.mini_batch_size) + else: + train_loss = losses.MultipleNegativesRankingLoss(model) + + output = Path(args.output_dir) + output.mkdir(parents=True, exist_ok=True) + warmup_steps = max(1, int(len(data_loader) * args.epochs * args.warmup_ratio)) + model.fit( + train_objectives=[(data_loader, train_loss)], + epochs=args.epochs, + warmup_steps=warmup_steps, + optimizer_params={"lr": args.learning_rate}, + output_path=str(output), + show_progress_bar=True, + ) + (output / "places_training_manifest.json").write_text( + json.dumps( + { + "base_model": args.base_model, + "training_examples": len(examples), + "epochs": args.epochs, + "batch_size": args.batch_size, + "learning_rate": args.learning_rate, + "query_prefix": "query: ", + "passage_prefix": "passage: ", + "objective": type(train_loss).__name__, + "seed": args.seed, + }, + indent=2, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + +def _read_training_rows(path: Path) -> list[dict[str, Any]]: + if not path.is_file(): + raise FileNotFoundError(f"Training dataset not found: {path}") + rows: list[dict[str, Any]] = [] + for line_number, raw_line in enumerate( + path.read_text(encoding="utf-8").splitlines(), + start=1, + ): + if not raw_line.strip(): + continue + payload = json.loads(raw_line) + query = _required_text(payload.get("query"), line_number, "query") + positive = _required_text(payload.get("positive"), line_number, "positive") + raw_negatives = payload.get("hard_negatives", []) + if isinstance(raw_negatives, str): + raw_negatives = [raw_negatives] + if not isinstance(raw_negatives, list): + raise ValueError( + f"Line {line_number}: hard_negatives must be a list of strings" + ) + negatives = [ + _required_text(value, line_number, "hard_negatives") + for value in raw_negatives + ] + rows.append( + {"query": query, "positive": positive, "hard_negatives": negatives} + ) + if len(rows) < 2: + raise ValueError("At least two training examples are required") + return rows + + +def _required_text(value: Any, line_number: int, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Line {line_number}: {field} must be a non-empty string") + return " ".join(value.split()) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--train-file", required=True) + parser.add_argument("--output-dir", required=True) + parser.add_argument( + "--base-model", + default="intfloat/multilingual-e5-base", + ) + parser.add_argument("--device", default=None) + parser.add_argument("--epochs", type=int, default=2) + parser.add_argument("--batch-size", type=int, default=16) + parser.add_argument("--mini-batch-size", type=int, default=8) + parser.add_argument("--max-hard-negatives", type=int, default=3) + parser.add_argument("--learning-rate", type=float, default=2e-5) + parser.add_argument("--warmup-ratio", type=float, default=0.1) + parser.add_argument("--seed", type=int, default=42) + return parser.parse_args() + + +if __name__ == "__main__": + main() diff --git a/sql/migrations/20260716_02_places_semantic_v1.sql b/sql/migrations/20260716_02_places_semantic_v1.sql new file mode 100644 index 0000000000000000000000000000000000000000..bf8643c2111ca703ca79e4ce71a7d0af72c805f3 --- /dev/null +++ b/sql/migrations/20260716_02_places_semantic_v1.sql @@ -0,0 +1,255 @@ +-- Additive Places-only Sentence-Transformer index (multilingual-e5-base: 768d). +-- This migration never alters or drops the existing FastText VECTOR(300) table. + +BEGIN; + +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE IF NOT EXISTS public.place_embeddings_semantic_v1 ( + external_id TEXT PRIMARY KEY, + document TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + embedding VECTOR(768) NOT NULL, + content_hash TEXT NOT NULL, + embedding_model TEXT NOT NULL, + embedding_version TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + textsearch TSVECTOR GENERATED ALWAYS AS ( + to_tsvector('simple', document) + ) STORED, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS place_embeddings_semantic_v1_embedding_hnsw_idx +ON public.place_embeddings_semantic_v1 +USING hnsw (embedding vector_cosine_ops); + +CREATE INDEX IF NOT EXISTS place_embeddings_semantic_v1_textsearch_gin_idx +ON public.place_embeddings_semantic_v1 USING gin (textsearch); + +CREATE INDEX IF NOT EXISTS place_embeddings_semantic_v1_metadata_gin_idx +ON public.place_embeddings_semantic_v1 USING gin (metadata); + +CREATE OR REPLACE FUNCTION public.match_places_semantic_v1( + query_embedding VECTOR(768), + match_count INTEGER, + filters JSONB DEFAULT '{}'::jsonb +) +RETURNS TABLE ( + external_id TEXT, + document TEXT, + metadata JSONB, + score DOUBLE PRECISION +) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT + place.external_id, + place.document, + place.metadata, + 1 - (place.embedding <=> query_embedding) AS score + FROM public.place_embeddings_semantic_v1 AS place + WHERE place.is_active = true + AND COALESCE((filters->>'is_active')::boolean, true) = true + AND ((filters ? 'city') IS FALSE OR lower(place.metadata->>'city') = lower(filters->>'city')) + AND ((filters ? 'state') IS FALSE OR lower(place.metadata->>'state') = lower(filters->>'state')) + AND ((filters ? 'category') IS FALSE OR lower(place.metadata->>'category') = lower(filters->>'category')) + AND ((filters ? 'price_range') IS FALSE OR place.metadata->>'price_range' = filters->>'price_range') + AND ((filters ? 'occasion') IS FALSE OR place.metadata->>'occasion' ILIKE ('%' || (filters->>'occasion') || '%')) + AND ( + (filters ? 'place_ids') IS FALSE + OR place.external_id IN (SELECT jsonb_array_elements_text(filters->'place_ids')) + ) + ORDER BY place.embedding <=> query_embedding, place.external_id + LIMIT GREATEST(match_count, 0); +$$; + +CREATE OR REPLACE FUNCTION public.search_places_semantic_v1( + query_text TEXT, + query_embedding VECTOR(768), + match_count INTEGER, + filters JSONB DEFAULT '{}'::jsonb +) +RETURNS TABLE ( + external_id TEXT, + document TEXT, + metadata JSONB, + score DOUBLE PRECISION, + semantic_score DOUBLE PRECISION, + lexical_score DOUBLE PRECISION +) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + WITH dense AS ( + SELECT + place.external_id, + 1 - (place.embedding <=> query_embedding) AS semantic_score, + row_number() OVER ( + ORDER BY place.embedding <=> query_embedding, place.external_id + ) AS dense_rank + FROM public.place_embeddings_semantic_v1 AS place + WHERE place.is_active = true + AND COALESCE((filters->>'is_active')::boolean, true) = true + AND ((filters ? 'city') IS FALSE OR lower(place.metadata->>'city') = lower(filters->>'city')) + AND ((filters ? 'state') IS FALSE OR lower(place.metadata->>'state') = lower(filters->>'state')) + AND ((filters ? 'category') IS FALSE OR lower(place.metadata->>'category') = lower(filters->>'category')) + AND ((filters ? 'price_range') IS FALSE OR place.metadata->>'price_range' = filters->>'price_range') + AND ((filters ? 'occasion') IS FALSE OR place.metadata->>'occasion' ILIKE ('%' || (filters->>'occasion') || '%')) + AND ( + (filters ? 'place_ids') IS FALSE + OR place.external_id IN (SELECT jsonb_array_elements_text(filters->'place_ids')) + ) + ORDER BY place.embedding <=> query_embedding, place.external_id + LIMIT GREATEST(match_count * 4, 100) + ), + lexical AS ( + SELECT + place.external_id, + ts_rank_cd( + place.textsearch, + websearch_to_tsquery('simple', COALESCE(query_text, '')) + )::DOUBLE PRECISION AS lexical_score, + row_number() OVER ( + ORDER BY + ts_rank_cd( + place.textsearch, + websearch_to_tsquery('simple', COALESCE(query_text, '')) + ) DESC, + place.external_id + ) AS lexical_rank + FROM public.place_embeddings_semantic_v1 AS place + WHERE place.is_active = true + AND COALESCE((filters->>'is_active')::boolean, true) = true + AND ((filters ? 'city') IS FALSE OR lower(place.metadata->>'city') = lower(filters->>'city')) + AND ((filters ? 'state') IS FALSE OR lower(place.metadata->>'state') = lower(filters->>'state')) + AND ((filters ? 'category') IS FALSE OR lower(place.metadata->>'category') = lower(filters->>'category')) + AND ((filters ? 'price_range') IS FALSE OR place.metadata->>'price_range' = filters->>'price_range') + AND ((filters ? 'occasion') IS FALSE OR place.metadata->>'occasion' ILIKE ('%' || (filters->>'occasion') || '%')) + AND ( + (filters ? 'place_ids') IS FALSE + OR place.external_id IN (SELECT jsonb_array_elements_text(filters->'place_ids')) + ) + AND query_text IS NOT NULL + AND btrim(query_text) <> '' + AND place.textsearch @@ websearch_to_tsquery('simple', query_text) + ORDER BY lexical_score DESC, place.external_id + LIMIT GREATEST(match_count * 4, 100) + ), + unioned AS ( + SELECT + COALESCE(dense.external_id, lexical.external_id) AS external_id, + dense.semantic_score, + lexical.lexical_score, + dense.dense_rank, + lexical.lexical_rank + FROM dense + FULL OUTER JOIN lexical USING (external_id) + ), + scored AS ( + SELECT + unioned.*, + ( + COALESCE(1.0 / (60.0 + unioned.dense_rank), 0.0) + + COALESCE(1.0 / (60.0 + unioned.lexical_rank), 0.0) + ) AS rrf_score + FROM unioned + ) + SELECT + place.external_id, + place.document, + place.metadata, + LEAST( + 1.0, + 0.75 * GREATEST(COALESCE(scored.semantic_score, 0.0), 0.0) + + 0.15 * LEAST(COALESCE(scored.lexical_score, 0.0), 1.0) + + 0.10 * LEAST(scored.rrf_score * 31.0, 1.0) + ) AS score, + scored.semantic_score, + scored.lexical_score + FROM scored + JOIN public.place_embeddings_semantic_v1 AS place USING (external_id) + ORDER BY score DESC, place.external_id + LIMIT GREATEST(match_count, 0); +$$; + +CREATE OR REPLACE FUNCTION public.upsert_place_embedding_semantic_v1( + p_external_id TEXT, + p_document TEXT, + p_metadata JSONB, + p_embedding VECTOR(768), + p_content_hash TEXT, + p_embedding_model TEXT, + p_embedding_version TEXT, + p_is_active BOOLEAN +) +RETURNS VOID +LANGUAGE sql +SECURITY DEFINER +SET search_path = public +AS $$ + INSERT INTO public.place_embeddings_semantic_v1 ( + external_id, document, metadata, embedding, content_hash, + embedding_model, embedding_version, is_active, updated_at + ) VALUES ( + p_external_id, p_document, p_metadata, p_embedding, p_content_hash, + p_embedding_model, p_embedding_version, p_is_active, now() + ) + ON CONFLICT (external_id) DO UPDATE SET + document = EXCLUDED.document, + metadata = EXCLUDED.metadata, + embedding = EXCLUDED.embedding, + content_hash = EXCLUDED.content_hash, + embedding_model = EXCLUDED.embedding_model, + embedding_version = EXCLUDED.embedding_version, + is_active = EXCLUDED.is_active, + updated_at = now(); +$$; + +CREATE OR REPLACE FUNCTION public.get_place_content_hashes_semantic_v1( + p_external_ids TEXT[] +) +RETURNS TABLE (external_id TEXT, content_hash TEXT) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT place.external_id, place.content_hash + FROM public.place_embeddings_semantic_v1 AS place + WHERE place.external_id = ANY(p_external_ids); +$$; + +REVOKE ALL ON TABLE public.place_embeddings_semantic_v1 FROM PUBLIC; +REVOKE ALL ON FUNCTION public.match_places_semantic_v1(VECTOR, INTEGER, JSONB) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.search_places_semantic_v1(TEXT, VECTOR, INTEGER, JSONB) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.upsert_place_embedding_semantic_v1(TEXT, TEXT, JSONB, VECTOR, TEXT, TEXT, TEXT, BOOLEAN) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_place_content_hashes_semantic_v1(TEXT[]) FROM PUBLIC; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nlp_reader') THEN + EXECUTE 'GRANT USAGE ON SCHEMA public TO nlp_reader'; + EXECUTE 'GRANT EXECUTE ON FUNCTION public.match_places_semantic_v1(VECTOR, INTEGER, JSONB) TO nlp_reader'; + EXECUTE 'GRANT EXECUTE ON FUNCTION public.search_places_semantic_v1(TEXT, VECTOR, INTEGER, JSONB) TO nlp_reader'; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nlp_writer') THEN + EXECUTE 'GRANT USAGE ON SCHEMA public TO nlp_writer'; + EXECUTE 'GRANT EXECUTE ON FUNCTION public.upsert_place_embedding_semantic_v1(TEXT, TEXT, JSONB, VECTOR, TEXT, TEXT, TEXT, BOOLEAN) TO nlp_writer'; + EXECUTE 'GRANT EXECUTE ON FUNCTION public.get_place_content_hashes_semantic_v1(TEXT[]) TO nlp_writer'; + END IF; +END +$$; + +-- Execute after creating the least-privilege roles used by the service: +-- GRANT EXECUTE ON FUNCTION public.match_places_semantic_v1(VECTOR, INTEGER, JSONB) TO nlp_reader; +-- GRANT EXECUTE ON FUNCTION public.search_places_semantic_v1(TEXT, VECTOR, INTEGER, JSONB) TO nlp_reader; +-- GRANT EXECUTE ON FUNCTION public.upsert_place_embedding_semantic_v1(TEXT, TEXT, JSONB, VECTOR, TEXT, TEXT, TEXT, BOOLEAN) TO nlp_writer; +-- GRANT EXECUTE ON FUNCTION public.get_place_content_hashes_semantic_v1(TEXT[]) TO nlp_writer; + +COMMIT; diff --git a/sql/verify_places_semantic_v1.sql b/sql/verify_places_semantic_v1.sql new file mode 100644 index 0000000000000000000000000000000000000000..271633e9cc33813e694a956b16b50fdf540b758f --- /dev/null +++ b/sql/verify_places_semantic_v1.sql @@ -0,0 +1,46 @@ +BEGIN; +SET TRANSACTION READ ONLY; + +DO $$ +DECLARE + embedding_type TEXT; +BEGIN + SELECT format_type(attribute.atttypid, attribute.atttypmod) + INTO embedding_type + FROM pg_attribute AS attribute + WHERE attribute.attrelid = 'public.place_embeddings_semantic_v1'::regclass + AND attribute.attname = 'embedding' + AND NOT attribute.attisdropped; + + IF embedding_type IS DISTINCT FROM 'vector(768)' THEN + RAISE EXCEPTION 'Expected place_embeddings_semantic_v1.embedding vector(768), got %', embedding_type; + END IF; + IF to_regprocedure('public.match_places_semantic_v1(vector,integer,jsonb)') IS NULL THEN + RAISE EXCEPTION 'match_places_semantic_v1 is missing'; + END IF; + IF to_regprocedure('public.search_places_semantic_v1(text,vector,integer,jsonb)') IS NULL THEN + RAISE EXCEPTION 'search_places_semantic_v1 is missing'; + END IF; + IF NOT EXISTS ( + SELECT 1 + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'place_embeddings_semantic_v1' + AND indexdef ILIKE '%USING hnsw%' + ) THEN + RAISE EXCEPTION 'Places semantic HNSW index is missing'; + END IF; +END +$$; + +-- Inspect this plan after a representative backfill. With enough rows, the +-- nearest-neighbor branch should use the HNSW index rather than materializing +-- the entire eligible corpus before ordering. +EXPLAIN (ANALYZE, BUFFERS, COSTS, VERBOSE) +SELECT place.external_id +FROM public.place_embeddings_semantic_v1 AS place +WHERE place.is_active = true +ORDER BY place.embedding <=> (array_fill(0::real, ARRAY[768])::vector) +LIMIT 20; + +ROLLBACK; diff --git a/tests/conftest.py b/tests/conftest.py index dee765dcdf9a606f52eb39de30dee6c637989b3a..57cb013d0165d7ad1f1a82c4cbcdac18ac3d5661 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,10 @@ os.environ["EMBEDDING_PROVIDER"] = "mock" os.environ["EMBEDDING_DIMENSION"] = "16" os.environ["EMBEDDING_MODEL"] = "mock-embedding" os.environ["EMBEDDING_VERSION"] = "test-v1" +os.environ["PLACES_EMBEDDING_PROVIDER"] = "mock" +os.environ["PLACES_EMBEDDING_DIMENSION"] = "16" +os.environ["PLACES_EMBEDDING_MODEL"] = "mock-place-embedding" +os.environ["PLACES_EMBEDDING_VERSION"] = "test-places-v1" os.environ["GROQ_API_KEY"] = "" os.environ["SEARCH_INTERNAL_TOKEN"] = "test-search-token" os.environ["NLP_SERVICE_TOKEN"] = "test-nlp-service-token" diff --git a/tests/test_api_endpoints.py b/tests/test_api_endpoints.py index 8b6585a9a18f7542db6ee9088de15668e42fa014..929043e9478bac2dcafec850fd6f30ec7110c0b7 100644 --- a/tests/test_api_endpoints.py +++ b/tests/test_api_endpoints.py @@ -20,14 +20,14 @@ def test_places_search_endpoint() -> None: payload = response.json() assert payload["query"] == "lugares tranquilos para cenar" assert payload["places"] - assert payload["metrics"]["engine"] == "fasttext_mean_embeddings" + assert payload["metrics"]["engine"] == "mock-place-embedding" assert payload["metrics"]["candidate_retrieval"] == "mock_embeddings" assert payload["metrics"]["score_metric"] == "cosine_similarity" assert payload["metrics"]["ranking_parameters"] == {"dimension": 16.0} assert payload["metrics"]["field_weights"] == { - "tags": 6, - "category": 4, - "description": 3, + "tags": 1, + "category": 1, + "description": 1, "name": 1, } assert payload["metrics"]["returned_count"] == len(payload["places"]) @@ -76,7 +76,7 @@ def test_places_chat_endpoint_returns_trace_and_structured_places() -> None: response = client.post( "/places/chat", json={ - "message": "quiero una cena tranquila con mi pareja", + "message": "cafe tranquilo postres platica", "city": "Tuxtla Gutierrez", "filters": {"occasion": "pareja", "is_active": True}, "limit": 3, @@ -92,6 +92,72 @@ def test_places_chat_endpoint_returns_trace_and_structured_places() -> None: assert payload["metadata"]["places_used_as_context"] +def test_places_chat_opt_in_uses_semantic_conversation_contract() -> None: + client = TestClient(create_app()) + + response = client.post( + "/places/chat", + json={ + "conversation_id": "3a4723f6-260d-4c11-b9d6-089f07a4f338", + "turn": 1, + "message": "una cafeteria tranquila", + "conversation_state": { + "city": "Tuxtla Gutierrez", + "taxonomy_version": "places-taxonomy-v1", + }, + "user_location": {"lat": 16.7531, "lng": -93.1156}, + "filters": {"is_active": True}, + "candidate_limit": 5, + "limit": 1, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["action"] == "recommendations" + assert payload["state_patch"]["target_category"] == "cafe" + assert payload["location_directive"]["source"] == "user_current" + assert payload["uncertainty"]["decision"] == "auto" + assert payload["metadata"]["pipeline"] == "places-chat-semantic-v2" + assert len(payload["places"]) == 1 + + +def test_places_chat_opt_in_rejects_incompatible_conversation_state() -> None: + client = TestClient(create_app()) + + response = client.post( + "/places/chat", + json={ + "conversation_id": "3a4723f6-260d-4c11-b9d6-089f07a4f338", + "message": "donas", + "conversation_state": {"taxonomy_version": "obsolete-v0"}, + "user_location": {"lat": 16.7531, "lng": -93.1156}, + }, + ) + + assert response.status_code == 409 + + +def test_places_chat_opt_in_maps_stale_clarification_to_conflict() -> None: + client = TestClient(create_app()) + + response = client.post( + "/places/chat", + json={ + "conversation_id": "3a4723f6-260d-4c11-b9d6-089f07a4f338", + "message": "donas", + "conversation_state": {"taxonomy_version": "places-taxonomy-v1"}, + "clarification_choice": { + "clarification_id": "00000000-0000-4000-8000-000000000000", + "option_id": "bakery", + }, + "user_location": {"lat": 16.7531, "lng": -93.1156}, + }, + ) + + assert response.status_code == 409 + + def test_places_recommendations_returns_llm_message_and_semantic_metadata() -> None: client = TestClient(create_app()) @@ -109,7 +175,7 @@ def test_places_recommendations_returns_llm_message_and_semantic_metadata() -> N payload = response.json() assert payload["message"] assert payload["places"] - assert payload["metrics"]["engine"] == "fasttext_mean_embeddings" + assert payload["metrics"]["engine"] == "mock-place-embedding" assert payload["metrics"]["score_metric"] == "cosine_similarity" assert payload["metrics"]["returned_count"] == len(payload["places"]) assert payload["metrics"]["candidate_retrieval"] == "mock_embeddings" @@ -118,7 +184,7 @@ def test_places_recommendations_returns_llm_message_and_semantic_metadata() -> N assert payload["metrics"]["scope"] == "current_query" assert payload["metrics"]["ground_truth_available"] is False assert "evaluation_metrics" not in payload - assert payload["metadata"]["ranking"] == "fasttext_mean_embeddings" + assert payload["metadata"]["ranking"] == "mock-place-embedding" assert payload["metadata"]["response_mode"] == "confident" assert payload["metadata"]["used_llm"] is True diff --git a/tests/test_bert_place_intent_extractor.py b/tests/test_bert_place_intent_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..0043e9ec6e3d0a9a889c082cc1768c86698ff67a --- /dev/null +++ b/tests/test_bert_place_intent_extractor.py @@ -0,0 +1,277 @@ +import math + +import pytest + +from app.modules.places.infrastructure.bert_intent_extractor import ( + BertIntentInferenceError, + BertIntentModelLoadError, + BertIntentOutputError, + BertPlaceIntentExtractor, +) + + +class FakeTokenClassifier: + def __init__(self, predictions: list[dict[str, object]]) -> None: + self.predictions = predictions + self.calls: list[str] = [] + + def __call__(self, text: str) -> list[dict[str, object]]: + self.calls.append(text) + return list(self.predictions) + + +def _prediction( + text: str, + value: str, + entity: str, + score: float, + *, + after: int = 0, +) -> dict[str, object]: + start = text.index(value, after) + return { + "entity": entity, + "score": score, + "start": start, + "end": start + len(value), + "word": value, + } + + +def test_extractor_loads_lazily_and_preserves_raw_open_value_spans() -> None: + text = ( + "Quiero donas artesanales, sin ruido, cerca del Parque Central a 2 km." + ) + predictions = [ + _prediction(text, "donas", "B-CATEGORY", 0.96), + _prediction(text, "artesanales", "I-CATEGORY", 0.90), + _prediction(text, "ruido", "B-EXCLUSION", 0.94), + _prediction(text, "Parque", "B-LOCATION", 0.91), + _prediction(text, "Central", "I-LOCATION", 0.89), + _prediction(text, "2", "B-RADIUS", 0.88), + _prediction(text, "km", "I-RADIUS", 0.86), + ] + classifier = FakeTokenClassifier(predictions) + loader_calls: list[tuple[str, str | None, int | str | None]] = [] + + def loader( + model_name: str, + revision: str | None, + device: int | str | None, + ) -> FakeTokenClassifier: + loader_calls.append((model_name, revision, device)) + return classifier + + extractor = BertPlaceIntentExtractor( + "frimeet/places-intent-bert", + model_version="2026-07-16", + device="cpu", + model_loader=loader, + ) + + assert extractor.is_loaded is False + assert loader_calls == [] + + frame = extractor.extract(text) + + assert extractor.is_loaded is True + assert loader_calls == [ + ("frimeet/places-intent-bert", "2026-07-16", "cpu") + ] + assert frame.raw_text == text + assert frame.model_name == "frimeet/places-intent-bert" + assert frame.model_version == "2026-07-16" + assert [span.text for span in frame.spans] == [ + "donas artesanales", + "ruido", + "Parque Central", + "2 km", + ] + assert frame.categories[0].text == "donas artesanales" + assert frame.categories[0].polarity == "positive" + assert frame.categories[0].token_count == 2 + assert frame.exclusions[0].text == "ruido" + assert frame.exclusions[0].polarity == "negative" + assert frame.by_type("LOCATION")[0].text == "Parque Central" + assert frame.by_type("RADIUS")[0].text == "2 km" + assert 0.86 <= frame.confidence <= 0.96 + for span in frame.spans: + assert text[span.start : span.end] == span.text + + extractor.extract(text) + assert len(loader_calls) == 1 + assert classifier.calls == [text, text] + + +def test_custom_model_labels_map_to_slots_and_contextual_polarity() -> None: + text = "Busco cafecito con terraza pero evito ruido" + classifier = FakeTokenClassifier( + [ + _prediction(text, "cafecito", "B-TARGET", 0.93), + _prediction(text, "terraza", "B-AMENITY", 0.91), + _prediction(text, "ruido", "B-NEGATIVE_AMENITY", 0.95), + _prediction(text, "Busco", "B-UNRELATED_HEAD", 0.99), + ] + ) + extractor = BertPlaceIntentExtractor( + "injected-model", + classifier=classifier, + label_definitions={ + "TARGET": "CATEGORY", + "AMENITY": ("PREFERENCE", "positive"), + "NEGATIVE_AMENITY": ("PREFERENCE", "negative"), + }, + ) + + frame = extractor.extract(text) + + assert [span.text for span in frame.categories] == ["cafecito"] + assert [span.text for span in frame.preferences] == ["terraza"] + assert [span.text for span in frame.exclusions] == ["ruido"] + assert frame.exclusions[0].slot_type == "PREFERENCE" + assert "UNRELATED_HEAD" not in {span.slot_type for span in frame.spans} + + +def test_stray_i_label_is_recovered_but_a_skipped_token_breaks_the_span() -> None: + text = "terraza muy tranquila" + classifier = FakeTokenClassifier( + [ + _prediction(text, "terraza", "I-PREFERENCE", 0.92), + _prediction(text, "muy", "I-PREFERENCE", 0.30), + _prediction(text, "tranquila", "I-PREFERENCE", 0.90), + ] + ) + extractor = BertPlaceIntentExtractor( + "injected-model", + classifier=classifier, + minimum_token_confidence=0.8, + ) + + frame = extractor.extract(text) + + assert [span.text for span in frame.preferences] == ["terraza", "tranquila"] + assert all(span.token_count == 1 for span in frame.preferences) + + +def test_huggingface_token_index_breaks_span_across_ignored_o_tokens() -> None: + text = "cafe cerca del parque" + first = _prediction(text, "cafe", "B-CATEGORY", 0.94) + first["index"] = 1 + second = _prediction(text, "parque", "I-CATEGORY", 0.91) + second["index"] = 5 + classifier = FakeTokenClassifier([first, second]) + + frame = BertPlaceIntentExtractor( + "injected-model", + classifier=classifier, + ).extract(text) + + assert [span.text for span in frame.categories] == ["cafe", "parque"] + assert all(span.token_count == 1 for span in frame.categories) + + +def test_empty_text_returns_an_empty_frame_without_loading_the_model() -> None: + def loader( + _model_name: str, + _revision: str | None, + _device: int | str | None, + ) -> FakeTokenClassifier: + raise AssertionError("empty text must not load transformers") + + extractor = BertPlaceIntentExtractor("lazy-model", model_loader=loader) + + frame = extractor.extract(" \t\n") + + assert frame.raw_text == " \t\n" + assert frame.spans == () + assert frame.confidence == 0.0 + assert extractor.is_loaded is False + + +def test_injected_classifier_does_not_require_transformers_and_accepts_group_labels() -> None: + text = "Parque México" + classifier = FakeTokenClassifier( + [ + { + "entity_group": "LOCATION", + "score": 0.97, + "start": 0, + "end": len(text), + } + ] + ) + extractor = BertPlaceIntentExtractor("no-transformers-needed", classifier=classifier) + + frame = extractor.extract(text) + + assert frame.by_type("LOCATION")[0].text == text + assert frame.by_type("LOCATION")[0].confidence == pytest.approx(0.97) + + +def test_loader_inference_and_malformed_output_errors_have_context() -> None: + def broken_loader( + _model_name: str, + _revision: str | None, + _device: int | str | None, + ) -> FakeTokenClassifier: + raise OSError("model cache unavailable") + + extractor = BertPlaceIntentExtractor("missing-model", model_loader=broken_loader) + with pytest.raises( + BertIntentModelLoadError, + match="missing-model.*model cache unavailable", + ): + extractor.extract("donas") + + class BrokenClassifier: + def __call__(self, _text: str) -> list[dict[str, object]]: + raise RuntimeError("backend crashed") + + extractor = BertPlaceIntentExtractor("broken-model", classifier=BrokenClassifier()) + with pytest.raises( + BertIntentInferenceError, + match="broken-model.*backend crashed", + ): + extractor.extract("donas") + + malformed = FakeTokenClassifier( + [{"entity": "B-CATEGORY", "score": 0.9, "start": 0}] + ) + extractor = BertPlaceIntentExtractor("bad-output", classifier=malformed) + with pytest.raises(BertIntentOutputError, match="integer end offset"): + extractor.extract("donas") + + +def test_frame_and_span_confidence_are_length_weighted() -> None: + text = "cafe silencioso" + classifier = FakeTokenClassifier( + [ + _prediction(text, "cafe", "B-CATEGORY", 1.0), + _prediction(text, "silencioso", "B-PREFERENCE", 0.5), + ] + ) + frame = BertPlaceIntentExtractor("model", classifier=classifier).extract(text) + + expected = (4 * 1.0 + 10 * 0.5) / 14 + assert frame.confidence == pytest.approx(expected) + assert math.isfinite(frame.confidence) + + +def test_constructor_rejects_invalid_configuration() -> None: + classifier = FakeTokenClassifier([]) + with pytest.raises(ValueError, match="model_name_or_path"): + BertPlaceIntentExtractor(" ") + with pytest.raises(ValueError, match="minimum_token_confidence"): + BertPlaceIntentExtractor("model", minimum_token_confidence=1.1) + with pytest.raises(ValueError, match="classifier or model_loader"): + BertPlaceIntentExtractor( + "model", + classifier=classifier, + model_loader=lambda _name, _revision, _device: classifier, + ) + with pytest.raises(ValueError, match="slot_type"): + BertPlaceIntentExtractor( + "model", + classifier=classifier, + label_definitions={"CUSTOM": "NOT_A_SLOT"}, # type: ignore[dict-item] + ) diff --git a/tests/test_embeddings.py b/tests/test_embeddings.py index fdb152655b0daa022dafd22075482ce7251e6b25..0e9ba26884b8c2aad21118f2214a52b5dd248743 100644 --- a/tests/test_embeddings.py +++ b/tests/test_embeddings.py @@ -1,4 +1,7 @@ from app.shared.nlp.embeddings.mock import MockEmbeddingProvider +from app.shared.nlp.embeddings.base import EmbeddingProvider +from app.shared.nlp.embeddings.cached import CachedEmbeddingProvider +from app.shared.cache.memory import SimpleTTLCache def test_mock_embedding_provider_is_deterministic() -> None: @@ -9,3 +12,27 @@ def test_mock_embedding_provider_is_deterministic() -> None: assert first == second assert len(first) == provider.dimension + + +class RecordingBatchProvider(EmbeddingProvider): + def __init__(self) -> None: + self.batches: list[list[str]] = [] + + def embed_text(self, text: str) -> list[float]: + raise AssertionError("batch cache should use embed_batch for misses") + + def embed_batch(self, texts: list[str]) -> list[list[float]]: + self.batches.append(list(texts)) + return [[float(len(text))] for text in texts] + + +def test_cached_provider_batches_unique_misses_and_preserves_order() -> None: + inner = RecordingBatchProvider() + provider = CachedEmbeddingProvider(inner, SimpleTTLCache()) + + first = provider.embed_batch(["donas", "cafe", "donas"]) + second = provider.embed_batch(["cafe", "parque"]) + + assert first == [[5.0], [4.0], [5.0]] + assert second == [[4.0], [6.0]] + assert inner.batches == [["donas", "cafe"], ["parque"]] diff --git a/tests/test_hybrid_chat_retriever.py b/tests/test_hybrid_chat_retriever.py new file mode 100644 index 0000000000000000000000000000000000000000..db6eb91246b19906bd18d7fd1b567039486c737d --- /dev/null +++ b/tests/test_hybrid_chat_retriever.py @@ -0,0 +1,232 @@ +import pytest + +from app.modules.places.domain.chat_intent import ( + ConversationStatePatch, + LocationIntent, + ParsedPlaceChatIntent, +) +from app.modules.places.domain.models import PlaceCandidate +from app.modules.places.infrastructure.hybrid_chat_retriever import ( + HybridContentPlaceChatRetriever, +) +from app.shared.nlp.embeddings.mock import MockEmbeddingProvider + + +def _intent( + query: str, + *, + target_category: str | None = None, + category_values: tuple[str, ...] = (), + hard_filters: dict[str, object] | None = None, + exclusions: tuple[str, ...] = (), +) -> ParsedPlaceChatIntent: + return ParsedPlaceChatIntent( + action="recommendations", + target_category=target_category, + category_values=category_values, + hard_filters=hard_filters or {}, + soft_preferences=(), + exclusions=exclusions, + reference=None, + location=LocationIntent( + scope="target_results", + source="user_current", + ), + semantic_query=query, + confidence=0.90, + state_patch=ConversationStatePatch(), + ) + + +class RecordingRepository: + source_name = "test" + + def __init__(self, candidates: list[PlaceCandidate]) -> None: + self.candidates = candidates + self.calls: list[dict[str, object]] = [] + + async def search(self, embedding, filters, limit): + self.calls.append( + { + "embedding": embedding, + "filters": filters, + "limit": limit, + } + ) + return self.candidates + + +@pytest.mark.asyncio +async def test_open_vocabulary_recommendation_retrieves_without_category() -> None: + repository = RecordingRepository( + [ + PlaceCandidate( + id="donut_shop", + name="Dulce Circular", + category="bakery", + score=0.0, + metadata={"tags": "postres artesanales"}, + ) + ] + ) + retriever = HybridContentPlaceChatRetriever( + embedding_provider=MockEmbeddingProvider(dimension=16), + place_repository=repository, + minimum_content_score=0.95, + ) + + candidates = await retriever.retrieve( + intent=_intent( + "donas", + hard_filters={"city": "Tuxtla Gutierrez", "state": "Chiapas"}, + ), + limit=3, + ) + + assert [candidate.place_id for candidate in candidates] == ["donut_shop"] + assert repository.calls[0]["filters"].categories is None + assert repository.calls[0]["filters"].city == "Tuxtla Gutierrez" + assert repository.calls[0]["filters"].state == "Chiapas" + diagnostics = candidates[0].metadata["retrieval_diagnostics"] + assert diagnostics == { + "category_affinity": 0.0, + "category_match": "not_requested", + "exclusion_affinity": 0.0, + "exclusion_matches": [], + "content_quality": "weak", + "meets_minimum_content_score": False, + "minimum_content_score": 0.95, + "query_token_count": 1, + } + + +@pytest.mark.asyncio +async def test_category_boosts_ranking_but_does_not_remove_other_categories() -> None: + repository = RecordingRepository( + [ + PlaceCandidate( + id="park", + name="Parque Vecinal", + category="park", + score=0.2, + ), + PlaceCandidate( + id="cafe", + name="Salon Urbano", + category="cafe", + score=0.2, + ), + ] + ) + retriever = HybridContentPlaceChatRetriever( + embedding_provider=MockEmbeddingProvider(dimension=16), + place_repository=repository, + ) + + candidates = await retriever.retrieve( + intent=_intent( + "sitio agradable", + target_category="cafe", + category_values=("cafe", "cafeteria"), + ), + limit=5, + ) + + assert [candidate.place_id for candidate in candidates] == ["cafe", "park"] + assert candidates[0].metadata["retrieval_diagnostics"]["category_match"] == ( + "exact" + ) + assert candidates[1].metadata["retrieval_diagnostics"]["category_match"] == ( + "none" + ) + + +class HybridRepository: + source_name = "test_hybrid" + + def __init__(self) -> None: + self.hybrid_call: dict[str, object] | None = None + + async def search_hybrid(self, query_text, embedding, filters, limit): + self.hybrid_call = { + "query_text": query_text, + "embedding": embedding, + "filters": filters, + "limit": limit, + } + return [ + PlaceCandidate( + id="hybrid_result", + name="Resultado Hibrido", + category="bakery", + # The fused repository score is not a semantic score. This + # row came from the independent lexical candidate pool. + score=0.99, + metadata={"lexical_score": 1.0}, + ) + ] + + async def search(self, embedding, filters, limit): + del embedding, filters, limit + raise AssertionError("search must not run when search_hybrid is available") + + +@pytest.mark.asyncio +async def test_optional_repository_hybrid_search_is_preferred() -> None: + repository = HybridRepository() + retriever = HybridContentPlaceChatRetriever( + embedding_provider=MockEmbeddingProvider(dimension=16), + place_repository=repository, + ) + + candidates = await retriever.retrieve( + intent=_intent("Donas cerca"), + limit=3, + ) + + assert [candidate.place_id for candidate in candidates] == ["hybrid_result"] + assert repository.hybrid_call is not None + assert repository.hybrid_call["query_text"] == "Donas cerca" + assert repository.hybrid_call["filters"].categories is None + assert repository.hybrid_call["limit"] == 40 + assert candidates[0].semantic_score == 0.0 + assert candidates[0].lexical_score > 0.0 + + +@pytest.mark.asyncio +async def test_exclusions_penalize_instead_of_dropping_and_respect_negation() -> None: + repository = RecordingRepository( + [ + PlaceCandidate( + id="quiet", + name="Patio Sereno", + category="cafe", + score=0.5, + document="Un espacio tranquilo sin ruido exterior", + ), + PlaceCandidate( + id="noisy", + name="Foro Central", + category="cafe", + score=0.5, + document="Musica y ruido durante toda la noche", + ), + ] + ) + retriever = HybridContentPlaceChatRetriever( + embedding_provider=MockEmbeddingProvider(dimension=16), + place_repository=repository, + ) + + candidates = await retriever.retrieve( + intent=_intent("lugar tranquilo", exclusions=("ruido",)), + limit=5, + ) + + assert [candidate.place_id for candidate in candidates] == ["quiet", "noisy"] + quiet_diagnostics = candidates[0].metadata["retrieval_diagnostics"] + noisy_diagnostics = candidates[1].metadata["retrieval_diagnostics"] + assert quiet_diagnostics["exclusion_affinity"] == 0.0 + assert noisy_diagnostics["exclusion_affinity"] == 1.0 + assert noisy_diagnostics["exclusion_matches"] == ["ruido"] + assert candidates[0].content_score > candidates[1].content_score diff --git a/tests/test_internal_places_chat.py b/tests/test_internal_places_chat.py index 29e29bed1fd759c4195272f22526d4528de488cc..92e0f586ea0f6a32f18c7b82bba048239efdaa19 100644 --- a/tests/test_internal_places_chat.py +++ b/tests/test_internal_places_chat.py @@ -52,8 +52,9 @@ def test_internal_chat_returns_only_technical_candidates() -> None: "strict_radius": False, } assert payload["candidates"] - assert {candidate["place_id"] for candidate in payload["candidates"]} == { - "place_1" + assert payload["candidates"][0]["place_id"] == "place_1" + assert "place_1" in { + candidate["place_id"] for candidate in payload["candidates"] } assert set(payload["candidates"][0]) == { "place_id", @@ -114,7 +115,7 @@ def test_internal_chat_recommends_for_short_explicit_categories( } -def test_internal_chat_never_returns_parks_for_a_cafe_query() -> None: +def test_internal_chat_soft_category_ranks_cafe_first_near_park_anchor() -> None: client = TestClient(create_app()) request = { **BASE_REQUEST, @@ -130,9 +131,7 @@ def test_internal_chat_never_returns_parks_for_a_cafe_query() -> None: assert response.status_code == 200 payload = response.json() assert payload["action"] == "recommendations" - assert {candidate["place_id"] for candidate in payload["candidates"]} == { - "place_1" - } + assert payload["candidates"][0]["place_id"] == "place_1" assert payload["location_directive"]["source"] == "explicit_anchor" assert payload["location_directive"]["anchor_place_id"] == "place_6" @@ -296,7 +295,7 @@ def test_internal_chat_rejects_a_stale_structured_choice() -> None: assert response.status_code == 409 -def test_internal_chat_applies_content_exclusions_before_ranking() -> None: +def test_internal_chat_applies_content_exclusions_as_negative_ranking_evidence() -> None: client = TestClient(create_app()) request = { **BASE_REQUEST, @@ -311,8 +310,10 @@ def test_internal_chat_applies_content_exclusions_before_ranking() -> None: assert response.status_code == 200 payload = response.json() - assert payload["action"] == "no_match" - assert payload["candidates"] == [] + assert payload["action"] == "recommendations" + candidate_ids = [candidate["place_id"] for candidate in payload["candidates"]] + assert "place_1" in candidate_ids + assert candidate_ids[0] != "place_1" assert payload["state_patch"]["exclusions"] == ["tranquilo"] diff --git a/tests/test_main_api_place_source.py b/tests/test_main_api_place_source.py index 23a58d838b68a784ed932db63ca069987a08fb09..b0402731ea6ca43d200093cdb8c4ce05d93efd0b 100644 --- a/tests/test_main_api_place_source.py +++ b/tests/test_main_api_place_source.py @@ -31,7 +31,7 @@ def test_place_to_source_record_maps_api_place() -> None: assert len(record.content_hash) == 64 -def test_place_to_source_record_resolves_and_weights_numeric_tags() -> None: +def test_place_to_source_record_resolves_tags_without_token_repetition() -> None: record = place_to_source_record( { "id": "place_weighted", @@ -48,17 +48,17 @@ def test_place_to_source_record_resolves_and_weights_numeric_tags() -> None: assert record.metadata["tags"] == "Compras,Ropa barata" assert record.metadata["tag_ids"] == [29, 187, 9999] assert record.metadata["unknown_tag_ids"] == [9999] - assert record.metadata["semantic_document_version"] == "weighted-tags-v2" + assert record.metadata["semantic_document_version"] == "structured-place-v3" assert record.document.count("Nombre Ambiguo") == 1 - assert record.document.count("Venta de prendas y accesorios.") == 3 - assert record.document.count("Compras Ropa barata") == 6 - assert record.document.count("compras tiendas ropa") == 4 + assert record.document.count("Venta de prendas y accesorios") == 1 + assert record.document.count("Compras Ropa barata") == 1 + assert "Tipo registrado: shopping." in record.document assert "osm" not in record.document assert "Direccion que no debe influir" not in record.document assert "9999" not in record.document -def test_semantic_documents_keep_canonical_and_local_category_terms() -> None: +def test_semantic_documents_keep_source_category_without_manual_expansion() -> None: park = place_to_source_record( {"id": "park", "name": "Area Uno", "category": "park"} ) @@ -66,8 +66,10 @@ def test_semantic_documents_keep_canonical_and_local_category_terms() -> None: {"id": "shopping", "name": "Area Dos", "category": "shopping"} ) - assert park is not None and "park parque" in park.document - assert shopping is not None and "shopping compras" in shopping.document + assert park is not None and "Tipo registrado: park." in park.document + assert shopping is not None and "Tipo registrado: shopping." in shopping.document + assert "park parque naturaleza" not in park.document + assert "shopping compras tiendas" not in shopping.document def test_place_tag_catalog_contains_complete_supplied_mapping() -> None: diff --git a/tests/test_open_vocabulary_category_classifier.py b/tests/test_open_vocabulary_category_classifier.py new file mode 100644 index 0000000000000000000000000000000000000000..7e75be430b1cd4913ed5cb19f963515f128e60aa --- /dev/null +++ b/tests/test_open_vocabulary_category_classifier.py @@ -0,0 +1,228 @@ +import math + +import pytest + +from app.modules.places.infrastructure.open_vocabulary_category_classifier import ( + OpenVocabularyPlaceCategoryClassifier, + PlaceCategoryConcept, +) +from app.shared.nlp.embeddings.base import EmbeddingProvider + + +class ControlledEmbeddingProvider(EmbeddingProvider): + def __init__(self, vectors: dict[str, list[float]]) -> None: + self.vectors = vectors + self.text_calls: list[str] = [] + self.batch_calls: list[list[str]] = [] + + def embed_text(self, text: str) -> list[float]: + self.text_calls.append(text) + return self.vectors.get(text, [0.0, 0.0, 0.0]) + + def embed_batch(self, texts: list[str]) -> list[list[float]]: + self.batch_calls.append(list(texts)) + return [self.vectors.get(text, [0.0, 0.0, 0.0]) for text in texts] + + +def _catalog_vectors() -> dict[str, list[float]]: + return { + "Dulces horneados. Lugares con masas dulces y glaseadas": [1.0, 0.0, 0.0], + "Dulces horneados": [1.0, 0.0, 0.0], + "Lugares con masas dulces y glaseadas": [1.0, 0.0, 0.0], + "quiero una dona artesanal": [1.0, 0.0, 0.0], + "Naturaleza urbana. Espacios abiertos con vegetacion": [0.0, 1.0, 0.0], + "Naturaleza urbana": [0.0, 1.0, 0.0], + "Espacios abiertos con vegetacion": [0.0, 1.0, 0.0], + "quiero caminar entre arboles": [0.0, 1.0, 0.0], + "se me antojaron donitas": [0.9, 0.1, 0.0], + "quiero salir": [1.0, 1.0, 0.0], + "algo completamente distinto": [0.0, 0.0, 1.0], + } + + +def _concepts() -> list[PlaceCategoryConcept]: + return [ + PlaceCategoryConcept( + id="sweet_baked_goods", + label="Dulces horneados", + description="Lugares con masas dulces y glaseadas", + examples=("quiero una dona artesanal",), + storage_values=("baked_goods", "pastry_vendor"), + ), + PlaceCategoryConcept( + id="urban_nature", + label="Naturaleza urbana", + description="Espacios abiertos con vegetacion", + examples=("quiero caminar entre arboles",), + storage_values=("green_area",), + ), + ] + + +def test_rank_uses_injected_catalog_and_exposes_score_margin_and_storage_values() -> None: + embeddings = ControlledEmbeddingProvider(_catalog_vectors()) + classifier = OpenVocabularyPlaceCategoryClassifier( + _concepts(), + embeddings, + ) + + assert classifier.is_indexed is False + assert embeddings.batch_calls == [] + + matches = classifier.rank("se me antojaron donitas", limit=2) + + assert classifier.is_indexed is True + assert len(embeddings.batch_calls) == 1 + assert [match.concept_id for match in matches] == [ + "sweet_baked_goods", + "urban_nature", + ] + assert matches[0].storage_values == ("baked_goods", "pastry_vendor") + assert matches[0].score == pytest.approx(0.9938837) + assert matches[0].margin > 0.88 + assert matches[1].margin >= 1.0 + assert classifier.get_concept("SWEET_BAKED_GOODS") == _concepts()[0] + + +def test_rank_limit_still_calculates_top_margin_against_runner_up() -> None: + classifier = OpenVocabularyPlaceCategoryClassifier( + _concepts(), + ControlledEmbeddingProvider(_catalog_vectors()), + ) + + only_match = classifier.rank("se me antojaron donitas", limit=1) + + assert len(only_match) == 1 + assert only_match[0].margin > 0.88 + + +def test_query_and_concept_encoders_can_use_distinct_e5_prefix_roles() -> None: + vectors = _catalog_vectors() + query_embeddings = ControlledEmbeddingProvider( + {"se me antojaron donitas": vectors["se me antojaron donitas"]} + ) + concept_embeddings = ControlledEmbeddingProvider(vectors) + classifier = OpenVocabularyPlaceCategoryClassifier( + _concepts(), + query_embeddings, + concept_embedding_provider=concept_embeddings, + ) + + matches = classifier.rank("se me antojaron donitas", limit=1) + + assert matches[0].concept_id == "sweet_baked_goods" + assert query_embeddings.text_calls == ["se me antojaron donitas"] + assert query_embeddings.batch_calls == [] + assert len(concept_embeddings.batch_calls) == 1 + + +def test_classify_is_compatible_with_place_activity_classifier() -> None: + classifier = OpenVocabularyPlaceCategoryClassifier( + _concepts(), + ControlledEmbeddingProvider(_catalog_vectors()), + minimum_similarity=0.5, + minimum_margin=0.05, + ) + + result = classifier.classify("se me antojaron donitas") + + assert result is not None + assert result.category == "sweet_baked_goods" + assert result.source == "semantic_activity" + assert result.category_values == ("baked_goods", "pastry_vendor") + assert result.label == "Dulces horneados" + assert 0.0 <= result.confidence <= 0.99 + + +def test_classify_abstains_when_candidates_are_tied_or_similarity_is_low() -> None: + classifier = OpenVocabularyPlaceCategoryClassifier( + _concepts(), + ControlledEmbeddingProvider(_catalog_vectors()), + minimum_similarity=0.5, + minimum_margin=0.05, + ) + + assert classifier.classify("quiero salir") is None + assert classifier.classify("algo completamente distinto") is None + + +def test_mapping_catalog_is_supported_without_code_level_categories() -> None: + concept = { + "id": "orbital_archive", + "label": "Archivo orbital", + "description": "Colecciones documentales sobre misiones espaciales", + "examples": "quiero estudiar expediciones fuera de la Tierra", + "storage_values": ["space_records"], + } + semantic_text = ( + "Archivo orbital. Colecciones documentales sobre misiones espaciales" + ) + embeddings = ControlledEmbeddingProvider( + { + semantic_text: [1.0, 0.0, 0.0], + "Archivo orbital": [1.0, 0.0, 0.0], + "Colecciones documentales sobre misiones espaciales": [1.0, 0.0, 0.0], + "quiero estudiar expediciones fuera de la Tierra": [1.0, 0.0, 0.0], + "busco documentos de misiones espaciales": [1.0, 0.0, 0.0], + } + ) + classifier = OpenVocabularyPlaceCategoryClassifier([concept], embeddings) + + match = classifier.rank("busco documentos de misiones espaciales", limit=1)[0] + + assert match.concept_id == "orbital_archive" + assert match.storage_values == ("space_records",) + + +def test_blank_or_zero_query_and_empty_catalog_return_no_matches() -> None: + embeddings = ControlledEmbeddingProvider(_catalog_vectors()) + classifier = OpenVocabularyPlaceCategoryClassifier(_concepts(), embeddings) + + assert classifier.rank(" ") == () + assert classifier.rank("unknown") == () + assert classifier.classify("unknown") is None + assert embeddings.batch_calls == [] + + empty = OpenVocabularyPlaceCategoryClassifier([], embeddings) + assert empty.rank("se me antojaron donitas") == () + + +def test_catalog_and_rank_inputs_are_validated() -> None: + embeddings = ControlledEmbeddingProvider({}) + with pytest.raises(ValueError, match="unique"): + OpenVocabularyPlaceCategoryClassifier( + [ + { + "id": "Dynamic", + "label": "First", + "description": "First concept", + }, + { + "id": "dynamic", + "label": "Second", + "description": "Second concept", + }, + ], + embeddings, + ) + with pytest.raises(ValueError, match="missing required fields"): + OpenVocabularyPlaceCategoryClassifier( + [{"id": "incomplete", "label": "Incomplete"}], + embeddings, + ) + + classifier = OpenVocabularyPlaceCategoryClassifier([], embeddings) + with pytest.raises(ValueError, match="positive integer"): + classifier.rank("query", limit=0) + with pytest.raises(TypeError, match="text must be str"): + classifier.rank(42) # type: ignore[arg-type] + + +def test_non_finite_vectors_are_rejected() -> None: + embeddings = ControlledEmbeddingProvider( + {"query": [math.nan, 0.0, 1.0]} + ) + classifier = OpenVocabularyPlaceCategoryClassifier(_concepts(), embeddings) + + with pytest.raises(ValueError, match="NaN or infinity"): + classifier.rank("query") diff --git a/tests/test_pgvector_readiness.py b/tests/test_pgvector_readiness.py index 8cd369f8f25ab6df3e4e27d32da69fcea872b234..2160c83e6e5ee106f168401fc17f003c103da929 100644 --- a/tests/test_pgvector_readiness.py +++ b/tests/test_pgvector_readiness.py @@ -1,4 +1,9 @@ -from app.shared.vector_store.aws_pgvector import _read_contract_is_ready +from types import SimpleNamespace + +from app.shared.vector_store.aws_pgvector import ( + _configured_read_contract_signatures, + _read_contract_is_ready, +) def _functions(search_v2: bool) -> dict[str, dict[str, object]]: @@ -20,3 +25,19 @@ def test_readiness_rejects_legacy_search_function() -> None: def test_readiness_accepts_search_candidate_v2_contract() -> None: assert _read_contract_is_ready(True, _functions(search_v2=True)) is True + + +def test_readiness_includes_configured_places_semantic_contract() -> None: + signatures = _configured_read_contract_signatures( + SimpleNamespace( + places_pgvector_match_function="match_places_semantic_v1", + places_pgvector_hybrid_function="search_places_semantic_v1", + ) + ) + + assert signatures["match_places_semantic_v1"] == ( + "match_places_semantic_v1(vector, integer, jsonb)" + ) + assert signatures["search_places_semantic_v1"] == ( + "search_places_semantic_v1(text, vector, integer, jsonb)" + ) diff --git a/tests/test_place_chat_intent_parser.py b/tests/test_place_chat_intent_parser.py index c0aa7e9bcf49cf40cecc3ec3f91ea2cff95c9fd6..ebce20f683b61595f75dbef6656e332b44b0a5f4 100644 --- a/tests/test_place_chat_intent_parser.py +++ b/tests/test_place_chat_intent_parser.py @@ -5,9 +5,13 @@ from app.modules.places.domain.chat_intent import ( ConversationState, ExplicitTargetLocation, PendingClarification, + PendingClarificationOption, PlaceCategoryInference, PlaceReference, ) +from app.modules.places.infrastructure.bert_intent_extractor import ( + BertPlaceIntentExtractor, +) from app.modules.places.domain.errors import ClarificationStateMismatchError from app.modules.places.infrastructure.deterministic_intent_parser import ( DeterministicPlaceChatIntentParser, @@ -122,15 +126,31 @@ def test_explicit_radius_is_strict_and_removed_from_anchor_text() -> None: assert intent.location.strict_radius is True -def test_missing_category_requires_clarification() -> None: +def test_missing_category_preserves_open_concept_for_retrieval() -> None: intent = DeterministicPlaceChatIntentParser().parse( message="quiero un lugar bonito para salir", state=ConversationState(), has_user_location=True, ) - assert intent.action == "clarification" + assert intent.action == "recommendations" + assert intent.target_category is None + assert "bonito" in intent.semantic_query assert intent.unresolved == ("target_category",) + assert intent.state_patch.pending_clarification is None + + +def test_unknown_category_word_is_not_dropped_before_dense_retrieval() -> None: + intent = DeterministicPlaceChatIntentParser().parse( + message="quiero donas artesanales", + state=ConversationState(), + has_user_location=True, + ) + + assert intent.action == "recommendations" + assert intent.target_category is None + assert "donas" in intent.semantic_query + assert intent.raw_category_phrase is not None def test_food_activity_defaults_to_restaurant_without_clarification() -> None: @@ -353,7 +373,7 @@ def test_pending_scope_is_resolved_by_structured_choice_without_repeating() -> N def test_structured_choice_rejects_a_stale_clarification_id() -> None: parser = DeterministicPlaceChatIntentParser() first = parser.parse( - message="quiero un lugar bonito para salir", + message="cafeterias como la de Hello Kitty cerca del Parque Central", state=ConversationState(), has_user_location=True, ) @@ -410,3 +430,336 @@ def test_exclusion_is_kept_out_of_positive_preferences() -> None: assert "musica" not in intent.soft_preferences assert intent.exclusions == ("musica",) assert intent.state_patch.as_dict()["exclusions"] == ["musica"] + + +def test_unknown_exclusion_is_preserved_and_does_not_consume_positive_context() -> None: + intent = DeterministicPlaceChatIntentParser().parse( + message="una cafeteria sin ruido con terraza", + state=ConversationState(), + has_user_location=True, + ) + + assert intent.action == "recommendations" + assert intent.exclusions == ("ruido",) + assert "terraza" in intent.semantic_query + assert "ruido" not in intent.semantic_query + + +def test_bert_slots_drive_open_category_exclusion_and_location_before_legacy() -> None: + message = "quiero donas artesanales sin ruido cerca de la plaza" + + def token(value: str, entity: str, score: float) -> dict[str, object]: + start = message.index(value) + return { + "entity": entity, + "score": score, + "start": start, + "end": start + len(value), + } + + class Classifier: + def __call__(self, _text: str) -> list[dict[str, object]]: + return [ + token("donas", "B-CATEGORY", 0.96), + token("artesanales", "I-CATEGORY", 0.94), + token("ruido", "B-EXCLUSION", 0.95), + token("plaza", "B-LOCATION", 0.93), + ] + + extractor = BertPlaceIntentExtractor( + "places-intent-test", + model_version="test-v1", + classifier=Classifier(), + ) + intent = DeterministicPlaceChatIntentParser( + contextual_extractor=extractor, + ).parse( + message=message, + state=ConversationState(), + has_user_location=True, + ) + + assert intent.action == "recommendations" + assert intent.target_category == "donas artesanales" + assert intent.category_values == ("donas artesanales",) + assert intent.raw_category_phrase == "donas artesanales" + assert intent.exclusions == ("ruido",) + assert intent.location.scope == "target_results" + assert intent.location.anchor_text == "plaza" + assert "donas artesanales" in intent.semantic_query + assert "ruido" not in intent.semantic_query + assert intent.state_patch.target_category == "donas artesanales" + assert intent.intent_model_version == ( + "bert-token:places-intent-test@test-v1+deterministic-open-v2" + ) + + +def test_bert_raw_category_is_aligned_semantically_to_dynamic_storage_values() -> None: + message = "quiero donas artesanales" + start = message.index("donas") + + class TokenClassifier: + def __call__(self, _text: str) -> list[dict[str, object]]: + return [ + { + "entity": "B-CATEGORY", + "score": 0.96, + "start": start, + "end": len(message), + } + ] + + class Concept: + id = "donut_shop" + storage_values = ("bakery", "dessert") + + class ActivityClassifier: + concepts = (Concept(),) + + def classify(self, text: str) -> PlaceCategoryInference | None: + assert text == "donas artesanales" + return PlaceCategoryInference( + category="donut_shop", + confidence=0.91, + source="semantic_activity", + category_values=("bakery", "dessert"), + label="Donas", + ) + + intent = DeterministicPlaceChatIntentParser( + activity_classifier=ActivityClassifier(), + contextual_extractor=BertPlaceIntentExtractor( + "places-intent-test", + classifier=TokenClassifier(), + ), + ).parse( + message=message, + state=ConversationState(), + has_user_location=True, + ) + + assert intent.target_category == "donut_shop" + assert intent.category_values == ("bakery", "dessert") + assert intent.raw_category_phrase == "donas artesanales" + + +def test_bert_failure_falls_open_and_exposes_fallback_model_version() -> None: + class BrokenClassifier: + def __call__(self, _text: str) -> list[dict[str, object]]: + raise RuntimeError("inference backend unavailable") + + extractor = BertPlaceIntentExtractor( + "broken-intent-model", + model_version="broken-v1", + classifier=BrokenClassifier(), + ) + intent = DeterministicPlaceChatIntentParser( + contextual_extractor=extractor, + ).parse( + message="recomiendame una cafeteria tranquila", + state=ConversationState(), + has_user_location=True, + ) + + assert intent.action == "recommendations" + assert intent.target_category == "cafe" + assert "tranquilo" in intent.soft_preferences + assert intent.intent_model_version == ( + "deterministic-open-v2+bert-fallback:broken-v1" + ) + + +def test_successful_bert_frame_does_not_reapply_manual_category_aliases() -> None: + extractor = BertPlaceIntentExtractor( + "places-intent-test", + classifier=lambda _text: [], + ) + + intent = DeterministicPlaceChatIntentParser( + contextual_extractor=extractor, + ).parse( + message="cafeteria con una variacion no etiquetada", + state=ConversationState(), + has_user_location=True, + ) + + assert intent.target_category is None + assert intent.category_values == () + assert "cafeteria" in intent.semantic_query + assert intent.category_source == "unresolved" + + +def test_partial_bert_frame_fuses_missing_context_slots_independently() -> None: + message = "cafeteria tranquila sin ruido cerca del centro" + category_start = message.index("cafeteria") + extractor = BertPlaceIntentExtractor( + "places-intent-test", + classifier=lambda _text: [ + { + "entity": "B-CATEGORY", + "score": 0.97, + "start": category_start, + "end": category_start + len("cafeteria"), + } + ], + ) + + intent = DeterministicPlaceChatIntentParser( + contextual_extractor=extractor, + ).parse( + message=message, + state=ConversationState(), + has_user_location=True, + ) + + # The model-provided open category is preserved (no taxonomy remap), while + # omitted slots use narrow rollout fallbacks instead of disappearing. + assert intent.target_category == "cafeteria" + assert intent.location.scope == "target_results" + assert intent.location.source == "current_message" + assert intent.location.anchor_text == "centro" + assert intent.exclusions == ("ruido",) + assert "tranquilo" in intent.soft_preferences + assert "centro" not in intent.semantic_query + assert "ruido" not in intent.semantic_query + + +def test_bert_preference_reference_and_radius_remain_open_raw_signals() -> None: + message = "busco salones de te estilo Casa Azul con terraza maximo 2 km" + + def token(value: str, entity: str) -> dict[str, object]: + start = message.index(value) + return { + "entity": entity, + "score": 0.94, + "start": start, + "end": start + len(value), + } + + class Classifier: + def __call__(self, _text: str) -> list[dict[str, object]]: + return [ + token("salones", "B-CATEGORY"), + token("de", "I-CATEGORY"), + token("te", "I-CATEGORY"), + token("Casa", "B-REFERENCE"), + token("Azul", "I-REFERENCE"), + token("terraza", "B-PREFERENCE"), + token("2", "B-RADIUS"), + token("km", "I-RADIUS"), + ] + + intent = DeterministicPlaceChatIntentParser( + contextual_extractor=BertPlaceIntentExtractor( + "places-intent-test", + classifier=Classifier(), + ), + ).parse( + message=message, + state=ConversationState(), + has_user_location=True, + ) + + assert intent.target_category == "salones de te" + assert intent.soft_preferences == ("terraza",) + assert intent.reference is not None + assert intent.reference.entity == "Casa Azul" + assert intent.location.source == "user_current" + assert intent.location.radius_meters == 2000 + assert intent.location.strict_radius is True + + +def test_dynamic_category_clarification_selection_is_not_taxonomy_gated() -> None: + pending = PendingClarification( + clarification_id="dynamic-category-1", + kind="intent_category", + options=( + PendingClarificationOption( + option_id="donuts", + value="donas artesanales", + label="Donas artesanales", + ), + PendingClarificationOption( + option_id="desserts", + value="postres frios", + label="Postres frios", + ), + ), + ) + state = ConversationState( + soft_preferences=("tranquilo",), + exclusions=("ruido",), + pending_clarification=pending, + city="Puebla", + ) + + intent = DeterministicPlaceChatIntentParser().parse( + message="Donas artesanales", + state=state, + has_user_location=True, + clarification_choice=ClarificationChoice( + clarification_id="dynamic-category-1", + option_id="donuts", + ), + ) + + assert intent.action == "recommendations" + assert intent.target_category == "donas artesanales" + assert intent.category_values == ("donas artesanales",) + assert intent.raw_category_phrase == "donas artesanales" + assert intent.soft_preferences == ("tranquilo",) + assert intent.exclusions == ("ruido",) + assert intent.hard_filters["city"] == "Puebla" + assert intent.state_patch.target_category == "donas artesanales" + assert intent.state_patch.clear_pending_clarification is True + assert "dynamic-clarification-v1" in intent.intent_model_version + + +def test_open_catalog_category_values_are_rehydrated_on_following_turn() -> None: + class Concept: + id = "donut_shop" + storage_values = ("bakery", "dessert") + + class ActivityClassifier: + concepts = (Concept(),) + + def classify(self, text: str) -> PlaceCategoryInference | None: + if "donas" in text: + return PlaceCategoryInference( + category="donut_shop", + confidence=0.91, + source="semantic_activity", + category_values=("bakery", "dessert"), + label="Donas", + ) + return None + + parser = DeterministicPlaceChatIntentParser( + activity_classifier=ActivityClassifier(), + ) + first = parser.parse( + message="quiero donas glaseadas", + state=ConversationState(), + has_user_location=True, + ) + continued = parser.parse( + message="mas barato", + state=ConversationState(target_category=first.target_category), + has_user_location=True, + ) + + assert first.target_category == "donut_shop" + assert first.category_values == ("bakery", "dessert") + assert continued.target_category == "donut_shop" + assert continued.category_values == ("bakery", "dessert") + + +def test_generic_short_request_never_produces_an_empty_embedding_query() -> None: + intent = DeterministicPlaceChatIntentParser().parse( + message="dame opciones", + state=ConversationState(), + has_user_location=True, + ) + + assert intent.action == "recommendations" + assert intent.semantic_query == "dame opciones" diff --git a/tests/test_place_chat_recommendations_use_case.py b/tests/test_place_chat_recommendations_use_case.py index 8568e966955310f6fdd46e2f15e2950ab5a6ae78..06470e19cb98f87c75c9bdefbcc0b729621590c3 100644 --- a/tests/test_place_chat_recommendations_use_case.py +++ b/tests/test_place_chat_recommendations_use_case.py @@ -1,3 +1,5 @@ +from dataclasses import replace + import pytest from app.modules.places.application.use_cases.chat_place_recommendations import ( @@ -10,8 +12,11 @@ from app.modules.places.domain.clarifications import ( from app.modules.places.domain.chat_intent import ( ClarificationChoice, ConversationState, + ExplicitTargetLocation, + IntentAlternative, PendingClarification, PendingClarificationOption, + PlaceChatCandidate, PlaceReference, ResolvedPlaceAnchor, ) @@ -33,21 +38,32 @@ from app.shared.nlp.llm.mock import MockLLMProvider from app.shared.nlp.llm.output_guard import PlaceChatOutputGuard -def build_use_case(*, llm_enabled: bool, anchor_resolver=None): +def build_use_case( + *, + llm_enabled: bool, + anchor_resolver=None, + intent_parser=None, + retriever=None, + nearby_place_provider=None, +): embedding = MockEmbeddingProvider(dimension=16) return ChatPlaceRecommendationsUseCase( - intent_parser=DeterministicPlaceChatIntentParser(), + intent_parser=intent_parser or DeterministicPlaceChatIntentParser(), anchor_resolver=anchor_resolver or MockPlaceAnchorResolver(), - retriever=HybridContentPlaceChatRetriever( - embedding_provider=embedding, - place_repository=MockPlaceVectorRepository(embedding), - minimum_content_score=0.20, + retriever=( + retriever + or HybridContentPlaceChatRetriever( + embedding_provider=embedding, + place_repository=MockPlaceVectorRepository(embedding), + minimum_content_score=0.20, + ) ), llm_provider=MockLLMProvider(), output_guard=PlaceChatOutputGuard(), ranking_version="places-chat-v2", taxonomy_version="places-taxonomy-v1", llm_enabled=llm_enabled, + nearby_place_provider=nearby_place_provider, ) @@ -68,6 +84,29 @@ def test_public_anchor_buttons_are_bounded_for_the_main_api_contract() -> None: assert len(clarification.options[0].message) <= 200 +def test_open_category_buttons_use_safe_ids_and_preserve_raw_values() -> None: + from app.modules.places.domain.clarifications import new_category_clarification + + pending = new_category_clarification( + ("donas artesanales", "café de especialidad"), + kind="intent_category", + ) + clarification = to_public_clarification(pending) + + assert [option.option_id for option in pending.options] == [ + "donas_artesanales_1", + "cafe_de_especialidad_2", + ] + assert [option.value for option in pending.options] == [ + "donas artesanales", + "café de especialidad", + ] + assert [option.option_id for option in clarification.options] == [ + "donas_artesanales_1", + "cafe_de_especialidad_2", + ] + + @pytest.mark.asyncio async def test_llm_cannot_change_action_or_candidates() -> None: without_llm = await build_use_case(llm_enabled=False).execute( @@ -95,6 +134,326 @@ async def test_llm_cannot_change_action_or_candidates() -> None: assert with_llm.guard_reason == "candidate_name_deferred_to_main_api" +class LowConfidenceIntentParser: + def __init__(self, alternatives: tuple[IntentAlternative, ...]) -> None: + self._alternatives = alternatives + + def parse( + self, + message, + state, + has_user_location, + clarification_choice=None, + ): + del message, clarification_choice + parsed = DeterministicPlaceChatIntentParser().parse( + message="una panaderia tranquila", + state=state, + has_user_location=has_user_location, + ) + return replace( + parsed, + semantic_query="algo dulce tranquilo", + confidence=0.45, + alternatives=self._alternatives, + category_source="semantic_activity", + ) + + +class CategoryEvidenceRetriever: + def __init__(self) -> None: + self.calls = 0 + + async def retrieve(self, intent, limit): + del intent, limit + self.calls += 1 + return [ + PlaceChatCandidate( + place_id="bakery_1", + name="Panaderia Local", + category="bakery", + content_score=0.62, + semantic_score=0.64, + lexical_score=0.20, + match_level="broad", + matched_reasons=("algo dulce",), + ) + ] + + +@pytest.mark.asyncio +async def test_low_confidence_uses_dynamic_category_hypotheses() -> None: + parser = LowConfidenceIntentParser( + alternatives=( + IntentAlternative( + key="bakery", + description="Panaderias artesanales", + confidence=0.68, + ), + IntentAlternative( + key="ice_cream", + description="Postres y heladerias", + confidence=0.64, + ), + ) + ) + + result = await build_use_case( + llm_enabled=False, + intent_parser=parser, + ).execute( + message="quiero algo dulce y tranquilo", + state=ConversationState(), + user_latitude=16.7531, + user_longitude=-93.1156, + candidate_limit=5, + result_limit=3, + ) + + assert result.action == "clarification" + assert result.clarification is not None + assert [option.option_id for option in result.clarification.options] == [ + "bakery", + "ice_cream", + ] + assert [option.label for option in result.clarification.options] == [ + "Panaderias artesanales", + "Postres y heladerias", + ] + assert result.state_patch["target_category"] == "bakery" + assert result.state_patch["soft_preferences"] == ["tranquilo"] + assert [ + option["id"] + for option in result.state_patch["pending_clarification"]["options"] + ] == ["bakery", "ice_cream"] + + +@pytest.mark.asyncio +async def test_low_confidence_without_top_k_uses_retrieval_evidence_not_fixed_menu() -> None: + retriever = CategoryEvidenceRetriever() + result = await build_use_case( + llm_enabled=False, + intent_parser=LowConfidenceIntentParser(alternatives=()), + retriever=retriever, + ).execute( + message="quiero algo dulce y tranquilo", + state=ConversationState(), + user_latitude=16.7531, + user_longitude=-93.1156, + candidate_limit=5, + result_limit=3, + ) + + assert retriever.calls == 1 + assert result.action == "recommendations" + assert result.clarification is None + assert [candidate.place_id for candidate in result.candidates] == ["bakery_1"] + assert "intent_confidence" in result.unresolved + assert "restaurante" not in result.message.casefold() + assert "cafeteria" not in result.message.casefold() + assert "parque" not in result.message.casefold() + assert result.state_patch["soft_preferences"] == ["tranquilo"] + + +@pytest.mark.asyncio +async def test_low_quality_hypotheses_do_not_create_an_arbitrary_menu() -> None: + parser = LowConfidenceIntentParser( + alternatives=( + IntentAlternative("bakery", "Panaderias", 0.55), + IntentAlternative("outdoors", "Espacios abiertos", 0.52), + ) + ) + + result = await build_use_case( + llm_enabled=False, + intent_parser=parser, + retriever=CategoryEvidenceRetriever(), + ).execute( + message="algo dificil de interpretar", + state=ConversationState(), + user_latitude=16.7531, + user_longitude=-93.1156, + candidate_limit=5, + result_limit=3, + ) + + assert result.action == "recommendations" + assert result.clarification is None + assert "intent_confidence" in result.unresolved + + +class WeakEvidenceRetriever: + async def retrieve(self, intent, limit): + del intent, limit + return [ + PlaceChatCandidate( + place_id="weak_1", + name="Coincidencia tenue", + category="cafe", + content_score=0.0, + semantic_score=0.0, + lexical_score=0.0, + match_level="broad", + matched_reasons=(), + metadata={ + "retrieval_diagnostics": { + "meets_minimum_content_score": False, + } + }, + ) + ] + + +@pytest.mark.asyncio +async def test_weak_candidates_are_returned_as_reviewable_not_confident() -> None: + result = await build_use_case( + llm_enabled=False, + retriever=WeakEvidenceRetriever(), + ).execute( + message="una cafeteria", + state=ConversationState(), + user_latitude=16.7531, + user_longitude=-93.1156, + candidate_limit=5, + result_limit=3, + ) + + assert result.action == "recommendations" + assert result.unresolved == ("retrieval_evidence",) + assert [candidate.place_id for candidate in result.candidates] == ["weak_1"] + assert "evidencia" in result.message.casefold() + + +class RecordingNearbyProvider: + def __init__(self) -> None: + self.call = None + + async def get_nearby_place_ids(self, latitude, longitude, radius_meters): + self.call = (latitude, longitude, radius_meters) + return {"near_2", "near_1"} + + +class RecordingFilteredRetriever: + def __init__(self) -> None: + self.intent = None + + async def retrieve(self, intent, limit): + del limit + self.intent = intent + return [ + PlaceChatCandidate( + place_id="near_1", + name="Cercano", + category="cafe", + content_score=0.8, + semantic_score=0.8, + lexical_score=0.4, + match_level="exact", + matched_reasons=("cafe",), + metadata={ + "retrieval_diagnostics": { + "meets_minimum_content_score": True, + } + }, + ) + ] + + +@pytest.mark.asyncio +async def test_current_location_ids_are_applied_before_content_retrieval() -> None: + nearby = RecordingNearbyProvider() + retriever = RecordingFilteredRetriever() + + result = await build_use_case( + llm_enabled=False, + retriever=retriever, + nearby_place_provider=nearby, + ).execute( + message="una cafeteria", + state=ConversationState(), + user_latitude=16.7531, + user_longitude=-93.1156, + candidate_limit=5, + result_limit=3, + ) + + assert result.action == "recommendations" + assert nearby.call == (16.7531, -93.1156, 5_000) + assert retriever.intent.hard_filters["place_ids"] == ("near_1", "near_2") + + +class CoordinateAnchorResolver: + async def resolve(self, text, city, state, limit=3): + del text, city, state, limit + return [ + ResolvedPlaceAnchor( + place_id="anchor_centro", + name="Centro", + latitude=16.75, + longitude=-93.12, + score=0.95, + ) + ] + + +@pytest.mark.asyncio +async def test_explicit_anchor_coordinates_filter_before_retrieval() -> None: + nearby = RecordingNearbyProvider() + retriever = RecordingFilteredRetriever() + + result = await build_use_case( + llm_enabled=False, + anchor_resolver=CoordinateAnchorResolver(), + retriever=retriever, + nearby_place_provider=nearby, + ).execute( + message="una cafeteria cerca del centro", + state=ConversationState(), + user_latitude=16.70, + user_longitude=-93.10, + candidate_limit=5, + result_limit=3, + ) + + assert result.action == "recommendations" + assert result.location_directive.source == "explicit_anchor" + assert nearby.call == (16.75, -93.12, 5_000) + assert retriever.intent.hard_filters["place_ids"] == ("near_1", "near_2") + + +@pytest.mark.asyncio +async def test_persisted_resolved_anchor_keeps_nearby_filter_on_followup() -> None: + nearby = RecordingNearbyProvider() + retriever = RecordingFilteredRetriever() + + result = await build_use_case( + llm_enabled=False, + anchor_resolver=CoordinateAnchorResolver(), + retriever=retriever, + nearby_place_provider=nearby, + ).execute( + message="otra cafeteria tranquila", + state=ConversationState( + explicit_target_location=ExplicitTargetLocation( + anchor_text="Centro", + place_id="anchor_centro", + label="Centro", + radius_meters=3_000, + strict_radius=True, + ) + ), + user_latitude=None, + user_longitude=None, + candidate_limit=5, + result_limit=3, + ) + + assert result.action == "recommendations" + assert result.location_directive.source == "state_anchor" + assert nearby.call == (16.75, -93.12, 3_000) + assert retriever.intent.hard_filters["place_ids"] == ("near_1", "near_2") + + class AmbiguousAnchorResolver: async def resolve(self, text, city, state, limit=3): del text, city, state, limit @@ -308,7 +667,7 @@ class MixedCategoryRepository: ("shopping", "shopping_exact"), ), ) -async def test_short_category_queries_survive_sparse_scores_without_family_noise( +async def test_short_category_queries_survive_sparse_scores_without_being_filtered( message: str, expected_id: str, ) -> None: @@ -325,11 +684,23 @@ async def test_short_category_queries_survive_sparse_scores_without_family_noise candidates = await retriever.retrieve(intent=intent, limit=5) - assert [candidate.place_id for candidate in candidates] == [expected_id] + candidate_ids = [candidate.place_id for candidate in candidates] + assert expected_id in candidate_ids + assert set(candidate_ids) == { + "park_exact", + "cinema_compatible", + "entertainment_noise", + "shopping_exact", + "family_noise", + } + assert all( + candidate.metadata["retrieval_diagnostics"]["content_quality"] == "weak" + for candidate in candidates + ) @pytest.mark.asyncio -async def test_hard_category_and_controlled_theme_relaxation() -> None: +async def test_category_is_ranking_evidence_instead_of_a_hard_filter() -> None: embedding = MockEmbeddingProvider(dimension=16) retriever = HybridContentPlaceChatRetriever( embedding_provider=embedding, @@ -344,16 +715,18 @@ async def test_hard_category_and_controlled_theme_relaxation() -> None: candidates = await retriever.retrieve(intent=intent, limit=10) - assert [candidate.place_id for candidate in candidates] == [ + assert {candidate.place_id for candidate in candidates} == { + "park", "exact", "family", "broad", - ] - assert [candidate.match_level for candidate in candidates] == [ - "exact", - "family", - "broad", - ] + } + diagnostics = { + candidate.place_id: candidate.metadata["retrieval_diagnostics"] + for candidate in candidates + } + assert diagnostics["park"]["category_match"] == "none" + assert diagnostics["exact"]["category_match"] == "exact" class ReferenceLocationResolver: diff --git a/tests/test_place_embedding_configuration.py b/tests/test_place_embedding_configuration.py new file mode 100644 index 0000000000000000000000000000000000000000..841ca84899982f3200242987447f565430ebc65e --- /dev/null +++ b/tests/test_place_embedding_configuration.py @@ -0,0 +1,182 @@ +import json + +import pytest +from pydantic import ValidationError + +from app.modules.places.api import dependencies as place_dependencies +from app.modules.places.infrastructure.bert_intent_extractor import ( + BertPlaceIntentExtractor, +) +from app.modules.places.infrastructure.place_category_catalog import ( + load_place_category_concepts, +) +from app.shared.config.settings import Settings +from app.shared.nlp.embeddings.factory import create_place_embedding_provider +from app.shared.nlp.embeddings.mock import MockEmbeddingProvider +from app.shared.nlp.embeddings.sentence_transformer import ( + SentenceTransformerEmbeddingProvider, +) + + +def test_places_embedding_provider_is_independent_from_global_dimension() -> None: + settings = Settings( + _env_file=None, + ENV="local", + EMBEDDING_PROVIDER="mock", + EMBEDDING_DIMENSION=16, + PLACES_EMBEDDING_PROVIDER="mock", + PLACES_EMBEDDING_DIMENSION=384, + PLACES_EMBEDDING_MODEL="places-test", + ) + + provider = create_place_embedding_provider(settings) + + assert isinstance(provider, MockEmbeddingProvider) + assert provider.dimension == 384 + assert settings.embedding_dimension == 16 + + +def test_blank_optional_places_runtime_values_are_normalized_to_none() -> None: + settings = Settings( + _env_file=None, + PLACES_EMBEDDING_DEVICE=" ", + PLACES_CATEGORY_CATALOG_PATH="", + PLACES_PGVECTOR_HYBRID_FUNCTION=" ", + ) + + assert settings.places_embedding_device is None + assert settings.places_category_catalog_path is None + assert settings.places_pgvector_hybrid_function is None + + +def test_sentence_transformer_factory_configures_query_and_passage_prefixes() -> None: + settings = Settings( + _env_file=None, + ENV="local", + PLACES_EMBEDDING_PROVIDER="sentence_transformer", + PLACES_EMBEDDING_DIMENSION=768, + PLACES_EMBEDDING_MODEL="intfloat/multilingual-e5-base", + PLACES_EMBEDDING_QUERY_PREFIX="query: ", + PLACES_EMBEDDING_PASSAGE_PREFIX="passage: ", + ) + + query = create_place_embedding_provider(settings, text_role="query") + passage = create_place_embedding_provider(settings, text_role="passage") + + assert isinstance(query, SentenceTransformerEmbeddingProvider) + assert isinstance(passage, SentenceTransformerEmbeddingProvider) + assert query.text_prefix == "query: " + assert passage.text_prefix == "passage: " + assert query.is_loaded is False + assert passage.is_loaded is False + + +def test_quoted_e5_prefixes_preserve_the_significant_space(tmp_path) -> None: + env_file = tmp_path / ".env" + env_file.write_text( + 'PLACES_EMBEDDING_QUERY_PREFIX="query: "\n' + 'PLACES_EMBEDDING_PASSAGE_PREFIX="passage: "\n', + encoding="utf-8", + ) + + settings = Settings(_env_file=env_file) + + assert settings.places_embedding_query_prefix == "query: " + assert settings.places_embedding_passage_prefix == "passage: " + + +def test_places_embedding_provider_rejects_unknown_backend() -> None: + with pytest.raises(ValidationError, match="PLACES_EMBEDDING_PROVIDER"): + Settings( + _env_file=None, + ENV="local", + PLACES_EMBEDDING_PROVIDER="closed_taxonomy_magic", + ) + + +def test_external_place_concept_catalog_is_data_driven(tmp_path) -> None: + path = tmp_path / "concepts.json" + path.write_text( + json.dumps( + { + "concepts": [ + { + "id": "donut_shop", + "label": "Donas", + "description": "Lugar especializado en donas artesanales", + "examples": ["algo dulce glaseado"], + "storage_values": ["bakery", "dessert"], + } + ] + } + ), + encoding="utf-8", + ) + + concepts = load_place_category_concepts(str(path)) + + assert [concept.id for concept in concepts] == ["donut_shop"] + assert concepts[0].storage_values == ("bakery", "dessert") + + +def test_place_chat_intent_provider_defaults_to_deterministic() -> None: + settings = Settings(_env_file=None, ENV="local") + + assert settings.places_chat_intent_provider == "deterministic" + assert settings.places_chat_bert_model_path is None + + +def test_place_chat_bert_provider_is_explicit_and_validated() -> None: + settings = Settings( + _env_file=None, + ENV="local", + PLACES_CHAT_INTENT_PROVIDER="BERT", + PLACES_CHAT_BERT_MODEL_PATH="models/places-intent", + PLACES_CHAT_BERT_MODEL_VERSION="intent-v3", + PLACES_CHAT_BERT_DEVICE="cpu", + PLACES_CHAT_BERT_MIN_TOKEN_CONFIDENCE=0.72, + ) + + assert settings.places_chat_intent_provider == "bert" + assert settings.places_chat_bert_model_path == "models/places-intent" + assert settings.places_chat_bert_model_version == "intent-v3" + assert settings.places_chat_bert_device == "cpu" + assert settings.places_chat_bert_min_token_confidence == pytest.approx(0.72) + + with pytest.raises(ValidationError, match="PLACES_CHAT_BERT_MODEL_PATH"): + Settings( + _env_file=None, + ENV="local", + PLACES_CHAT_INTENT_PROVIDER="bert", + ) + + +def test_place_chat_dependency_builds_bert_extractor_without_loading_it( + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings( + _env_file=None, + ENV="local", + PLACES_EMBEDDING_PROVIDER="mock", + PLACES_CHAT_INTENT_PROVIDER="bert", + PLACES_CHAT_BERT_MODEL_PATH="models/places-intent", + PLACES_CHAT_BERT_MODEL_VERSION="intent-v3", + PLACES_CHAT_BERT_DEVICE="cpu", + ) + monkeypatch.setattr(place_dependencies, "get_settings", lambda: settings) + monkeypatch.setattr( + place_dependencies, + "load_place_category_concepts", + lambda *_args, **_kwargs: (), + ) + place_dependencies.get_place_chat_intent_parser.cache_clear() + try: + parser = place_dependencies.get_place_chat_intent_parser() + extractor = parser._contextual_extractor + + assert isinstance(extractor, BertPlaceIntentExtractor) + assert extractor.model_name == "models/places-intent" + assert extractor.model_version == "intent-v3" + assert extractor.is_loaded is False + finally: + place_dependencies.get_place_chat_intent_parser.cache_clear() diff --git a/tests/test_places_use_cases.py b/tests/test_places_use_cases.py index b0c1fb80c20d411b2f97cebc035d3e3b6d66fb61..f8ccd20b14bbf9f2d0b90ee692f2f1e1d4e45da4 100644 --- a/tests/test_places_use_cases.py +++ b/tests/test_places_use_cases.py @@ -11,7 +11,10 @@ from app.modules.places.infrastructure.mock_place_repository import MockPlaceVec from app.shared.nlp.embeddings.mock import MockEmbeddingProvider from app.shared.nlp.llm.base import LLMProvider, LLMResult, PlaceResponseMode from app.shared.nlp.llm.mock import MockLLMProvider -from app.shared.nlp.llm.output_guard import DEFAULT_PLACE_CHAT_FALLBACK, PlaceChatOutputGuard +from app.shared.nlp.llm.output_guard import ( + LOW_CONFIDENCE_PLACE_CHAT_FALLBACK, + PlaceChatOutputGuard, +) @pytest.mark.asyncio @@ -243,9 +246,10 @@ async def test_chat_places_falls_back_when_llm_fails() -> None: limit=3, ) - assert result.message == DEFAULT_PLACE_CHAT_FALLBACK + assert result.message == LOW_CONFIDENCE_PLACE_CHAT_FALLBACK assert result.places assert result.metadata["used_llm"] is False + assert result.metadata["response_mode"] == "low_confidence" class FailingLLMProvider(LLMProvider): diff --git a/tests/test_semantic_activity_classifier.py b/tests/test_semantic_activity_classifier.py index a98bce578c0b5cebf959f02e2f4d4fc3a764d023..a62b4087facf96434bb26e6b3a71005d58450f57 100644 --- a/tests/test_semantic_activity_classifier.py +++ b/tests/test_semantic_activity_classifier.py @@ -1,6 +1,9 @@ from app.modules.places.infrastructure.semantic_activity_classifier import ( SemanticPlaceActivityClassifier, ) +from app.modules.places.infrastructure.open_vocabulary_category_classifier import ( + PlaceCategoryConcept, +) from app.shared.nlp.embeddings.base import EmbeddingProvider from app.shared.nlp.preprocessing.text import prepare_for_embedding @@ -45,9 +48,26 @@ class ControlledEmbeddingProvider(EmbeddingProvider): return [self.embed_text(text) for text in texts] +CONCEPTS = ( + PlaceCategoryConcept( + id="restaurant", + label="comida restaurante", + description="hambre tacos pizza sushi antojo platillo cocina", + storage_values=("restaurant",), + ), + PlaceCategoryConcept( + id="sports", + label="ejercicio gimnasio deporte", + description="entrenar futbol cancha nadar fitness", + storage_values=("sports",), + ), +) + + def test_classifier_finds_activity_inside_a_long_message() -> None: classifier = SemanticPlaceActivityClassifier( embedding_provider=ControlledEmbeddingProvider(), + concepts=CONCEPTS, ) result = classifier.classify( @@ -62,6 +82,7 @@ def test_classifier_finds_activity_inside_a_long_message() -> None: def test_classifier_abstains_when_the_best_categories_are_tied() -> None: classifier = SemanticPlaceActivityClassifier( embedding_provider=ControlledEmbeddingProvider(), + concepts=CONCEPTS, ) result = classifier.classify("mezcla") @@ -72,6 +93,7 @@ def test_classifier_abstains_when_the_best_categories_are_tied() -> None: def test_classifier_abstains_for_a_generic_single_word_request() -> None: classifier = SemanticPlaceActivityClassifier( embedding_provider=ControlledEmbeddingProvider(), + concepts=CONCEPTS, ) assert classifier.classify("salir") is None diff --git a/tests/test_sentence_transformer_embeddings.py b/tests/test_sentence_transformer_embeddings.py new file mode 100644 index 0000000000000000000000000000000000000000..683b4f73464d3842887652c7aabd16fe12b724fc --- /dev/null +++ b/tests/test_sentence_transformer_embeddings.py @@ -0,0 +1,223 @@ +import math + +import pytest + +from app.shared.nlp.embeddings.sentence_transformer import ( + SentenceTransformerDimensionError, + SentenceTransformerEmbeddingProvider, + SentenceTransformerInferenceError, + SentenceTransformerModelLoadError, +) + + +class FakeSentenceTransformer: + def __init__( + self, + vectors: dict[str, list[float]], + dimension: int = 3, + ) -> None: + self.vectors = vectors + self.dimension = dimension + self.calls: list[tuple[list[str], dict[str, object]]] = [] + + def get_sentence_embedding_dimension(self) -> int: + return self.dimension + + def encode(self, sentences: list[str], **kwargs: object) -> list[list[float]]: + self.calls.append((list(sentences), kwargs)) + return [self.vectors[text] for text in sentences] + + +def test_provider_loads_lazily_and_batches_non_empty_texts() -> None: + model = FakeSentenceTransformer( + { + "query: donas": [3.0, 4.0, 0.0], + "query: cafecito": [0.0, 0.0, 2.0], + } + ) + loader_calls: list[tuple[str, str | None]] = [] + + def loader(name: str, device: str | None) -> FakeSentenceTransformer: + loader_calls.append((name, device)) + return model + + provider = SentenceTransformerEmbeddingProvider( + "domain-model", + expected_dimension=3, + batch_size=8, + device="cpu", + text_prefix="query: ", + model_loader=loader, + ) + + assert provider.is_loaded is False + assert provider.dimension == 3 + assert loader_calls == [] + + embeddings = provider.embed_batch([" donas ", " ", "cafecito"]) + + assert provider.is_loaded is True + assert loader_calls == [("domain-model", "cpu")] + assert embeddings[0] == pytest.approx([0.6, 0.8, 0.0]) + assert embeddings[1] == [0.0, 0.0, 0.0] + assert embeddings[2] == pytest.approx([0.0, 0.0, 1.0]) + assert model.calls == [ + ( + ["query: donas", "query: cafecito"], + { + "batch_size": 8, + "convert_to_numpy": True, + "normalize_embeddings": False, + "show_progress_bar": False, + }, + ) + ] + + +def test_empty_text_returns_zero_without_loading_model() -> None: + def loader( + _name: str, _device: str | None + ) -> FakeSentenceTransformer: + raise AssertionError("empty input must not load the model") + + provider = SentenceTransformerEmbeddingProvider( + "domain-model", + expected_dimension=3, + model_loader=loader, + ) + + assert provider.embed_text("\t \n") == [0.0, 0.0, 0.0] + assert provider.embed_batch([]) == [] + assert provider.is_loaded is False + + +def test_model_is_loaded_only_once_across_calls() -> None: + model = FakeSentenceTransformer({"first": [1.0, 0.0, 0.0], "second": [0.0, 1.0, 0.0]}) + load_count = 0 + + def loader( + _name: str, _device: str | None + ) -> FakeSentenceTransformer: + nonlocal load_count + load_count += 1 + return model + + provider = SentenceTransformerEmbeddingProvider( + "domain-model", + expected_dimension=3, + model_loader=loader, + ) + + provider.embed_text("first") + provider.embed_text("second") + + assert load_count == 1 + assert len(model.calls) == 2 + + +def test_provider_rejects_reported_model_dimension_mismatch() -> None: + model = FakeSentenceTransformer({}, dimension=4) + provider = SentenceTransformerEmbeddingProvider( + "wrong-model", + expected_dimension=3, + model_loader=lambda _name, _device: model, + ) + + with pytest.raises( + SentenceTransformerDimensionError, + match="model=4, configured=3", + ): + provider.embed_text("donas") + + assert provider.is_loaded is False + + +def test_provider_rejects_bad_inference_output() -> None: + model = FakeSentenceTransformer({"donas": [1.0, 2.0]}, dimension=3) + provider = SentenceTransformerEmbeddingProvider( + "bad-output-model", + expected_dimension=3, + model_loader=lambda _name, _device: model, + ) + + with pytest.raises( + SentenceTransformerDimensionError, + match="returned=2, expected=3", + ): + provider.embed_text("donas") + + +def test_provider_wraps_loader_and_inference_failures_with_context() -> None: + def broken_loader(_name: str, _device: str | None) -> FakeSentenceTransformer: + raise OSError("model cache unavailable") + + provider = SentenceTransformerEmbeddingProvider( + "missing-model", + expected_dimension=3, + model_loader=broken_loader, + ) + with pytest.raises( + SentenceTransformerModelLoadError, + match="missing-model.*model cache unavailable", + ): + provider.embed_text("donas") + + class BrokenModel(FakeSentenceTransformer): + def encode( + self, sentences: list[str], **kwargs: object + ) -> list[list[float]]: + raise RuntimeError("backend crashed") + + broken_model = BrokenModel({}, dimension=3) + provider = SentenceTransformerEmbeddingProvider( + "broken-model", + expected_dimension=3, + model_loader=lambda _name, _device: broken_model, + ) + with pytest.raises( + SentenceTransformerInferenceError, + match="broken-model.*backend crashed", + ): + provider.embed_text("donas") + + +def test_provider_rejects_zero_norm_and_non_finite_vectors() -> None: + zero_model = FakeSentenceTransformer({"zero": [0.0, 0.0, 0.0]}) + provider = SentenceTransformerEmbeddingProvider( + "zero-model", + expected_dimension=3, + model_loader=lambda _name, _device: zero_model, + ) + with pytest.raises(SentenceTransformerInferenceError, match="zero-norm"): + provider.embed_text("zero") + + nan_model = FakeSentenceTransformer({"nan": [math.nan, 0.0, 1.0]}) + provider = SentenceTransformerEmbeddingProvider( + "nan-model", + expected_dimension=3, + model_loader=lambda _name, _device: nan_model, + ) + with pytest.raises( + SentenceTransformerInferenceError, + match="NaN or infinity", + ): + provider.embed_text("nan") + + +def test_provider_validates_constructor_and_input() -> None: + with pytest.raises(ValueError, match="model_name_or_path"): + SentenceTransformerEmbeddingProvider(" ", expected_dimension=3) + with pytest.raises(ValueError, match="expected_dimension"): + SentenceTransformerEmbeddingProvider("model", expected_dimension=0) + with pytest.raises(ValueError, match="batch_size"): + SentenceTransformerEmbeddingProvider( + "model", expected_dimension=3, batch_size=0 + ) + + provider = SentenceTransformerEmbeddingProvider( + "model", + expected_dimension=3, + model_loader=lambda _name, _device: FakeSentenceTransformer({}), + ) + with pytest.raises(TypeError, match=r"texts\[1\].*int"): + provider.embed_batch(["valid", 42]) # type: ignore[list-item] diff --git a/tests/test_sql_contract.py b/tests/test_sql_contract.py index 2be2c99d2125e77018ab2e58a8318186aed13420..8fc50058d9b68f8308793c7fbdf353631407c3bf 100644 --- a/tests/test_sql_contract.py +++ b/tests/test_sql_contract.py @@ -103,3 +103,29 @@ def test_global_search_sql_does_not_directly_cast_untrusted_event_metadata() -> sql = path.read_text(encoding="utf-8") assert "NULLIF(e.metadata->>'start_time', '')::timestamptz" not in sql assert "(e.metadata->>'duration_minutes')::integer" not in sql + + +def test_places_semantic_index_is_additive_and_hybrid() -> None: + migration = Path( + "sql/migrations/20260716_02_places_semantic_v1.sql" + ).read_text(encoding="utf-8") + upper = migration.upper() + + assert "PLACE_EMBEDDINGS_SEMANTIC_V1" in upper + assert "VECTOR(768)" in upper + assert "SEARCH_PLACES_SEMANTIC_V1" in upper + assert "FULL OUTER JOIN" in upper + assert "WEBSEARCH_TO_TSQUERY" in upper + assert "USING HNSW" in upper + assert "AS MATERIALIZED" not in upper + assert "FROM PUBLIC.PLACE_EMBEDDINGS_SEMANTIC_V1 AS PLACE" in upper + assert "ALTER TABLE PUBLIC.PLACE_EMBEDDINGS" not in upper + assert "DROP TABLE" not in upper + + verifier = Path("sql/verify_places_semantic_v1.sql") + assert verifier.exists() + verifier_sql = verifier.read_text(encoding="utf-8").upper() + assert "SET TRANSACTION READ ONLY" in verifier_sql + assert "VECTOR(768)" in verifier_sql + assert "USING HNSW" in verifier_sql + assert "EXPLAIN (ANALYZE, BUFFERS" in verifier_sql diff --git a/tests/test_train_place_intent_bert.py b/tests/test_train_place_intent_bert.py new file mode 100644 index 0000000000000000000000000000000000000000..997f9e5539721c0808c06b687669b3f8e008d889 --- /dev/null +++ b/tests/test_train_place_intent_bert.py @@ -0,0 +1,201 @@ +import json + +import pytest + +from scripts.train_place_intent_bert import ( + IGNORED_LABEL_ID, + LABEL_TO_ID, + IntentTrainingExample, + LabeledSpan, + align_spans_to_token_offsets, + build_parser, + encode_examples, + read_jsonl, + validate_training_example, +) + + +def test_validate_training_example_preserves_raw_values_and_normalizes_slots() -> None: + text = "Quiero donas artesanales sin ruido cerca de Parque Mexico" + payload = { + "text": text, + "spans": [ + { + "start": text.index("donas"), + "end": text.index("artesanales") + len("artesanales"), + "slot": "category", + }, + { + "start": text.index("ruido"), + "end": text.index("ruido") + len("ruido"), + "label": "EXCLUSION", + }, + { + "start": text.index("Parque"), + "end": len(text), + "slot": "LOCATION", + }, + ], + } + + example = validate_training_example(payload, context="line 1") + + assert example.text == text + assert [span.slot for span in example.spans] == [ + "CATEGORY", + "EXCLUSION", + "LOCATION", + ] + assert [text[span.start : span.end] for span in example.spans] == [ + "donas artesanales", + "ruido", + "Parque Mexico", + ] + + +@pytest.mark.parametrize( + ("payload", "error"), + [ + ({"text": "donas", "spans": "CATEGORY"}, "spans must be a list"), + ( + { + "text": "donas", + "spans": [{"start": -1, "end": 5, "slot": "CATEGORY"}], + }, + "offsets must satisfy", + ), + ( + { + "text": "donas", + "spans": [{"start": 0, "end": 5, "slot": "RESTAURANT"}], + }, + "unsupported slot", + ), + ( + { + "text": "donas ricas", + "spans": [ + {"start": 0, "end": 7, "slot": "CATEGORY"}, + {"start": 6, "end": 11, "slot": "PREFERENCE"}, + ], + }, + "overlapping spans", + ), + ], +) +def test_validate_training_example_rejects_invalid_annotations( + payload: object, + error: str, +) -> None: + with pytest.raises(ValueError, match=error): + validate_training_example(payload) + + +def test_alignment_assigns_iob_to_subwords_and_ignores_special_tokens() -> None: + text = "Quiero donas sin ruido" + spans = ( + LabeledSpan(start=7, end=12, slot="CATEGORY"), + LabeledSpan(start=17, end=22, slot="EXCLUSION"), + ) + offsets = ( + (0, 0), + (0, 6), + (7, 9), + (9, 12), + (13, 16), + (17, 22), + (0, 0), + ) + + labels = align_spans_to_token_offsets(text, spans, offsets) + + assert labels == [ + IGNORED_LABEL_ID, + LABEL_TO_ID["O"], + LABEL_TO_ID["B-CATEGORY"], + LABEL_TO_ID["I-CATEGORY"], + LABEL_TO_ID["O"], + LABEL_TO_ID["B-EXCLUSION"], + IGNORED_LABEL_ID, + ] + + +def test_alignment_rejects_annotations_lost_by_truncation() -> None: + text = "donas cerca del parque" + spans = (LabeledSpan(start=16, end=22, slot="LOCATION"),) + + with pytest.raises(ValueError, match="possibly truncated.*LOCATION"): + align_spans_to_token_offsets( + text, + spans, + [(0, 0), (0, 5), (6, 11), (0, 0)], + ) + + +class FakeFastTokenizer: + is_fast = True + + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + def __call__(self, text: str, **kwargs: object) -> dict[str, object]: + self.calls.append({"text": text, **kwargs}) + return { + "input_ids": [101, 1001, 102], + "attention_mask": [1, 1, 1], + "offset_mapping": [(0, 0), (0, len(text)), (0, 0)], + } + + +def test_encode_examples_works_with_an_injected_tokenizer() -> None: + tokenizer = FakeFastTokenizer() + examples = [ + IntentTrainingExample( + text="cafecito", + spans=(LabeledSpan(start=0, end=8, slot="CATEGORY"),), + ) + ] + + encoded = encode_examples(examples, tokenizer, max_length=32) + + assert encoded == [ + { + "input_ids": [101, 1001, 102], + "attention_mask": [1, 1, 1], + "labels": [ + IGNORED_LABEL_ID, + LABEL_TO_ID["B-CATEGORY"], + IGNORED_LABEL_ID, + ], + } + ] + assert tokenizer.calls == [ + { + "text": "cafecito", + "truncation": True, + "max_length": 32, + "return_offsets_mapping": True, + } + ] + + +def test_read_jsonl_reports_the_failing_line(tmp_path) -> None: + dataset = tmp_path / "train.jsonl" + dataset.write_text( + json.dumps({"text": "donas", "spans": []}) + + "\n" + + "{invalid-json}\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match=r"train\.jsonl, line 2: invalid JSON"): + read_jsonl(dataset) + + +def test_help_is_built_without_importing_optional_ml_dependencies() -> None: + help_text = build_parser().format_help() + + assert "--train-file" in help_text + assert "--validation-file" in help_text + assert "--base-model" in help_text + assert "--learning-rate" in help_text