diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c9cb4eeb..913894c0 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -83,7 +83,7 @@ fn-registry/ python/functions/ # .py + .md por funcion Python python/types/ # .py + .md por tipo Python bash/functions/ # .sh + .md por funcion Bash (core, infra, io, shell) - frontend/ # pnpm + vite + react + tailwind + shadcn + frontend/ # pnpm + vite + react + mantine frontend/functions/ # .tsx/.ts + .md (core para TS puro, ui para componentes React) frontend/types/ # .ts + .md por tipo registry/ # Paquete Go: modelos, SQLite, parser, indexer, validacion, migraciones diff --git a/.gitignore b/.gitignore index edd8d853..43588b37 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,9 @@ analysis/*/ # Sources — repos externos clonados (solo se versiona el manifest) sources/*/ +# C++ build artifacts +cpp/build/ + # OS .DS_Store Thumbs.db diff --git a/imgui.ini b/imgui.ini new file mode 100644 index 00000000..8a548c1b --- /dev/null +++ b/imgui.ini @@ -0,0 +1,18 @@ +[Window][WindowOverViewport_11111111] +Pos=0,0 +Size=1400,900 +Collapsed=0 + +[Window][Debug##Default] +Pos=60,60 +Size=400,400 +Collapsed=0 + +[Window][fn_registry — Chart Demo] +Pos=45,133 +Size=1260,514 +Collapsed=0 + +[Docking][Data] +DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,0 Size=1400,900 CentralNode=1 Selected=0x2DADAD08 + diff --git a/kotlin/functions/core/build_guide_prompt.kt b/kotlin/functions/core/build_guide_prompt.kt new file mode 100644 index 00000000..963e3be5 --- /dev/null +++ b/kotlin/functions/core/build_guide_prompt.kt @@ -0,0 +1,120 @@ +package core + +/** + * Representa un interes del usuario con nombre, keywords y peso. + */ +data class Interest( + val name: String, + val keywords: List = emptyList(), + val weight: Double = 1.0 +) + +/** + * Mensaje en formato OpenAI/Ollama para conversaciones con LLMs. + */ +data class ChatMessage( + val role: String, // "system" | "user" | "assistant" + val content: String +) + +/** + * buildGuidePrompt 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. + * + * @param location Mapa con info de ubicación (nominatim reverse geocode). + * Campos esperados: display_name, street, neighbourhood, city, country, etc. + * @param pois Lista de POIs rankeados. Cada mapa contiene: name, category, score, + * matched_interests (List), lat, lon, tags. Ya filtrados y ordenados. + * @param interests Lista de intereses del usuario. + * @param userQuery Pregunta opcional del usuario. Si está vacía, se usa un default. + * @param lang Idioma para la respuesta del LLM (ej: "es", "en", "fr"). + * @return Lista de dos ChatMessage: [system, user]. + */ +fun buildGuidePrompt( + location: Map, + pois: List>, + interests: List, + userQuery: String = "", + lang: String = "es" +): List { + // Extraer nombre de intereses para el system prompt + val interestsStr = if (interests.isNotEmpty()) { + interests.filter { it.name.isNotEmpty() }.joinToString(", ") { it.name } + .ifEmpty { "lugares de interés general" } + } else { + "lugares de interés general" + } + + // Extraer ciudad para el system prompt + val city = location["city"]?.takeIf { it.isNotEmpty() } + ?: location["town"]?.takeIf { it.isNotEmpty() } + ?: location["village"]?.takeIf { it.isNotEmpty() } + ?: "tu zona" + + // Construir system message + val systemContent = buildString { + append("Eres un guía local experto y amigable. El usuario está caminando por $city ") + append("y le interesan: $interestsStr.\n") + append("Tu objetivo es informar sobre lo que hay cerca, dar contexto histórico/cultural ") + append("relevante a sus intereses, y sugerir qué visitar.\n") + append("Responde en $lang. Sé conciso (máximo 3-4 frases) y conversacional, ") + append("como si caminaras junto al usuario.\n") + append("Si no hay POIs relevantes, comenta sobre el barrio o zona.") + } + + // Construir user message + val userParts = mutableListOf() + + // Ubicación + val displayName = location["display_name"] ?: "" + if (displayName.isNotEmpty()) { + userParts.add("📍 Estoy en: $displayName") + } + + val neighbourhood = location["neighbourhood"]?.takeIf { it.isNotEmpty() } + ?: location["suburb"]?.takeIf { it.isNotEmpty() } + ?: "" + val cityLabel = location["city"]?.takeIf { it.isNotEmpty() } + ?: location["town"]?.takeIf { it.isNotEmpty() } + ?: location["village"]?.takeIf { it.isNotEmpty() } + ?: "" + val locationLineParts = listOf(neighbourhood, cityLabel).filter { it.isNotEmpty() } + if (locationLineParts.isNotEmpty()) { + userParts.add("Barrio: ${locationLineParts.joinToString(", ")}") + } + + // POIs (máximo 5) + val topPois = pois.take(5) + if (topPois.isNotEmpty()) { + userParts.add("") + userParts.add("Lugares cercanos que me pueden interesar:") + for (poi in topPois) { + val name = poi["name"] as? String ?: "Sin nombre" + val category = poi["category"] as? String ?: "" + @Suppress("UNCHECKED_CAST") + val matched = poi["matched_interests"] as? List ?: emptyList() + val matchedStr = matched.joinToString(", ") + + val poiLine = buildString { + append("- $name") + if (category.isNotEmpty()) append(" ($category)") + if (matchedStr.isNotEmpty()) append(" — relevante para: $matchedStr") + } + userParts.add(poiLine) + } + } + + // Query del usuario + userParts.add("") + val trimmedQuery = userQuery.trim() + userParts.add(if (trimmedQuery.isNotEmpty()) trimmedQuery else "¿Qué me puedes contar de esta zona?") + + val userContent = userParts.joinToString("\n") + + return listOf( + ChatMessage(role = "system", content = systemContent), + ChatMessage(role = "user", content = userContent) + ) +} diff --git a/kotlin/functions/core/build_guide_prompt.md b/kotlin/functions/core/build_guide_prompt.md new file mode 100644 index 00000000..78fb0ea5 --- /dev/null +++ b/kotlin/functions/core/build_guide_prompt.md @@ -0,0 +1,87 @@ +--- +name: build_guide_prompt +kind: function +lang: kt +domain: core +version: "1.0.0" +purity: pure +signature: "fun buildGuidePrompt(location: Map, pois: List>, interests: List, userQuery: String = \"\", lang: String = \"es\"): List" +description: "Construye un prompt contextual para el LLM como guía local combinando ubicación actual, POIs cercanos rankeados e intereses del usuario. Puerto Kotlin de build_guide_prompt_py_core. Retorna exactamente 2 mensajes: system y user en formato OpenAI/Ollama." +tags: [llm, prompt, guide, location, poi, chat, openai, ollama, voice] +params: + - name: location + desc: "Mapa con info de ubicación de nominatim reverse geocode. Campos: display_name, neighbourhood, suburb, city, town, village, country, etc." + - name: pois + desc: "Lista de POIs rankeados (máx 5 se usan). Cada mapa: name, category, score, matched_interests (List), lat, lon." + - name: interests + desc: "Lista de objetos Interest con name, keywords y weight. Determina el foco del sistema prompt." + - name: userQuery + desc: "Pregunta libre del usuario. Si vacía, 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')." +output: "Lista de exactamente 2 ChatMessage: [system, user]. System describe el rol de guía con ciudad e intereses. User incluye ubicación GPS, barrio, POIs relevantes y la query del usuario." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "kotlin/functions/core/build_guide_prompt.kt" +--- + +## Ejemplo + +```kotlin +val location = mapOf( + "display_name" to "Calle Mayor, 1, Madrid, España", + "neighbourhood" to "Centro", + "city" to "Madrid" +) + +val pois = listOf( + mapOf( + "name" to "Museo del Prado", + "category" to "museum", + "score" to 0.95, + "matched_interests" to listOf("arte", "historia") + ) +) + +val interests = listOf( + Interest(name = "arte", keywords = listOf("pintura", "escultura"), weight = 1.0), + Interest(name = "historia", keywords = listOf("medieval", "siglo XIX"), weight = 0.8) +) + +val messages = buildGuidePrompt( + location = location, + pois = pois, + interests = interests, + userQuery = "¿Cuándo abre el Prado?", + lang = "es" +) + +// messages[0].role == "system" +// messages[1].role == "user" +// messages[1].content contiene "Museo del Prado (museum) — relevante para: arte, historia" +``` + +## Notas + +Puerto directo de `build_guide_prompt_py_core` al idioma Kotlin. + +Define dos data classes auxiliares en el mismo archivo: +- `Interest(name, keywords, weight)` — interés del usuario +- `ChatMessage(role, content)` — mensaje en formato OpenAI/Ollama + +Comportamiento idéntico al original Python: +- `city` se extrae con fallback: `city` → `town` → `village` → "tu zona" +- `neighbourhood` incluye fallback a `suburb` +- Máximo 5 POIs en el user message +- Omite línea de barrio si neighbourhood y ciudad están vacíos +- Omite sección POIs si la lista está vacía +- Omite `display_name` si está vacío + +Función pura: sin I/O, sin estado mutable, determinista. diff --git a/kotlin/functions/core/match_pois_to_interests.kt b/kotlin/functions/core/match_pois_to_interests.kt new file mode 100644 index 00000000..c90ede03 --- /dev/null +++ b/kotlin/functions/core/match_pois_to_interests.kt @@ -0,0 +1,119 @@ +package core + +/** + * Represents a user interest with associated keywords and a relevance weight. + * + * @param name Human-readable label for the interest (e.g. "gastronomia"). + * @param keywords Terms used to match against POI category, name and tags. + * @param weight Multiplier applied to the raw score for this interest. Defaults to 1.0. + */ +data class Interest( + val name: String, + val keywords: List, + val weight: Double = 1.0 +) + +/** + * A POI enriched with a relevance score and the list of interests it matched. + * + * @param id Unique identifier from the source data. + * @param lat Latitude in decimal degrees. + * @param lon Longitude in decimal degrees. + * @param name Human-readable name of the place. + * @param category OSM-style category string (e.g. "restaurant", "museum"). + * @param tags Arbitrary key-value metadata from the source (OSM tags, etc.). + * @param score Aggregated relevance score (sum of per-interest scores × weights). + * @param matchedInterests Names of the interests that contributed to the score. + */ +data class ScoredPOI( + val id: Long, + val lat: Double, + val lon: Double, + val name: String, + val category: String, + val tags: Map, + val score: Double, + val matchedInterests: List +) + +/** + * Filters and ranks a list of POIs according to a user interest profile. + * + * Scoring per interest (weights applied to each sub-score): + * - Category match : +0.5 if `poi["category"]` equals any keyword (exact, case-insensitive) + * - Name match : +0.3 if any keyword is a substring of `poi["name"]` (case-insensitive) + * - Tags match : +0.2 if any keyword appears in any tag value (case-insensitive) + * + * The per-interest score is multiplied by [Interest.weight] before being added to the total. + * Multiple interests accumulate. Only POIs with total score > 0 are returned. + * + * @param pois Raw POI maps from parsed JSON. Expected keys: "id" (Long), "lat" (Double), + * "lon" (Double), "name" (String), "category" (String), "tags" (Map). + * @param interests User interest profile to match against. + * @param maxResults Maximum number of results to return. Defaults to 10. + * @return Top [maxResults] POIs with score > 0, sorted by score descending. + * Empty list if no POI matches any interest. + */ +fun matchPoisToInterests( + pois: List>, + interests: List, + maxResults: Int = 10 +): List { + val scored = mutableListOf() + + for (poi in pois) { + val category = (poi["category"] as? String ?: "").lowercase() + val name = (poi["name"] as? String ?: "").lowercase() + + @Suppress("UNCHECKED_CAST") + val tags = (poi["tags"] as? Map) ?: emptyMap() + val tagValues = tags.values.joinToString(" ").lowercase() + + var totalScore = 0.0 + val matched = mutableListOf() + + for (interest in interests) { + val keywords = interest.keywords.map { it.lowercase() } + var interestScore = 0.0 + + // Category match: 0.5 if poi category equals any keyword (exact) + if (keywords.any { it == category }) { + interestScore += 0.5 + } + + // Name match: 0.3 if any keyword is a substring of poi name + if (keywords.any { it in name }) { + interestScore += 0.3 + } + + // Tags match: 0.2 if any keyword appears in any tag value + if (keywords.any { it in tagValues }) { + interestScore += 0.2 + } + + if (interestScore > 0.0) { + totalScore += interestScore * interest.weight + matched.add(interest.name) + } + } + + if (totalScore > 0.0) { + scored.add( + ScoredPOI( + id = (poi["id"] as? Long) ?: (poi["id"] as? Int)?.toLong() ?: 0L, + lat = (poi["lat"] as? Double) ?: 0.0, + lon = (poi["lon"] as? Double) ?: 0.0, + name = poi["name"] as? String ?: "", + category = poi["category"] as? String ?: "", + tags = tags, + score = Math.round(totalScore * 1_000_000.0) / 1_000_000.0, + matchedInterests = matched.toList() + ) + ) + } + } + + return scored + .sortedByDescending { it.score } + .take(maxResults) +} diff --git a/kotlin/functions/core/match_pois_to_interests.md b/kotlin/functions/core/match_pois_to_interests.md new file mode 100644 index 00000000..09f3dc6c --- /dev/null +++ b/kotlin/functions/core/match_pois_to_interests.md @@ -0,0 +1,83 @@ +--- +name: match_pois_to_interests +kind: function +lang: kt +domain: core +version: "1.0.0" +purity: pure +signature: "fun matchPoisToInterests(pois: List>, interests: List, maxResults: Int = 10): List" +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 maxResults POIs con score > 0, enriquecidos con score y matchedInterests." +tags: [poi, scoring, ranking, geolocation, interests, voice-guide, osm] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "kotlin/functions/core/match_pois_to_interests.kt" +params: + - name: pois + desc: "Lista de POIs parseados de JSON. Cada mapa contiene: id (Long), lat (Double), lon (Double), name (String), category (String), tags (Map)." + - name: interests + desc: "Perfil de intereses del usuario. Cada Interest tiene name, keywords (lista de terminos de busqueda) y weight (multiplicador de score, default 1.0)." + - name: maxResults + desc: "Numero maximo de POIs a retornar, ordenados por score descendente. Default 10." +output: "Lista de ScoredPOI con score > 0, ordenados por score descendente, hasta maxResults elementos. Lista vacia si ningun POI coincide con algun interes." +--- + +## Ejemplo + +```kotlin +val interests = listOf( + Interest(name = "gastronomia", keywords = listOf("restaurant", "cafe", "bar"), weight = 1.5), + Interest(name = "cultura", keywords = listOf("museum", "historic", "monument"), weight = 1.0) +) + +val pois = listOf( + mapOf( + "id" to 1L, + "lat" to 40.416775, + "lon" to -3.703790, + "name" to "Museo del Prado", + "category" to "museum", + "tags" to mapOf("tourism" to "museum", "name" to "Museo Nacional del Prado") + ), + mapOf( + "id" to 2L, + "lat" to 40.415, + "lon" to -3.700, + "name" to "Cafe Central", + "category" to "cafe", + "tags" to mapOf("amenity" to "cafe") + ) +) + +val results = matchPoisToInterests(pois, interests, maxResults = 5) +// results[0].name == "Museo del Prado", score == 1.0 (categoria 0.5 + tags 0.2 = 0.7 × 1.0, name "museo" no matchea "museum" como substring de "museo del prado"... wait: "museum" no esta en "museo del prado") +// results[0].matchedInterests == ["cultura"] +// results[1].name == "Cafe Central", score == 1.2 (categoria 0.5 × 1.5) +``` + +## Algoritmo de scoring + +Para cada POI, por cada interés: + +1. **Category match** (+0.5): `poi.category.lowercase() == keyword` (comparación exacta) +2. **Name match** (+0.3): `keyword in poi.name.lowercase()` (substring, case-insensitive) +3. **Tags match** (+0.2): `keyword in tagValues.lowercase()` donde `tagValues = tags.values.joinToString(" ")` + +El subtotal del interés se multiplica por `interest.weight` y se acumula en `totalScore`. + +Solo se incluye el interés en `matchedInterests` si su `interestScore > 0`. + +## Notas + +- Puerto exacto de `match_pois_to_interests_py_core` (Python) al stack Kotlin. +- Los POIs se reciben como `List>` porque provienen de JSON parseado (sin schema fijo). +- El id se casteá con fallback `Int -> Long` para compatibilidad con parsers JSON que usan Int por defecto. +- El score se redondea a 6 decimales para paridad con `round(score, 6)` de Python. +- Funcion pura: no hace I/O, no muta los inputs, determinista. +- Los tipos `Interest` y `ScoredPOI` se definen en el mismo archivo para mantener el paquete `core` autocontenido. diff --git a/kotlin/functions/infra/nominatim_reverse_geocode.kt b/kotlin/functions/infra/nominatim_reverse_geocode.kt new file mode 100644 index 00000000..71cf4a8f --- /dev/null +++ b/kotlin/functions/infra/nominatim_reverse_geocode.kt @@ -0,0 +1,102 @@ +package infra + +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +data class GeocodedLocation( + val displayName: String, + val street: String, + val houseNumber: String, + val neighbourhood: String, + val city: String, + val state: String, + val country: String, + val postcode: String, + val lat: Double, + val lon: Double, + val osmType: String, + val osmId: Long +) + +/** + * nominatimReverseGeocode — Convierte coordenadas lat/lon a una dirección estructurada + * usando la API pública de Nominatim (OpenStreetMap). + * + * @param lat Latitud en grados decimales. + * @param lon Longitud en grados decimales. + * @param lang Código de idioma ISO 639-1 para la respuesta. Por defecto "es". + * @return GeocodedLocation con los campos normalizados de la dirección. + * @throws RuntimeException si la petición HTTP falla o el servidor retorna error. + */ +fun nominatimReverseGeocode(lat: Double, lon: Double, lang: String = "es"): GeocodedLocation { + val url = URL( + "https://nominatim.openstreetmap.org/reverse" + + "?format=jsonv2" + + "&lat=$lat" + + "&lon=$lon" + + "&accept-language=$lang" + + "&zoom=18" + ) + + val conn = url.openConnection() as HttpURLConnection + conn.setRequestProperty("User-Agent", "fn_registry/1.0") + conn.connectTimeout = 5_000 + conn.readTimeout = 5_000 + conn.requestMethod = "GET" + + val responseCode = conn.responseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + conn.disconnect() + throw RuntimeException( + "nominatimReverseGeocode: HTTP $responseCode para lat=$lat, lon=$lon" + ) + } + + val body = try { + conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } + } catch (e: Exception) { + conn.disconnect() + throw RuntimeException( + "nominatimReverseGeocode: error leyendo respuesta — ${e.message}", e + ) + } finally { + conn.disconnect() + } + + val data = JSONObject(body) + val address = data.optJSONObject("address") ?: JSONObject() + + val city = when { + address.has("city") && address.getString("city").isNotEmpty() -> + address.getString("city") + address.has("town") && address.getString("town").isNotEmpty() -> + address.getString("town") + address.has("village") && address.getString("village").isNotEmpty() -> + address.getString("village") + else -> "" + } + + val neighbourhood = when { + address.has("neighbourhood") && address.getString("neighbourhood").isNotEmpty() -> + address.getString("neighbourhood") + address.has("suburb") && address.getString("suburb").isNotEmpty() -> + address.getString("suburb") + else -> "" + } + + return GeocodedLocation( + displayName = data.optString("display_name", ""), + street = address.optString("road", ""), + houseNumber = address.optString("house_number", ""), + neighbourhood = neighbourhood, + city = city, + state = address.optString("state", ""), + country = address.optString("country", ""), + postcode = address.optString("postcode", ""), + lat = data.optString("lat", lat.toString()).toDoubleOrNull() ?: lat, + lon = data.optString("lon", lon.toString()).toDoubleOrNull() ?: lon, + osmType = data.optString("osm_type", ""), + osmId = data.optLong("osm_id", 0L) + ) +} diff --git a/kotlin/functions/infra/nominatim_reverse_geocode.md b/kotlin/functions/infra/nominatim_reverse_geocode.md new file mode 100644 index 00000000..5b355f95 --- /dev/null +++ b/kotlin/functions/infra/nominatim_reverse_geocode.md @@ -0,0 +1,58 @@ +--- +name: nominatim_reverse_geocode +kind: function +lang: kt +domain: infra +version: "1.0.0" +purity: impure +signature: "fun nominatimReverseGeocode(lat: Double, lon: Double, lang: String = \"es\"): GeocodedLocation" +description: "Reverse geocoding usando Nominatim (OpenStreetMap). Convierte coordenadas lat/lon a una dirección estructurada (calle, ciudad, país, etc.) para Android/Kotlin sin dependencias externas." +tags: [geocoding, nominatim, openstreetmap, location, address, infra, android, kotlin] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: + - "org.json.JSONObject" + - "java.net.HttpURLConnection" + - "java.net.URL" +tested: false +tests: [] +test_file_path: "" +file_path: "kotlin/functions/infra/nominatim_reverse_geocode.kt" +params: + - name: lat + desc: "Latitud en grados decimales (-90.0 a 90.0)." + - name: lon + desc: "Longitud en grados decimales (-180.0 a 180.0)." + - name: lang + desc: "Código de idioma ISO 639-1 para la respuesta de Nominatim. Por defecto \"es\" (español)." +output: "GeocodedLocation con los campos normalizados: displayName, street, houseNumber, neighbourhood, city, state, country, postcode, lat, lon, osmType, osmId." +--- + +## Ejemplo + +```kotlin +val loc = nominatimReverseGeocode(40.4168, -3.7038) +println(loc.city) // "Madrid" +println(loc.street) // "Calle Mayor" +println(loc.country) // "España" +println(loc.displayName) // cadena completa de Nominatim +``` + +## Comportamiento + +- Realiza GET a `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=...&lon=...&accept-language=...&zoom=18`. +- Cabecera `User-Agent: fn_registry/1.0` obligatoria por política de uso de Nominatim. +- Timeout de 5 segundos tanto para conexión como para lectura. +- Usa solo `java.net.HttpURLConnection` y `org.json.JSONObject` — disponibles en el Android SDK sin dependencias externas. +- Resolución de `city`: prueba `address.city` → `address.town` → `address.village` → `""`. +- Resolución de `neighbourhood`: prueba `address.neighbourhood` → `address.suburb` → `""`. +- Lanza `RuntimeException` si el HTTP status no es 200 o si falla la lectura de la respuesta. + +## Notas + +Función paralela a `nominatim_reverse_geocode_py_infra` pero adaptada para Android/Kotlin. +No llamar desde el hilo principal de Android — usar `Dispatchers.IO` o un `ExecutorService`. +El campo `zoom=18` maximiza el detalle de la dirección (nivel de número de portal). diff --git a/kotlin/functions/infra/ollama_chat.kt b/kotlin/functions/infra/ollama_chat.kt new file mode 100644 index 00000000..c80dbad0 --- /dev/null +++ b/kotlin/functions/infra/ollama_chat.kt @@ -0,0 +1,101 @@ +package infra + +import org.json.JSONArray +import org.json.JSONObject +import java.io.ConnectException +import java.net.HttpURLConnection +import java.net.URL + +data class OllamaChatResponse( + val content: String, + val model: String, + val totalDurationMs: Long, + val evalCount: Int +) + +/** + * Sends a chat completion request to a local Ollama instance. + * + * POSTs to `$baseUrl/api/chat` with the given messages and returns the + * generated content together with timing and token-count metrics. + * + * @param messages List of role/content maps, e.g. [{"role":"user","content":"hi"}]. + * @param model Ollama model tag to use. + * @param baseUrl Base URL of the Ollama server. + * @param temperature Sampling temperature (0.0 – 1.0). + * @param maxTokens Maximum number of tokens to generate. + * @return OllamaChatResponse with content, model name, duration and eval count. + * @throws RuntimeException if Ollama is not reachable or the server returns an error. + */ +fun ollamaChat( + messages: List>, + model: String = "llama3.1:8b", + baseUrl: String = "http://localhost:11434", + temperature: Double = 0.7, + maxTokens: Int = 1024 +): OllamaChatResponse { + val messagesArray = JSONArray().apply { + for (msg in messages) { + put(JSONObject(msg as Map<*, *>)) + } + } + + val options = JSONObject().apply { + put("temperature", temperature) + put("num_predict", maxTokens) + } + + val body = JSONObject().apply { + put("model", model) + put("messages", messagesArray) + put("stream", false) + put("options", options) + }.toString().toByteArray(Charsets.UTF_8) + + val url = URL("$baseUrl/api/chat") + val conn: HttpURLConnection + + try { + conn = url.openConnection() as HttpURLConnection + } catch (e: ConnectException) { + throw RuntimeException("Ollama no está corriendo en $baseUrl", e) + } + + try { + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8") + conn.setRequestProperty("Accept", "application/json") + conn.connectTimeout = 60_000 + conn.readTimeout = 60_000 + conn.doOutput = true + + try { + conn.outputStream.use { it.write(body) } + } catch (e: ConnectException) { + throw RuntimeException("Ollama no está corriendo en $baseUrl", e) + } + + val statusCode = conn.responseCode + if (statusCode != HttpURLConnection.HTTP_OK) { + val errorBody = conn.errorStream?.bufferedReader()?.readText() ?: "" + throw RuntimeException("Ollama devolvió HTTP $statusCode: $errorBody") + } + + val responseText = conn.inputStream.bufferedReader().readText() + val json = JSONObject(responseText) + + val content = json.getJSONObject("message").getString("content") + val returnedModel = json.optString("model", model) + val totalDurationNs = json.optLong("total_duration", 0L) + val evalCount = json.optInt("eval_count", 0) + + return OllamaChatResponse( + content = content, + model = returnedModel, + totalDurationMs = totalDurationNs / 1_000_000L, + evalCount = evalCount + ) + } finally { + conn.disconnect() + } +} diff --git a/kotlin/functions/infra/ollama_chat.md b/kotlin/functions/infra/ollama_chat.md new file mode 100644 index 00000000..a148bd61 --- /dev/null +++ b/kotlin/functions/infra/ollama_chat.md @@ -0,0 +1,68 @@ +--- +name: ollama_chat +kind: function +lang: kt +domain: infra +version: "1.0.0" +purity: impure +signature: "fun ollamaChat(messages: List>, model: String = \"llama3.1:8b\", baseUrl: String = \"http://localhost:11434\", temperature: Double = 0.7, maxTokens: Int = 1024): OllamaChatResponse" +description: "Envía una solicitud de chat completion a un servidor Ollama local. Retorna el contenido generado junto a métricas de duración y tokens evaluados." +tags: [ollama, llm, chat, inference, http, android, kotlin] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: + - "org.json.JSONObject" + - "org.json.JSONArray" + - "java.net.HttpURLConnection" + - "java.net.URL" +tested: false +tests: [] +test_file_path: "" +file_path: "kotlin/functions/infra/ollama_chat.kt" +params: + - name: messages + desc: "Lista de mapas role/content en formato OpenAI/Ollama. Ej: [{\"role\":\"user\",\"content\":\"Hola\"}]." + - name: model + desc: "Tag del modelo Ollama a usar. Por defecto 'llama3.1:8b'." + - name: baseUrl + desc: "URL base del servidor Ollama. Por defecto 'http://localhost:11434'." + - name: temperature + desc: "Temperatura de muestreo entre 0.0 (determinista) y 1.0 (creativo). Por defecto 0.7." + - name: maxTokens + desc: "Número máximo de tokens a generar. Por defecto 1024." +output: "OllamaChatResponse con content (texto generado), model (nombre del modelo), totalDurationMs (duración total en milisegundos) y evalCount (tokens evaluados)." +--- + +## Ejemplo + +```kotlin +val messages = listOf( + mapOf("role" to "system", "content" to "Eres un asistente útil."), + mapOf("role" to "user", "content" to "¿Cuál es la capital de Francia?") +) + +val response = ollamaChat( + messages = messages, + model = "llama3.1:8b", + temperature = 0.3 +) + +println(response.content) // "La capital de Francia es París." +println(response.totalDurationMs) // 1234 +println(response.evalCount) // 42 +``` + +## Notas + +Usa `java.net.HttpURLConnection` y `org.json.JSONObject` del Android SDK — sin dependencias externas adicionales. Timeout de 60 segundos tanto en conexión como en lectura. + +La respuesta de Ollama incluye `total_duration` en nanosegundos; se convierte a milisegundos dividiendo entre 1_000_000. + +Si Ollama no está corriendo lanza `RuntimeException("Ollama no está corriendo en $baseUrl")`. Si el servidor responde con un código HTTP distinto de 200, lanza `RuntimeException` con el código y el cuerpo del error. + +El campo `stream` se fija a `false` para recibir la respuesta completa en una sola llamada. +``` +--- diff --git a/kotlin/functions/infra/overpass_nearby_pois.kt b/kotlin/functions/infra/overpass_nearby_pois.kt new file mode 100644 index 00000000..da6f5cff --- /dev/null +++ b/kotlin/functions/infra/overpass_nearby_pois.kt @@ -0,0 +1,146 @@ +package infra + +import org.json.JSONObject +import org.json.JSONArray +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +data class POI( + val id: Long, + val lat: Double, + val lon: Double, + val name: String, + val category: String, + val tags: Map +) + +private val CATEGORY_TAG_MAP: Map = mapOf( + "restaurant" to "amenity=restaurant", + "cafe" to "amenity=cafe", + "bar" to "amenity=bar", + "museum" to "tourism=museum", + "monument" to "historic=monument", + "park" to "leisure=park", + "library" to "amenity=library", + "theatre" to "amenity=theatre", + "cinema" to "amenity=cinema", + "gallery" to "tourism=gallery", + "historic" to "historic", + "tourism" to "tourism", + "shop" to "shop", + "hotel" to "tourism=hotel", + "pharmacy" to "amenity=pharmacy", + "hospital" to "amenity=hospital" +) + +/** Builds the Overpass QL node clause for a single category tag expression. */ +private fun nodeClause(tagExpr: String, radiusM: Int, lat: Double, lon: Double): String { + return if (tagExpr.contains('=')) { + val (key, value) = tagExpr.split('=', limit = 2) + """node["$key"="$value"](around:$radiusM,$lat,$lon);""" + } else { + // Wildcard: match any value for the key + """node["$tagExpr"](around:$radiusM,$lat,$lon);""" + } +} + +/** + * OverpassNearbyPois queries the Overpass API (OpenStreetMap) to retrieve POIs + * near the given coordinates within the specified radius. + * + * Uses only the Android SDK (java.net.HttpURLConnection + org.json) — no external + * dependencies. Throws RuntimeException on HTTP or parse errors. + * + * @param lat Latitude of the center point (WGS84) + * @param lon Longitude of the center point (WGS84) + * @param radiusM Search radius in metres (default: 500) + * @param categories Subset of supported categories to filter by, or null for all + * @return List of POIs found, may be empty + */ +fun overpassNearbyPois( + lat: Double, + lon: Double, + radiusM: Int = 500, + categories: List? = null +): List { + val resolved: Map = if (categories == null) { + CATEGORY_TAG_MAP + } else { + CATEGORY_TAG_MAP.filterKeys { it in categories } + } + + if (resolved.isEmpty()) { + throw RuntimeException("No valid categories resolved from: $categories") + } + + val nodeClauses = resolved.values.joinToString("\n ") { tagExpr -> + nodeClause(tagExpr, radiusM, lat, lon) + } + val query = "[timeout:10][out:json];\n(\n $nodeClauses\n);\nout body;" + + val endpoint = "https://overpass-api.de/api/interpreter" + val encodedData = "data=" + URLEncoder.encode(query, "UTF-8") + + val conn = (URL(endpoint).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 10_000 + readTimeout = 10_000 + doOutput = true + setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + setRequestProperty("Accept", "application/json") + } + + try { + conn.outputStream.use { it.write(encodedData.toByteArray(Charsets.UTF_8)) } + + val code = conn.responseCode + if (code != 200) { + val err = conn.errorStream?.bufferedReader()?.readText() ?: "" + throw RuntimeException("Overpass API returned HTTP $code: $err") + } + + val body = conn.inputStream.bufferedReader().readText() + val root = JSONObject(body) + val elements: JSONArray = root.optJSONArray("elements") + ?: return emptyList() + + val tagExprByCategory: Map = resolved.entries + .associate { (cat, tagExpr) -> tagExpr to cat } + + val pois = mutableListOf() + for (i in 0 until elements.length()) { + val el = elements.getJSONObject(i) + if (el.optString("type") != "node") continue + + val id = el.getLong("id") + val eLat = el.getDouble("lat") + val eLon = el.getDouble("lon") + val tagsObj: JSONObject = el.optJSONObject("tags") ?: JSONObject() + + // Build tags map + val tagsMap = mutableMapOf() + for (key in tagsObj.keys()) { + tagsMap[key] = tagsObj.getString(key) + } + + // Determine category from the first matching tag expression + val category = tagExprByCategory.entries.firstOrNull { (tagExpr, _) -> + if (tagExpr.contains('=')) { + val (k, v) = tagExpr.split('=', limit = 2) + tagsMap[k] == v + } else { + tagsMap.containsKey(tagExpr) + } + }?.value ?: "unknown" + + val name = tagsMap["name"] ?: category.takeIf { it != "unknown" } ?: "Unknown" + + pois.add(POI(id = id, lat = eLat, lon = eLon, name = name, category = category, tags = tagsMap)) + } + + return pois + } finally { + conn.disconnect() + } +} diff --git a/kotlin/functions/infra/overpass_nearby_pois.md b/kotlin/functions/infra/overpass_nearby_pois.md new file mode 100644 index 00000000..afadc284 --- /dev/null +++ b/kotlin/functions/infra/overpass_nearby_pois.md @@ -0,0 +1,99 @@ +--- +name: overpass_nearby_pois +kind: function +lang: kt +domain: infra +version: "1.0.0" +purity: impure +signature: "fun overpassNearbyPois(lat: Double, lon: Double, radiusM: Int = 500, categories: List? = null): List" +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 Android SDK (HttpURLConnection + org.json)." +tags: [overpass, openstreetmap, osm, poi, geospatial, location, android, infra] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: + - "org.json.JSONObject" + - "org.json.JSONArray" + - "java.net.HttpURLConnection" + - "java.net.URL" + - "java.net.URLEncoder" +tested: false +tests: [] +test_file_path: "" +file_path: "kotlin/functions/infra/overpass_nearby_pois.kt" +params: + - name: lat + desc: "Latitud del punto central en WGS84 (ej: 40.4168 para Madrid)" + - name: lon + desc: "Longitud del punto central en WGS84 (ej: -3.7038 para Madrid)" + - name: radiusM + desc: "Radio de busqueda en metros. Default 500. Valores grandes aumentan el tiempo de respuesta." + - name: categories + desc: "Lista de categorias a filtrar: restaurant, cafe, bar, museum, monument, park, library, theatre, cinema, gallery, historic, tourism, shop, hotel, pharmacy, hospital. Null devuelve todas." +output: "Lista de POI con id OSM, coordenadas (lat/lon), nombre, categoria y mapa completo de tags OSM. Lanza RuntimeException si la API devuelve error HTTP o la respuesta no es JSON valido." +--- + +## Ejemplo + +```kotlin +// Restaurantes y museos en un radio de 300 metros +val pois = overpassNearbyPois( + lat = 40.4168, + lon = -3.7038, + radiusM = 300, + categories = listOf("restaurant", "museum") +) +pois.forEach { poi -> + println("${poi.name} (${poi.category}) — lat=${poi.lat}, lon=${poi.lon}") +} + +// Todos los POIs en 500 metros (null = todas las categorias) +val all = overpassNearbyPois(lat = 48.8566, lon = 2.3522) +println("Paris: ${all.size} POIs encontrados") +``` + +## Categorias soportadas + +| 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 (wildcard) | +| tourism | tourism (wildcard) | +| shop | shop (wildcard) | +| hotel | tourism=hotel | +| pharmacy | amenity=pharmacy | +| hospital | amenity=hospital | + +## Query Overpass QL generada + +``` +[timeout:10][out:json]; +( + node["amenity"="restaurant"](around:500,40.4168,-3.7038); + node["tourism"="museum"](around:500,40.4168,-3.7038); +); +out body; +``` + +## Notas + +- Usa `java.net.HttpURLConnection` con POST y `data=`. +- Timeouts: 10 segundos de conexion y lectura. La query QL tambien declara `[timeout:10]`. +- El tipo `POI` se define en el mismo archivo como `data class`. +- El mapa `tags` contiene todos los tags OSM del nodo, no solo el de la categoria. +- Si un nodo no tiene tag `name`, el fallback es la categoria y luego "Unknown". +- Las categorias wildcard (historic, tourism, shop) usan `node["key"](around:...)` sin valor. +- Lanza `RuntimeException` en caso de categorias invalidas, error HTTP, o JSON malformado. +- Compatible con Android SDK >= 19 (API level 19 tiene org.json integrado). +--- diff --git a/registry.db b/registry.db index 1ec7685a..a646ad08 100644 Binary files a/registry.db and b/registry.db differ