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 01042bc23c
commit dede5e00af
19 changed files with 1568 additions and 0 deletions
@@ -0,0 +1,65 @@
---
name: nominatim_reverse_geocode
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def nominatim_reverse_geocode(lat: float, lon: float, lang: str = 'es') -> dict"
description: "Reverse geocoding usando Nominatim (OpenStreetMap). Convierte coordenadas lat/lon a una dirección estructurada con calle, ciudad, país y otros campos normalizados."
tags: [geocoding, nominatim, openstreetmap, osm, reverse-geocoding, geo, location, address]
params:
- name: lat
desc: "Latitud en grados decimales (rango -90 a 90)."
- name: lon
desc: "Longitud en grados decimales (rango -180 a 180)."
- name: lang
desc: "Código de idioma ISO 639-1 para la respuesta (ej: 'es', 'en', 'fr'). Por defecto 'es'."
output: "Diccionario con campos normalizados: display_name (dirección completa legible), street (calle/road), house_number, neighbourhood, city (city→town→village), state, country, postcode, lat, lon, osm_type (node/way/relation), osm_id. Campos ausentes retornan string vacío."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["urllib.request", "urllib.parse", "json"]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/nominatim_reverse_geocode.py"
---
## Ejemplo
```python
from infra.nominatim_reverse_geocode import nominatim_reverse_geocode
addr = nominatim_reverse_geocode(40.4168, -3.7038)
# {
# "display_name": "Puerta del Sol, Sol, Madrid, ...",
# "street": "Puerta del Sol",
# "house_number": "",
# "neighbourhood": "Sol",
# "city": "Madrid",
# "state": "Comunidad de Madrid",
# "country": "España",
# "postcode": "28013",
# "lat": 40.4168,
# "lon": -3.7038,
# "osm_type": "way",
# "osm_id": 12345678
# }
# Con idioma inglés
addr_en = nominatim_reverse_geocode(48.8566, 2.3522, lang="en")
# {"city": "Paris", "country": "France", ...}
```
## Notas
- Usa `urllib.request` de stdlib — sin dependencias externas.
- El header `User-Agent: fn_registry/1.0` es obligatorio según la política de uso de Nominatim.
- Timeout de 5 segundos. Para coordenadas en zonas remotas puede necesitarse más.
- Campo `city` se resuelve con fallback: `city → town → village → ""`.
- `lat`/`lon` del resultado son los del objeto OSM encontrado, que puede diferir ligeramente de los de entrada.
- Nominatim tiene límite de 1 request/segundo por IP. Para uso intensivo, considerar un servidor propio.
- Lanza `RuntimeError` en errores HTTP o de red.
@@ -0,0 +1,74 @@
"""Reverse geocoding usando Nominatim (OpenStreetMap)."""
import json
import urllib.request
import urllib.parse
def nominatim_reverse_geocode(lat: float, lon: float, lang: str = "es") -> dict:
"""Convierte coordenadas lat/lon a una dirección estructurada usando Nominatim.
Args:
lat: Latitud en grados decimales.
lon: Longitud en grados decimales.
lang: Código de idioma para la respuesta (ISO 639-1). Por defecto "es".
Returns:
Diccionario con campos normalizados de la dirección.
Raises:
RuntimeError: Si la petición HTTP falla o el servidor retorna error.
"""
params = urllib.parse.urlencode({
"format": "jsonv2",
"lat": str(lat),
"lon": str(lon),
"accept-language": lang,
"zoom": "18",
})
url = f"https://nominatim.openstreetmap.org/reverse?{params}"
req = urllib.request.Request(
url,
headers={"User-Agent": "fn_registry/1.0"},
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
if resp.status != 200:
raise RuntimeError(
f"nominatim_reverse_geocode: HTTP {resp.status} para lat={lat}, lon={lon}"
)
data = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raise RuntimeError(
f"nominatim_reverse_geocode: HTTP {e.code} para lat={lat}, lon={lon}"
) from e
except urllib.error.URLError as e:
raise RuntimeError(
f"nominatim_reverse_geocode: error de red — {e.reason}"
) from e
address = data.get("address", {})
city = (
address.get("city")
or address.get("town")
or address.get("village")
or ""
)
return {
"display_name": data.get("display_name", ""),
"street": address.get("road", ""),
"house_number": address.get("house_number", ""),
"neighbourhood": address.get("neighbourhood", ""),
"city": city,
"state": address.get("state", ""),
"country": address.get("country", ""),
"postcode": address.get("postcode", ""),
"lat": float(data.get("lat", lat)),
"lon": float(data.get("lon", lon)),
"osm_type": data.get("osm_type", ""),
"osm_id": int(data.get("osm_id", 0)),
}
+56
View File
@@ -0,0 +1,56 @@
---
name: ollama_chat
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def ollama_chat(messages: list[dict], model: str = 'llama3.1:8b', base_url: str = 'http://localhost:11434', temperature: float = 0.7, max_tokens: int = 1024) -> dict"
description: "Envía una solicitud de chat completion a Ollama (API local compatible con OpenAI). Retorna el contenido generado junto a métricas de duración y tokens."
tags: [ollama, llm, chat, inference, local-ai, http]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, urllib.request, urllib.error]
params:
- name: messages
desc: "Lista de mensajes de la conversación. Cada mensaje es un dict con 'role' (system|user|assistant) y 'content' (texto del mensaje)."
- name: model
desc: "Nombre del modelo Ollama a usar (ej: llama3.1:8b, mistral:7b, codellama:13b)."
- name: base_url
desc: "URL base de la instancia Ollama local. Por defecto http://localhost:11434."
- name: temperature
desc: "Temperatura de generación entre 0.0 (determinista) y 1.0 (creativo). Por defecto 0.7."
- name: max_tokens
desc: "Número máximo de tokens a generar en la respuesta. Por defecto 1024."
output: "Dict con 'content' (texto generado), 'model' (modelo usado), 'total_duration_ms' (duración total en milisegundos) y 'eval_count' (tokens generados)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/ollama_chat.py"
---
## Ejemplo
```python
resp = ollama_chat([
{"role": "system", "content": "Eres un guía turístico experto."},
{"role": "user", "content": "¿Qué puedo ver cerca del Museo del Prado?"}
], model="llama3.1:8b")
# {"content": "Cerca del Museo del Prado encontrarás...", "model": "llama3.1:8b", "total_duration_ms": 3200, "eval_count": 150}
```
## Notas
Función impura: hace una solicitud HTTP POST a la API de Ollama. Solo usa stdlib Python (urllib.request, json) — sin dependencias externas.
La generación de LLMs puede ser lenta; el timeout está fijo en 60 segundos. Para modelos grandes o respuestas largas, ajustar `max_tokens` o considerar streaming.
Manejo de errores:
- Connection refused → `RuntimeError("Ollama no está corriendo en {base_url}")`
- HTTP error → `RuntimeError("Ollama retornó HTTP {code}: {reason}")`
- Otros errores de red → `RuntimeError("Error de conexión con Ollama: {reason}")`
El campo `total_duration` que retorna Ollama está en nanosegundos; se convierte a milisegundos para mayor legibilidad. El campo `eval_count` puede no estar presente en todas las versiones de Ollama (default 0).
+70
View File
@@ -0,0 +1,70 @@
"""Envía una solicitud de chat completion a Ollama (API local compatible con OpenAI)."""
import json
import urllib.request
import urllib.error
from typing import Any
def ollama_chat(
messages: list[dict],
model: str = "llama3.1:8b",
base_url: str = "http://localhost:11434",
temperature: float = 0.7,
max_tokens: int = 1024,
) -> dict:
"""Envía una solicitud de chat completion a Ollama.
Args:
messages: Lista de mensajes con formato {"role": "system"|"user"|"assistant", "content": str}.
model: Nombre del modelo Ollama a usar.
base_url: URL base de la instancia Ollama.
temperature: Temperatura de generación (0.0 - 1.0).
max_tokens: Número máximo de tokens a generar.
Returns:
Dict con content, model, total_duration_ms y eval_count.
Raises:
RuntimeError: Si Ollama no está corriendo o retorna error HTTP.
"""
url = f"{base_url}/api/chat"
payload = {
"model": model,
"messages": messages,
"stream": False,
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
}
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
except urllib.error.URLError as exc:
reason = str(exc.reason) if hasattr(exc, "reason") else str(exc)
if "connection refused" in reason.lower() or "111" in reason:
raise RuntimeError(f"Ollama no está corriendo en {base_url}") from exc
raise RuntimeError(f"Error de conexión con Ollama: {reason}") from exc
except urllib.error.HTTPError as exc:
raise RuntimeError(f"Ollama retornó HTTP {exc.code}: {exc.reason}") from exc
response: dict[str, Any] = json.loads(raw)
content: str = response["message"]["content"]
total_duration_ns: int = response.get("total_duration", 0)
total_duration_ms: int = int(total_duration_ns / 1_000_000)
eval_count: int = response.get("eval_count", 0)
return {
"content": content,
"model": response.get("model", model),
"total_duration_ms": total_duration_ms,
"eval_count": eval_count,
}
@@ -0,0 +1,80 @@
---
name: overpass_nearby_pois
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "overpass_nearby_pois(lat: float, lon: float, radius_m: int = 500, categories: list[str] | None = None) -> list[dict]"
description: "Consulta la Overpass API (OpenStreetMap) para obtener POIs cercanos a una coordenada. Soporta 16 categorias mapeadas a tags OSM (amenity, tourism, historic, leisure, shop). Sin dependencias externas, solo stdlib."
tags: [osm, openstreetmap, overpass, poi, geolocation, nearby, geocoding, infra, http, stdlib]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "urllib.error", "urllib.parse", "urllib.request"]
params:
- name: lat
desc: "latitud del punto central de busqueda (grados decimales, -90 a 90)"
- name: lon
desc: "longitud del punto central de busqueda (grados decimales, -180 a 180)"
- name: radius_m
desc: "radio de busqueda en metros alrededor del punto central (defecto 500)"
- name: categories
desc: "lista de categorias a buscar entre: restaurant, cafe, bar, museum, monument, park, library, theatre, cinema, gallery, historic, tourism, shop, hotel, pharmacy, hospital. Si es None, usa todas las categorias comunes excepto hotel, pharmacy, hospital"
output: "lista de dicts con campos id (int), lat (float), lon (float), name (str), category (str) y tags (dict con todos los tags OSM del nodo)"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/overpass_nearby_pois.py"
---
## Ejemplo
```python
from infra.overpass_nearby_pois import overpass_nearby_pois
# POIs en el centro de Madrid (radio 300m)
pois = overpass_nearby_pois(40.4168, -3.7038, radius_m=300, categories=["museum", "monument"])
# [{"id": 123, "lat": 40.417, "lon": -3.704, "name": "Museo del Prado", "category": "museum", "tags": {...}}, ...]
# Todos los tipos comunes en radio 500m (defecto)
all_pois = overpass_nearby_pois(48.8566, 2.3522)
# Bares y restaurantes cerca de la Sagrada Familia
food = overpass_nearby_pois(41.4036, 2.1744, radius_m=200, categories=["restaurant", "cafe", "bar"])
```
## Mapeo de categorias a tags OSM
| Categoria | Tag OSM |
|-------------|----------------------|
| restaurant | amenity=restaurant |
| cafe | amenity=cafe |
| bar | amenity=bar |
| museum | tourism=museum |
| monument | historic=monument |
| park | leisure=park |
| library | amenity=library |
| theatre | amenity=theatre |
| cinema | amenity=cinema |
| gallery | tourism=gallery |
| historic | historic=* |
| tourism | tourism=* |
| shop | shop=* |
| hotel | tourism=hotel |
| pharmacy | amenity=pharmacy |
| hospital | amenity=hospital |
## Notas
Solo usa stdlib (urllib.request, urllib.parse, json). Sin dependencias externas.
La query Overpass QL usa union de nodos `(node[...]; node[...];)` con `out body` para obtener coordenadas y tags completos.
El endpoint publico `https://overpass-api.de/api/interpreter` tiene rate limits informales — para uso intensivo considerar un servidor Overpass propio o la instancia de kumi.systems.
El campo `category` refleja la primera coincidencia en el diccionario interno `_CATEGORY_TAGS`, que sigue el orden de insercion de Python 3.7+. Si un nodo tiene multiples tags relevantes (ej: un bar que tambien es restaurante), la categoria asignada sera la primera que matchee.
Timeout hardcodeado a 10 segundos tanto en la directiva Overpass QL `[timeout:10]` como en `urlopen(timeout=10)`.
@@ -0,0 +1,138 @@
"""Consulta la Overpass API para obtener POIs cercanos a una coordenada."""
import json
import urllib.error
import urllib.parse
import urllib.request
# Mapeo de categoría legible → (key OSM, value OSM)
# None como value significa wildcard (key=*)
_CATEGORY_TAGS: dict[str, tuple[str, str | None]] = {
"restaurant": ("amenity", "restaurant"),
"cafe": ("amenity", "cafe"),
"bar": ("amenity", "bar"),
"museum": ("tourism", "museum"),
"monument": ("historic", "monument"),
"park": ("leisure", "park"),
"library": ("amenity", "library"),
"theatre": ("amenity", "theatre"),
"cinema": ("amenity", "cinema"),
"gallery": ("tourism", "gallery"),
"historic": ("historic", None),
"tourism": ("tourism", None),
"shop": ("shop", None),
"hotel": ("tourism", "hotel"),
"pharmacy": ("amenity", "pharmacy"),
"hospital": ("amenity", "hospital"),
}
_DEFAULT_CATEGORIES = [
"restaurant", "cafe", "bar", "museum", "monument", "park",
"library", "theatre", "cinema", "gallery", "historic", "tourism", "shop",
]
_OVERPASS_ENDPOINT = "https://overpass-api.de/api/interpreter"
def _build_query(lat: float, lon: float, radius_m: int, categories: list[str]) -> str:
"""Construye una query Overpass QL con union de nodos."""
lines = ["[out:json][timeout:10];", "("]
for cat in categories:
if cat not in _CATEGORY_TAGS:
continue
key, value = _CATEGORY_TAGS[cat]
if value is None:
lines.append(f' node["{key}"](around:{radius_m},{lat},{lon});')
else:
lines.append(f' node["{key}"="{value}"](around:{radius_m},{lat},{lon});')
lines.append(");")
lines.append("out body;")
return "\n".join(lines)
def _extract_category(tags: dict) -> str:
"""Determina la categoría legible a partir de los tags OSM del nodo."""
for cat, (key, value) in _CATEGORY_TAGS.items():
if key not in tags:
continue
if value is None or tags[key] == value:
return cat
return "unknown"
def overpass_nearby_pois(
lat: float,
lon: float,
radius_m: int = 500,
categories: list[str] | None = None,
) -> list[dict]:
"""Obtiene POIs cercanos a una coordenada usando la Overpass API.
Args:
lat: latitud del punto central.
lon: longitud del punto central.
radius_m: radio de busqueda en metros (defecto 500).
categories: lista de categorias a buscar. Si es None, usa todas las
categorias comunes definidas internamente.
Returns:
lista de dicts con id, lat, lon, name, category y tags de cada POI.
Raises:
RuntimeError: si la request falla por error HTTP, timeout u otro error de red.
"""
active_categories = categories if categories is not None else _DEFAULT_CATEGORIES
query = _build_query(lat, lon, radius_m, active_categories)
encoded_query = urllib.parse.quote(query)
form_body = f"data={encoded_query}".encode("utf-8")
req = urllib.request.Request(
_OVERPASS_ENDPOINT,
data=form_body,
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
raw = response.read()
except urllib.error.HTTPError as e:
body_preview = e.read(200).decode("utf-8", errors="replace")
raise RuntimeError(
f"Overpass API HTTP error {e.code} for ({lat},{lon}) r={radius_m}m: {body_preview}"
) from e
except urllib.error.URLError as e:
raise RuntimeError(
f"Overpass API request failed for ({lat},{lon}) r={radius_m}m: {e.reason}"
) from e
try:
result = json.loads(raw)
except json.JSONDecodeError as e:
raise RuntimeError(
f"Overpass API returned invalid JSON for ({lat},{lon}): {e}"
) from e
pois: list[dict] = []
for element in result.get("elements", []):
if element.get("type") != "node":
continue
tags = element.get("tags", {})
category = _extract_category(tags)
# Nombre: tag "name", o valor del tag de categoría, o "Unknown"
if "name" in tags:
name = tags["name"]
else:
key = _CATEGORY_TAGS.get(category, (None, None))[0]
name = tags.get(key, "Unknown") if key else "Unknown"
pois.append({
"id": element["id"],
"lat": element["lat"],
"lon": element["lon"],
"name": name,
"category": category,
"tags": tags,
})
return pois