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:
@@ -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)),
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user