feat: add Python core and infra functions — PWA, geocoding, POI matching

Nuevas funciones Python: build_guide_prompt, generate_pwa_manifest,
generate_service_worker, match_pois_to_interests (core), nominatim_reverse_geocode,
ollama_chat, overpass_nearby_pois (infra). Incluye tests unitarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 23:47:19 +02:00
parent 89e443ab18
commit cbc4c5eafa
19 changed files with 1568 additions and 0 deletions
@@ -0,0 +1,83 @@
---
name: build_guide_prompt
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def build_guide_prompt(location: dict, pois: list[dict], interests: list[dict], user_query: str = \"\", lang: str = \"es\") -> list[dict]"
description: "Construye un prompt contextual para el LLM combinando ubicación actual, POIs cercanos rankeados e intereses del usuario. Retorna mensajes en formato OpenAI/Ollama."
tags: [prompt, llm, guide, location, pois, interests, openai, ollama, context]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: location
desc: "Dict con info de ubicación de nominatim_reverse_geocode. Campos esperados: display_name, street, neighbourhood, city, country. Los campos vacíos se omiten del prompt."
- name: pois
desc: "Lista de POIs rankeados por relevancia (ya filtrados por match_pois_to_interests). Cada POI tiene: name, category, score, matched_interests, lat, lon, tags. Se usan como máximo los primeros 5."
- name: interests
desc: "Lista de intereses del usuario. Cada interés tiene: name (str), keywords (list[str]), weight (float). Se usan los names para el system prompt."
- name: user_query
desc: "Pregunta opcional del usuario. Si está vacía o no se proporciona, se usa '¿Qué me puedes contar de esta zona?' como default."
- name: lang
desc: "Código de idioma para la respuesta del LLM (ej: 'es', 'en', 'fr'). Por defecto 'es'."
output: "Lista de dos mensajes en formato OpenAI/Ollama: [{'role': 'system', 'content': '...'}, {'role': 'user', 'content': '...'}]. El system message define el rol de guía local con los intereses y el idioma. El user message incluye la ubicación, los POIs relevantes y la query."
tested: true
tests:
- "con ubicacion completa pois y query"
- "sin pois solo ubicacion"
- "sin query usa default"
- "max 5 pois"
- "lang aparece en system"
- "location sin neighbourhood"
test_file_path: "python/functions/core/build_guide_prompt_test.py"
file_path: "python/functions/core/build_guide_prompt.py"
---
## Ejemplo
```python
messages = build_guide_prompt(
location={
"display_name": "Puerta del Sol, Madrid",
"city": "Madrid",
"neighbourhood": "Sol",
"street": "Puerta del Sol",
"country": "España",
},
pois=[
{
"name": "Museo del Prado",
"category": "museum",
"score": 1.0,
"matched_interests": ["arte"],
"lat": 40.41,
"lon": -3.70,
"tags": {},
}
],
interests=[{"name": "arte", "keywords": ["museo"], "weight": 1.0}],
user_query="¿Qué hay interesante por aquí?",
)
# [
# {"role": "system", "content": "Eres un guía local experto y amigable. El usuario está caminando por Madrid y le interesan: arte..."},
# {"role": "user", "content": "📍 Estoy en: Puerta del Sol, Madrid\nBarrio: Sol, Madrid\n\nLugares cercanos que me pueden interesar:\n- Museo del Prado (museum) — relevante para: arte\n\n¿Qué hay interesante por aquí?"}
# ]
```
## Notas
Función pura — no hace I/O, no muta los inputs, determinista.
El system message establece el rol del LLM como guía local con conocimiento de los intereses del usuario y el idioma de respuesta. Incluye instrucción de ser conciso (3-4 frases) y conversacional.
El user message combina:
- Ubicación completa del usuario (solo campos no vacíos)
- Lista de hasta 5 POIs con nombre, categoría y los intereses que satisfacen
- La query del usuario o un fallback genérico
Los campos de `location` vacíos o ausentes se omiten automáticamente del prompt para no generar ruido. Compatible con el formato de salida de `nominatim_reverse_geocode_py_infra` y `match_pois_to_interests`.
@@ -0,0 +1,91 @@
"""Construye un prompt contextual para el LLM combinando ubicación, POIs e intereses del usuario."""
def build_guide_prompt(
location: dict,
pois: list[dict],
interests: list[dict],
user_query: str = "",
lang: str = "es",
) -> list[dict]:
"""Construye un prompt contextual para el LLM como guía local.
Combina la ubicación actual del usuario, POIs cercanos rankeados por relevancia
e intereses del usuario para generar mensajes en formato OpenAI/Ollama.
Args:
location: Dict con info de ubicación de nominatim_reverse_geocode.
Campos esperados: display_name, street, neighbourhood, city, country, etc.
pois: Lista de POIs rankeados con campos: name, category, score,
matched_interests, lat, lon, tags. Ya filtrados y ordenados por score.
interests: Lista de intereses del usuario. Cada uno con: name, keywords, weight.
user_query: Pregunta opcional del usuario. Si está vacía, se usa un default.
lang: Idioma para la respuesta del LLM (ej: "es", "en", "fr").
Returns:
Lista de mensajes en formato OpenAI/Ollama:
[{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]
"""
# Extraer nombre de intereses para el system prompt
interest_names = [i.get("name", "") for i in interests if i.get("name")]
interests_str = ", ".join(interest_names) if interest_names else "lugares de interés general"
# Extraer ciudad para el system prompt
city = location.get("city", "") or location.get("town", "") or location.get("village", "") or "la zona"
# Construir system message
system_content = (
f"Eres un guía local experto y amigable. El usuario está caminando por {city} "
f"y le interesan: {interests_str}.\n"
"Tu objetivo es informar sobre lo que hay cerca, dar contexto histórico/cultural "
"relevante a sus intereses, y sugerir qué visitar.\n"
f"Responde en {lang}. Sé conciso (máximo 3-4 frases) y conversacional, "
"como si caminaras junto al usuario.\n"
"Si no hay POIs relevantes, comenta sobre el barrio o zona."
)
# Construir user message
user_parts = []
# Ubicación
display_name = location.get("display_name", "")
if display_name:
user_parts.append(f"📍 Estoy en: {display_name}")
neighbourhood = location.get("neighbourhood", "") or location.get("suburb", "")
city_label = location.get("city", "") or location.get("town", "") or location.get("village", "")
location_line_parts = [p for p in [neighbourhood, city_label] if p]
if location_line_parts:
user_parts.append(f"Barrio: {', '.join(location_line_parts)}")
# POIs (máximo 5)
top_pois = pois[:5]
if top_pois:
user_parts.append("")
user_parts.append("Lugares cercanos que me pueden interesar:")
for poi in top_pois:
name = poi.get("name", "Sin nombre")
category = poi.get("category", "")
matched = poi.get("matched_interests", [])
matched_str = ", ".join(matched) if matched else ""
poi_line = f"- {name}"
if category:
poi_line += f" ({category})"
if matched_str:
poi_line += f" — relevante para: {matched_str}"
user_parts.append(poi_line)
# Query del usuario
user_parts.append("")
if user_query and user_query.strip():
user_parts.append(user_query.strip())
else:
user_parts.append("¿Qué me puedes contar de esta zona?")
user_content = "\n".join(user_parts)
return [
{"role": "system", "content": system_content},
{"role": "user", "content": user_content},
]
@@ -0,0 +1,157 @@
"""Tests para build_guide_prompt."""
from build_guide_prompt import build_guide_prompt
def test_con_ubicacion_completa_pois_y_query():
location = {
"display_name": "Puerta del Sol, Madrid",
"street": "Puerta del Sol",
"neighbourhood": "Sol",
"city": "Madrid",
"country": "España",
}
pois = [
{
"name": "Museo del Prado",
"category": "museum",
"score": 1.0,
"matched_interests": ["arte"],
"lat": 40.41,
"lon": -3.70,
"tags": {},
},
{
"name": "Jardines del Retiro",
"category": "park",
"score": 0.8,
"matched_interests": ["naturaleza"],
"lat": 40.41,
"lon": -3.68,
"tags": {},
},
]
interests = [
{"name": "arte", "keywords": ["museo", "pintura"], "weight": 1.0},
{"name": "naturaleza", "keywords": ["parque", "jardín"], "weight": 0.8},
]
user_query = "¿Qué hay interesante por aquí?"
messages = build_guide_prompt(location, pois, interests, user_query)
assert len(messages) == 2
assert messages[0]["role"] == "system"
assert messages[1]["role"] == "user"
system = messages[0]["content"]
assert "Madrid" in system
assert "arte" in system
assert "naturaleza" in system
user = messages[1]["content"]
assert "Puerta del Sol, Madrid" in user
assert "Sol" in user
assert "Museo del Prado" in user
assert "museum" in user
assert "arte" in user
assert "Jardines del Retiro" in user
assert "¿Qué hay interesante por aquí?" in user
def test_sin_pois_solo_ubicacion():
location = {
"display_name": "Calle Mayor, Salamanca",
"street": "Calle Mayor",
"neighbourhood": "Centro Histórico",
"city": "Salamanca",
"country": "España",
}
pois = []
interests = [{"name": "historia", "keywords": ["catedral"], "weight": 1.0}]
user_query = "¿Algo interesante cerca?"
messages = build_guide_prompt(location, pois, interests, user_query)
assert len(messages) == 2
user = messages[1]["content"]
assert "Salamanca" in user
assert "Lugares cercanos" not in user
assert "¿Algo interesante cerca?" in user
def test_sin_query_usa_default():
location = {
"display_name": "Plaza Mayor, Sevilla",
"city": "Sevilla",
"neighbourhood": "Casco Antiguo",
"country": "España",
}
pois = [
{
"name": "Catedral de Sevilla",
"category": "cathedral",
"score": 0.95,
"matched_interests": ["historia", "arquitectura"],
"lat": 37.38,
"lon": -5.99,
"tags": {},
}
]
interests = [
{"name": "historia", "keywords": ["catedral"], "weight": 1.0},
{"name": "arquitectura", "keywords": ["edificio"], "weight": 0.9},
]
messages = build_guide_prompt(location, pois, interests)
assert len(messages) == 2
user = messages[1]["content"]
assert "¿Qué me puedes contar de esta zona?" in user
assert "Catedral de Sevilla" in user
assert "historia" in user
assert "arquitectura" in user
def test_max_5_pois():
location = {"display_name": "Barcelona", "city": "Barcelona"}
pois = [
{"name": f"Lugar {i}", "category": "poi", "score": 1.0 - i * 0.1, "matched_interests": ["cultura"], "lat": 0, "lon": 0, "tags": {}}
for i in range(10)
]
interests = [{"name": "cultura", "keywords": [], "weight": 1.0}]
messages = build_guide_prompt(location, pois, interests)
user = messages[1]["content"]
# Solo los primeros 5 POIs deben aparecer
for i in range(5):
assert f"Lugar {i}" in user
for i in range(5, 10):
assert f"Lugar {i}" not in user
def test_lang_aparece_en_system():
location = {"display_name": "Paris", "city": "Paris"}
pois = []
interests = [{"name": "art", "keywords": [], "weight": 1.0}]
messages = build_guide_prompt(location, pois, interests, lang="fr")
system = messages[0]["content"]
assert "fr" in system
def test_location_sin_neighbourhood():
location = {
"display_name": "Gran Via, Madrid",
"city": "Madrid",
}
pois = []
interests = []
messages = build_guide_prompt(location, pois, interests)
user = messages[1]["content"]
assert "Gran Via, Madrid" in user
# No debe romper si no hay neighbourhood
assert len(messages) == 2
@@ -0,0 +1,90 @@
---
name: generate_pwa_manifest
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def generate_pwa_manifest(name: str, short_name: str, description: str = \"\", start_url: str = \"/\", display: str = \"standalone\", orientation: str = \"portrait\", theme_color: str = \"#000000\", background_color: str = \"#000000\", icons: list[dict] | None = None, categories: list[str] | None = None, lang: str = \"es\") -> dict"
description: "Genera el contenido de un manifest.json para Progressive Web Apps segun el estandar W3C Web App Manifest. Retorna un dict listo para json.dumps(). Si icons es None genera placeholders estandar para 192x192 y 512x512."
tags: [pwa, manifest, web, json, frontend]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: name
desc: "Nombre completo de la aplicacion que aparece en la pantalla de instalacion."
- name: short_name
desc: "Nombre corto para launchers y pantallas de inicio (maximo 12 caracteres recomendado)."
- name: description
desc: "Descripcion de la aplicacion. Cadena vacia por defecto."
- name: start_url
desc: "URL de inicio al lanzar la PWA desde la pantalla de inicio. Por defecto '/'."
- name: display
desc: "Modo de visualizacion: standalone, fullscreen, minimal-ui o browser. Por defecto standalone."
- name: orientation
desc: "Orientacion preferida de pantalla: portrait, landscape o any. Por defecto portrait."
- name: theme_color
desc: "Color del tema en formato hex (#RRGGBB) para la barra de estado y UI del SO. Por defecto #000000."
- name: background_color
desc: "Color de fondo en formato hex (#RRGGBB) mostrado durante la carga. Por defecto #000000."
- name: icons
desc: "Lista de dicts con src, sizes, type y purpose. Si es None genera placeholders para 192x192 y 512x512."
- name: categories
desc: "Lista de categorias W3C (navigation, shopping, travel, etc). Si es None usa lista vacia."
- name: lang
desc: "Codigo de idioma BCP 47 del contenido principal de la app. Por defecto 'es'."
output: "Dict con la estructura completa W3C Web App Manifest: name, short_name, description, start_url, scope, display, orientation, theme_color, background_color, icons, categories y lang."
tested: true
tests:
- "valores por defecto generan manifest valido con icons placeholder"
- "icons custom se respetan"
- "todos los campos se incluyen correctamente"
- "categories none produce lista vacia"
- "json dumps compatible"
test_file_path: "python/functions/core/generate_pwa_manifest_test.py"
file_path: "python/functions/core/generate_pwa_manifest.py"
---
## Ejemplo
```python
manifest = generate_pwa_manifest(
name="Voice Guide",
short_name="VoiceGuide",
description="Guia por voz con POIs",
theme_color="#12b886",
background_color="#1a1b1e",
)
# {
# "name": "Voice Guide",
# "short_name": "VoiceGuide",
# "start_url": "/",
# "scope": "/",
# "display": "standalone",
# "orientation": "portrait",
# "theme_color": "#12b886",
# "background_color": "#1a1b1e",
# "icons": [
# {"src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable"},
# {"src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"},
# ],
# "categories": [],
# "lang": "es",
# }
import json
with open("public/manifest.json", "w") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
```
## Notas
Funcion pura — sin I/O, sin efectos secundarios. El caller es responsable de escribir el archivo con `json.dumps()` o `json.dump()`.
Los valores de `display` validos segun W3C son: `fullscreen`, `standalone`, `minimal-ui`, `browser`. La funcion no valida el valor — acepta cualquier string para permitir valores futuros del estandar.
El campo `scope` siempre se fija a `"/"`. Si se necesita un scope distinto, el caller puede modificar el dict resultante antes de serializar.
@@ -0,0 +1,67 @@
"""Genera el contenido de un manifest.json para Progressive Web Apps (W3C Web App Manifest)."""
def generate_pwa_manifest(
name: str,
short_name: str,
description: str = "",
start_url: str = "/",
display: str = "standalone",
orientation: str = "portrait",
theme_color: str = "#000000",
background_color: str = "#000000",
icons: list[dict] | None = None,
categories: list[str] | None = None,
lang: str = "es",
) -> dict:
"""Genera el contenido de un manifest.json para Progressive Web Apps.
Args:
name: Nombre completo de la aplicacion.
short_name: Nombre corto para launchers y pantallas de inicio.
description: Descripcion de la aplicacion.
start_url: URL de inicio de la PWA.
display: Modo de visualizacion (standalone, fullscreen, minimal-ui, browser).
orientation: Orientacion preferida (portrait, landscape, any).
theme_color: Color del tema en hex.
background_color: Color de fondo en hex.
icons: Lista de iconos con src, sizes, type y purpose. Si es None, usa placeholders estandar.
categories: Lista de categorias de la app. Si es None, usa lista vacia.
lang: Idioma principal de la aplicacion (BCP 47).
Returns:
Dict listo para json.dumps() con la estructura estandar W3C Web App Manifest.
"""
if icons is None:
icons = [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable",
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable",
},
]
if categories is None:
categories = []
return {
"name": name,
"short_name": short_name,
"description": description,
"start_url": start_url,
"scope": "/",
"display": display,
"orientation": orientation,
"theme_color": theme_color,
"background_color": background_color,
"icons": icons,
"categories": categories,
"lang": lang,
}
@@ -0,0 +1,94 @@
"""Tests para generate_pwa_manifest."""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from generate_pwa_manifest import generate_pwa_manifest
def test_valores_por_defecto_generan_manifest_valido_con_icons_placeholder():
result = generate_pwa_manifest(name="Mi App", short_name="App")
assert result["name"] == "Mi App"
assert result["short_name"] == "App"
assert result["start_url"] == "/"
assert result["scope"] == "/"
assert result["display"] == "standalone"
assert result["orientation"] == "portrait"
assert result["theme_color"] == "#000000"
assert result["background_color"] == "#000000"
assert result["lang"] == "es"
assert result["description"] == ""
assert result["categories"] == []
icons = result["icons"]
assert len(icons) == 2
assert icons[0]["src"] == "/icons/icon-192x192.png"
assert icons[0]["sizes"] == "192x192"
assert icons[0]["type"] == "image/png"
assert icons[0]["purpose"] == "any maskable"
assert icons[1]["src"] == "/icons/icon-512x512.png"
assert icons[1]["sizes"] == "512x512"
def test_icons_custom_se_respetan():
custom_icons = [
{"src": "/my-icon.png", "sizes": "256x256", "type": "image/png", "purpose": "any"},
]
result = generate_pwa_manifest(name="App", short_name="A", icons=custom_icons)
assert result["icons"] == custom_icons
assert len(result["icons"]) == 1
assert result["icons"][0]["src"] == "/my-icon.png"
assert result["icons"][0]["sizes"] == "256x256"
def test_todos_los_campos_se_incluyen_correctamente():
result = generate_pwa_manifest(
name="Voice Guide",
short_name="VoiceGuide",
description="Guia por voz con POIs",
start_url="/app",
display="fullscreen",
orientation="landscape",
theme_color="#12b886",
background_color="#1a1b1e",
categories=["navigation", "travel"],
lang="en",
)
assert result["name"] == "Voice Guide"
assert result["short_name"] == "VoiceGuide"
assert result["description"] == "Guia por voz con POIs"
assert result["start_url"] == "/app"
assert result["scope"] == "/"
assert result["display"] == "fullscreen"
assert result["orientation"] == "landscape"
assert result["theme_color"] == "#12b886"
assert result["background_color"] == "#1a1b1e"
assert result["categories"] == ["navigation", "travel"]
assert result["lang"] == "en"
# icons son placeholders por defecto
assert len(result["icons"]) == 2
def test_categories_none_produce_lista_vacia():
result = generate_pwa_manifest(name="App", short_name="A", categories=None)
assert result["categories"] == []
def test_json_dumps_compatible():
import json
result = generate_pwa_manifest(
name="Voice Guide",
short_name="VG",
theme_color="#12b886",
background_color="#1a1b1e",
)
serialized = json.dumps(result)
parsed = json.loads(serialized)
assert parsed["name"] == "Voice Guide"
assert parsed["theme_color"] == "#12b886"
@@ -0,0 +1,62 @@
---
name: generate_service_worker
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def generate_service_worker(cache_name: str = 'app-cache-v1', precache_urls: list[str] | None = None, network_first_patterns: list[str] | None = None) -> str"
description: "Genera el codigo JavaScript de un Service Worker basico para PWAs con estrategia cache-first para assets estaticos y network-first para API calls."
tags: [pwa, service-worker, javascript, cache, offline, codegen]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: cache_name
desc: "Nombre del cache para esta version del SW. Se usa como clave para identificar el cache activo y limpiar los anteriores en activate."
- name: precache_urls
desc: "Lista de URLs a pre-cachear durante el evento install. Por defecto: ['/', '/index.html']."
- name: network_first_patterns
desc: "Lista de patrones de URL (substrings) que usan estrategia network-first. Por defecto: ['/api/']. Cualquier URL que contenga alguno de estos patrones va a red primero."
output: "String con codigo JavaScript valido del Service Worker listo para escribir como sw.js. Incluye handlers de install (precacheo), activate (limpieza de caches antiguos) y fetch (cache-first / network-first segun patron)."
tested: true
tests:
- "SW por defecto contiene install/activate/fetch handlers"
- "Custom precache_urls aparecen en el codigo"
- "Custom network_first_patterns aparecen en el fetch handler"
- "El cache_name personalizado aparece en el codigo generado"
- "Los valores por defecto se usan cuando los parametros son None"
- "La funcion retorna un string no vacio"
- "El evento activate limpia caches antiguos"
- "La estrategia cache-first guarda respuestas de red en cache"
test_file_path: "python/functions/core/generate_service_worker_test.py"
file_path: "python/functions/core/generate_service_worker.py"
---
## Ejemplo
```python
sw_code = generate_service_worker(
cache_name="voice-guide-v1",
precache_urls=["/", "/index.html", "/manifest.json"],
network_first_patterns=["/api/"],
)
# Escribir al filesystem (operacion impura, fuera de esta funcion):
# with open("public/sw.js", "w") as f:
# f.write(sw_code)
```
## Notas
Funcion pura: no hace I/O, no tiene efectos secundarios, retorna siempre el mismo string dado los mismos inputs.
El codigo JS generado usa `self.skipWaiting()` en install y `self.clients.claim()` en activate para activacion inmediata del SW sin necesidad de refrescar la pagina.
La estrategia de fetch:
- **Network-first** (para `/api/` por defecto): intenta la red, guarda la respuesta exitosa en cache, y usa el cache como fallback si la red falla. Garantiza datos frescos cuando hay conexion.
- **Cache-first** (para todo lo demas): sirve desde cache si existe, si no va a red y guarda en cache. Optimo para assets estaticos (JS, CSS, imagenes) que cambian con el nombre de version.
Para invalidar el cache al desplegar una nueva version, basta con cambiar `cache_name` (ej: `app-cache-v2`). El evento activate limpia automaticamente cualquier cache con nombre distinto al actual.
@@ -0,0 +1,128 @@
"""Genera el codigo JavaScript de un Service Worker basico para PWAs."""
from __future__ import annotations
def generate_service_worker(
cache_name: str = "app-cache-v1",
precache_urls: list[str] | None = None,
network_first_patterns: list[str] | None = None,
) -> str:
"""Genera el codigo JavaScript de un Service Worker con estrategia cache-first
para assets estaticos y network-first para API calls.
Args:
cache_name: Nombre del cache a usar para esta version del SW.
precache_urls: URLs a pre-cachear en el evento install.
network_first_patterns: Patrones de URL que usan estrategia network-first.
Returns:
String con codigo JavaScript valido del Service Worker.
"""
if precache_urls is None:
precache_urls = ["/", "/index.html"]
if network_first_patterns is None:
network_first_patterns = ["/api/"]
precache_urls_js = _list_to_js_array(precache_urls, indent=2)
network_patterns_js = _list_to_js_array(network_first_patterns, indent=2)
network_check_js = _build_network_check(network_first_patterns)
return f"""\
// Service Worker — generado automaticamente
// Estrategia: cache-first para assets, network-first para API
const CACHE_NAME = '{cache_name}';
const PRECACHE_URLS = {precache_urls_js};
const NETWORK_FIRST_PATTERNS = {network_patterns_js};
// ── install ──────────────────────────────────────────────────────────────────
// Pre-cachea los assets estaticos esenciales al instalar el SW.
self.addEventListener('install', (event) => {{
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {{
return cache.addAll(PRECACHE_URLS);
}})
);
self.skipWaiting();
}});
// ── activate ─────────────────────────────────────────────────────────────────
// Limpia caches de versiones anteriores al activar la nueva version del SW.
self.addEventListener('activate', (event) => {{
event.waitUntil(
caches.keys().then((cacheNames) => {{
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
}})
);
self.clients.claim();
}});
// ── fetch ─────────────────────────────────────────────────────────────────────
// Intercepta peticiones:
// - URLs que coinciden con NETWORK_FIRST_PATTERNS → network-first con fallback a cache
// - El resto → cache-first con fallback a network (y guarda en cache la respuesta)
self.addEventListener('fetch', (event) => {{
const url = event.request.url;
const isNetworkFirst = {network_check_js};
if (isNetworkFirst) {{
// Network-first: intenta red, si falla usa cache
event.respondWith(
fetch(event.request)
.then((response) => {{
if (response && response.status === 200) {{
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {{
cache.put(event.request, responseClone);
}});
}}
return response;
}})
.catch(() => caches.match(event.request))
);
}} else {{
// Cache-first: sirve desde cache, si no existe va a red y guarda en cache
event.respondWith(
caches.match(event.request).then((cached) => {{
if (cached) {{
return cached;
}}
return fetch(event.request).then((response) => {{
if (response && response.status === 200) {{
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {{
cache.put(event.request, responseClone);
}});
}}
return response;
}});
}})
);
}}
}});
"""
def _list_to_js_array(items: list[str], indent: int = 0) -> str:
"""Convierte una lista Python a un array JS multilínea."""
if not items:
return "[]"
pad = " " * indent
inner = f",\n{pad} ".join(f"'{item}'" for item in items)
return f"[\n{pad} {inner},\n{pad}]"
def _build_network_check(patterns: list[str]) -> str:
"""Genera la expresion JS que comprueba si una URL matchea algun patron."""
if not patterns:
return "false"
checks = " || ".join(f"url.includes('{p}')" for p in patterns)
return checks
@@ -0,0 +1,62 @@
"""Tests para generate_service_worker."""
from generate_service_worker import generate_service_worker
def test_default_sw_contains_install_activate_fetch_handlers():
"""SW por defecto contiene install/activate/fetch handlers."""
code = generate_service_worker()
assert "addEventListener('install'" in code
assert "addEventListener('activate'" in code
assert "addEventListener('fetch'" in code
def test_custom_precache_urls_appear_in_code():
"""Custom precache_urls aparecen en el codigo."""
urls = ["/", "/index.html", "/manifest.json", "/offline.html"]
code = generate_service_worker(precache_urls=urls)
for url in urls:
assert f"'{url}'" in code
def test_custom_network_first_patterns_appear_in_fetch_handler():
"""Custom network_first_patterns aparecen en el fetch handler."""
patterns = ["/api/", "/graphql"]
code = generate_service_worker(network_first_patterns=patterns)
for pattern in patterns:
assert f"url.includes('{pattern}')" in code
def test_cache_name_appears_in_code():
"""El cache_name personalizado aparece en el codigo generado."""
code = generate_service_worker(cache_name="voice-guide-v1")
assert "voice-guide-v1" in code
def test_default_values_are_used_when_none():
"""Los valores por defecto se usan cuando los parametros son None."""
code = generate_service_worker()
assert "'/'" in code
assert "'/index.html'" in code
assert "url.includes('/api/')" in code
def test_returns_valid_string():
"""La funcion retorna un string no vacio."""
code = generate_service_worker()
assert isinstance(code, str)
assert len(code) > 0
def test_activate_cleans_old_caches():
"""El evento activate limpia caches antiguos."""
code = generate_service_worker(cache_name="my-cache-v2")
assert "caches.keys()" in code
assert "caches.delete(name)" in code
assert "name !== CACHE_NAME" in code
def test_cache_first_fallback_saves_to_cache():
"""La estrategia cache-first guarda respuestas de red en cache."""
code = generate_service_worker()
assert "cache.put(event.request" in code
@@ -0,0 +1,58 @@
---
name: match_pois_to_interests
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def match_pois_to_interests(pois: list[dict], interests: list[dict], max_results: int = 10) -> list[dict]"
description: "Filtra y rankea una lista de POIs segun un perfil de intereses del usuario. Calcula un score por categoria (0.5), nombre (0.3) y tags (0.2), multiplicado por el weight del interes. Retorna los top max_results POIs con score > 0, enriquecidos con score y matched_interests."
tags: [poi, ranking, filtering, interests, geospatial, recommendation, pure]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: pois
desc: "Lista de POIs, cada uno con campos id, lat, lon, name, category y tags (dict). Formato compatible con la salida de overpass_nearby_pois."
- name: interests
desc: "Lista de intereses del usuario, cada uno con name (str), keywords (list[str]) y weight (float entre 0 y 1). Los keywords se comparan contra categoria, nombre y tags del POI."
- name: max_results
desc: "Numero maximo de POIs a retornar. Por defecto 10."
output: "Lista de POIs filtrados y ordenados por score descendente. Cada POI incluye todos sus campos originales mas score (float) y matched_interests (list[str] con los nombres de intereses que matchearon). Lista vacia si ningun POI supera score 0."
tested: true
tests:
- "pois que matchean parcialmente por categoria y nombre"
- "pois sin match retorna lista vacia"
- "multiples intereses con diferentes weights acumulan score"
test_file_path: "python/functions/core/match_pois_to_interests_test.py"
file_path: "python/functions/core/match_pois_to_interests.py"
---
## Ejemplo
```python
pois = [
{"id": 1, "lat": 40.41, "lon": -3.70, "name": "Museo del Prado", "category": "museum", "tags": {"tourism": "museum"}},
{"id": 2, "lat": 40.42, "lon": -3.71, "name": "Bar El Tigre", "category": "bar", "tags": {"amenity": "bar"}},
]
interests = [{"name": "arte", "keywords": ["museo", "museum", "arte"], "weight": 1.0}]
result = match_pois_to_interests(pois, interests)
# [{"id": 1, ..., "score": 1.0, "matched_interests": ["arte"]}]
```
## Notas
Funcion pura: no hace I/O, no muta los inputs, es determinista.
**Calculo de score por interes:**
- Match de categoria: 0.5 si `poi["category"]` esta en los keywords del interes
- Match de nombre: 0.3 si algun keyword aparece como substring en `poi["name"]` (case-insensitive)
- Match de tags: 0.2 si algun keyword aparece en los valores de `poi["tags"]` (case-insensitive)
- El subtotal del interes se multiplica por `weight`
Si un POI matchea multiples intereses, los scores se suman. Un POI puede obtener score maximo de `sum(weight_i * 1.0)` sobre todos los intereses que matcheen.
El campo `tags` puede ser dict con valores string o numericos — todos se convierten a string para la comparacion.
@@ -0,0 +1,62 @@
"""Filtra y rankea POIs segun un perfil de intereses del usuario."""
def match_pois_to_interests(
pois: list[dict],
interests: list[dict],
max_results: int = 10,
) -> list[dict]:
"""Filtra y rankea una lista de POIs segun un perfil de intereses.
Calcula un score para cada POI basado en matches de categoria, nombre
y tags contra los keywords de cada interes. Solo retorna POIs con score > 0,
ordenados por score descendente, hasta max_results.
Args:
pois: Lista de POIs con campos id, lat, lon, name, category, tags.
interests: Lista de intereses con campos name, keywords y weight.
max_results: Numero maximo de resultados a retornar.
Returns:
Lista de POIs enriquecidos con score y matched_interests, ordenados
por score descendente. Lista vacia si ningun POI matchea.
"""
scored: list[dict] = []
for poi in pois:
total_score = 0.0
matched: list[str] = []
category = (poi.get("category") or "").lower()
name = (poi.get("name") or "").lower()
tags = poi.get("tags") or {}
tag_values = " ".join(str(v) for v in tags.values()).lower()
for interest in interests:
keywords: list[str] = [kw.lower() for kw in (interest.get("keywords") or [])]
weight: float = float(interest.get("weight", 1.0))
interest_name: str = interest.get("name", "")
interest_score = 0.0
# Match de categoria: 0.5 puntos si la categoria aparece en keywords
if any(kw == category for kw in keywords):
interest_score += 0.5
# Match de nombre: 0.3 puntos si algun keyword es substring del nombre
if any(kw in name for kw in keywords):
interest_score += 0.3
# Match de tags: 0.2 puntos si algun keyword aparece en valores de tags
if any(kw in tag_values for kw in keywords):
interest_score += 0.2
if interest_score > 0:
total_score += interest_score * weight
matched.append(interest_name)
if total_score > 0:
scored.append({**poi, "score": round(total_score, 6), "matched_interests": matched})
scored.sort(key=lambda p: p["score"], reverse=True)
return scored[:max_results]
@@ -0,0 +1,131 @@
"""Tests para match_pois_to_interests."""
from match_pois_to_interests import match_pois_to_interests
POIS_SAMPLE = [
{
"id": 1,
"lat": 40.41,
"lon": -3.70,
"name": "Museo del Prado",
"category": "museum",
"tags": {"tourism": "museum", "name": "Museo del Prado"},
},
{
"id": 2,
"lat": 40.42,
"lon": -3.71,
"name": "Bar El Tigre",
"category": "bar",
"tags": {"amenity": "bar", "name": "Bar El Tigre"},
},
{
"id": 3,
"lat": 40.43,
"lon": -3.72,
"name": "Galería de Arte Moderno",
"category": "gallery",
"tags": {"tourism": "gallery", "art": "modern"},
},
]
def test_pois_que_matchean_parcialmente_por_categoria_y_nombre():
"""pois que matchean parcialmente por categoria y nombre"""
interests = [{"name": "arte", "keywords": ["museo", "museum", "arte", "galería"], "weight": 1.0}]
result = match_pois_to_interests(POIS_SAMPLE, interests)
ids = [p["id"] for p in result]
# museo matchea por category "museum" (0.5) + nombre "Museo" (0.3) + tag "museum" (0.2) = 1.0
assert 1 in ids
# galería: sin match de categoria exacta (gallery != keywords), pero nombre contiene "Arte" -> check keywords
# keywords: "museo", "museum", "arte", "galería" — "arte" en "galería de arte moderno" -> 0.3
assert 3 in ids
# bar no matchea ninguno
assert 2 not in ids
# el museo debe tener score mas alto que la galeria
museo = next(p for p in result if p["id"] == 1)
galeria = next(p for p in result if p["id"] == 3)
assert museo["score"] > galeria["score"]
assert "arte" in museo["matched_interests"]
assert "arte" in galeria["matched_interests"]
def test_pois_sin_match_retorna_lista_vacia():
"""pois sin match retorna lista vacia"""
pois = [
{"id": 10, "lat": 40.0, "lon": -3.0, "name": "Parking Norte", "category": "parking", "tags": {"amenity": "parking"}},
{"id": 11, "lat": 40.1, "lon": -3.1, "name": "Gasolinera BP", "category": "fuel", "tags": {"amenity": "fuel"}},
]
interests = [{"name": "arte", "keywords": ["museo", "museum", "galería", "pintura"], "weight": 1.0}]
result = match_pois_to_interests(pois, interests)
assert result == []
def test_multiples_intereses_con_diferentes_weights_acumulan_score():
"""multiples intereses con diferentes weights acumulan score"""
pois = [
{
"id": 20,
"lat": 40.5,
"lon": -3.5,
"name": "Café Museo",
"category": "cafe",
"tags": {"amenity": "cafe", "tourism": "museum"},
}
]
interests = [
{"name": "arte", "keywords": ["museum", "museo", "arte"], "weight": 1.0},
{"name": "gastronomia", "keywords": ["cafe", "restaurant", "bar"], "weight": 0.8},
]
result = match_pois_to_interests(pois, interests)
assert len(result) == 1
poi = result[0]
# arte: nombre "Café Museo" contiene "museo" (0.3) + tag "museum" (0.2) = 0.5 * 1.0 = 0.5
# gastronomia: category "cafe" in keywords (0.5) + nombre "Café" contiene "cafe" (0.3) = 0.8 * 0.8 = 0.64
# total ~ 1.14
assert poi["score"] > 1.0
assert "arte" in poi["matched_interests"]
assert "gastronomia" in poi["matched_interests"]
assert len(poi["matched_interests"]) == 2
def test_max_results_limita_resultados():
pois = [
{"id": i, "lat": 40.0, "lon": -3.0, "name": f"Museo {i}", "category": "museum", "tags": {}}
for i in range(20)
]
interests = [{"name": "arte", "keywords": ["museum", "museo"], "weight": 1.0}]
result = match_pois_to_interests(pois, interests, max_results=5)
assert len(result) == 5
def test_inputs_no_mutados():
pois = [
{"id": 1, "lat": 40.0, "lon": -3.0, "name": "Museo A", "category": "museum", "tags": {}}
]
interests = [{"name": "arte", "keywords": ["museum"], "weight": 1.0}]
original_poi_keys = set(pois[0].keys())
match_pois_to_interests(pois, interests)
# el poi original no debe tener score ni matched_interests
assert set(pois[0].keys()) == original_poi_keys
if __name__ == "__main__":
test_pois_que_matchean_parcialmente_por_categoria_y_nombre()
print("PASS: pois que matchean parcialmente por categoria y nombre")
test_pois_sin_match_retorna_lista_vacia()
print("PASS: pois sin match retorna lista vacia")
test_multiples_intereses_con_diferentes_weights_acumulan_score()
print("PASS: multiples intereses con diferentes weights acumulan score")
test_max_results_limita_resultados()
print("PASS: max_results limita resultados")
test_inputs_no_mutados()
print("PASS: inputs no mutados")
print("---")
print("All tests passed.")