chore: add Kotlin directory structure, update registry.db and gitignore

Añade estructura inicial kotlin/functions/, actualiza registry.db con todos
los cambios indexados, y ajusta .gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 23:47:27 +02:00
parent dede5e00af
commit 73d181f2b0
13 changed files with 1005 additions and 1 deletions
+120
View File
@@ -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<String> = 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<String>), 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<String, String>,
pois: List<Map<String, Any>>,
interests: List<Interest>,
userQuery: String = "",
lang: String = "es"
): List<ChatMessage> {
// 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<String>()
// 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<String> ?: 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)
)
}
@@ -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<String, String>, pois: List<Map<String, Any>>, interests: List<Interest>, userQuery: String = \"\", lang: String = \"es\"): List<ChatMessage>"
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<String>), 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.
@@ -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<String>,
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<String, String>,
val score: Double,
val matchedInterests: List<String>
)
/**
* 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<String,String>).
* @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<Map<String, Any>>,
interests: List<Interest>,
maxResults: Int = 10
): List<ScoredPOI> {
val scored = mutableListOf<ScoredPOI>()
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<String, String>) ?: emptyMap()
val tagValues = tags.values.joinToString(" ").lowercase()
var totalScore = 0.0
val matched = mutableListOf<String>()
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)
}
@@ -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<Map<String, Any>>, interests: List<Interest>, maxResults: Int = 10): List<ScoredPOI>"
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<String,String>)."
- 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<Map<String, Any>>` 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.