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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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<Map<String, String>>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: ollama_chat
|
||||
kind: function
|
||||
lang: kt
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "fun ollamaChat(messages: List<Map<String, String>>, 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.
|
||||
```
|
||||
---
|
||||
@@ -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<String, String>
|
||||
)
|
||||
|
||||
private val CATEGORY_TAG_MAP: Map<String, String> = 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<String>? = null
|
||||
): List<POI> {
|
||||
val resolved: Map<String, String> = 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<String, String> = resolved.entries
|
||||
.associate { (cat, tagExpr) -> tagExpr to cat }
|
||||
|
||||
val pois = mutableListOf<POI>()
|
||||
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<String, String>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<String>? = null): List<POI>"
|
||||
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=<query_url_encoded>`.
|
||||
- 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).
|
||||
---
|
||||
BIN
Binary file not shown.
Reference in New Issue
Block a user