gioser

sergio/humanoid

HTML 13TOML 10 Definitions

⎇ f37d04fbc73ba84633ad5ea9d762032f7cc3d958 keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt 238 lines · 9.5 KB History raw

Trazo: ampliar diccionarios ES/EN + ajustes del motor de sugerencias f37d04fbc · Sergio ·

1package com.rizoma.trazo.data
2
3import android.content.Context
4import androidx.datastore.core.DataStore
5import androidx.datastore.preferences.core.Preferences
6import androidx.datastore.preferences.core.edit
7import androidx.datastore.preferences.core.intPreferencesKey
8import androidx.datastore.preferences.preferencesDataStore
9import kotlinx.coroutines.CoroutineScope
10import kotlinx.coroutines.Dispatchers
11import kotlinx.coroutines.flow.first
12import kotlinx.coroutines.launch
13import java.util.concurrent.ConcurrentHashMap
14
15private val Context.trazoLearnedStore: DataStore<Preferences> by preferencesDataStore(name = "trazo_learned")
16
17/**
18 * Predicción por prefijo, bilingüe (es + en). Dos fuentes:
19 *  - [base]: diccionarios empaquetados (assets), peso fijo por frecuencia.
20 *  - [learned]: palabras que el usuario escribe, contadas y persistidas.
21 *
22 * Mecanismo "buenas sugerencias": una palabra del diccionario se refuerza al instante,
23 * pero una palabra NUEVA solo entra a las sugerencias tras escribirse [PROMOTE] veces,
24 * así los errores escritos una sola vez nunca contaminan. Las sugerencias salen en el
25 * idioma del prefijo que escribes (ambos diccionarios conviven).
26 */
27class SuggestionEngine(context: Context, private val scope: CoroutineScope) {
28
29    private val appContext = context.applicationContext
30    private val store = appContext.trazoLearnedStore
31
32    // Diccionarios separados por idioma para poder activarlos/desactivarlos.
33    private val baseEs = ConcurrentHashMap<String, Int>()
34    private val baseEn = ConcurrentHashMap<String, Int>()
35    private val learned = ConcurrentHashMap<String, Int>()
36    // Bigramas: palabra previa -> {palabra siguiente -> veces}. Alimenta "palabra siguiente".
37    private val bigrams = ConcurrentHashMap<String, ConcurrentHashMap<String, Int>>()
38
39    init {
40        scope.launch(Dispatchers.IO) {
41            loadFile("dict_es.txt", baseEs)
42            loadFile("dict_en.txt", baseEn)
43            loadPersisted()
44        }
45    }
46
47    private fun loadFile(name: String, into: ConcurrentHashMap<String, Int>) {
48        runCatching {
49            appContext.assets.open(name).bufferedReader().useLines { lines ->
50                var i = 0
51                lines.forEach { line ->
52                    val w = line.trim().lowercase()
53                    if (w.isNotEmpty()) {
54                        val weight = maxOf(1, BASE_TOP - i)
55                        into.merge(w, weight) { a, b -> maxOf(a, b) }
56                        i++
57                    }
58                }
59            }
60        }
61    }
62
63    private suspend fun loadPersisted() {
64        runCatching {
65            store.data.first().asMap().forEach { (key, value) ->
66                val name = key.name
67                if (value !is Int) return@forEach
68                when {
69                    name.startsWith(W) -> learned[name.removePrefix(W)] = value
70                    name.startsWith(B) -> {
71                        val rest = name.removePrefix(B)
72                        val sep = rest.indexOf(SEP)
73                        if (sep > 0 && sep < rest.length - 1) {
74                            val prev = rest.substring(0, sep)
75                            val next = rest.substring(sep + 1)
76                            bigrams.getOrPut(prev) { ConcurrentHashMap() }[next] = value
77                        }
78                    }
79                }
80            }
81        }
82    }
83
84    private fun inBase(w: String) = baseEs.containsKey(w) || baseEn.containsKey(w)
85
86    /**
87     * Completa el prefijo en los idiomas activos. Tres niveles, de mejor a peor:
88     *  1. prefijo exacto con acentos (`info` → `informe`)              → +[EXACT_BONUS]
89     *  2. prefijo sin acentos (`informacion` → `información`)          → peso base
90     *  3. fuzzy: 1 errata en el prefijo (`infrome` → `informe`),
91     *     solo si aún faltan sugerencias                              → −[FUZZY_PENALTY]
92     * Mayor peso primero; a igual peso, la más corta.
93     */
94    fun suggest(prefix: String, useEs: Boolean, useEn: Boolean, limit: Int = 3): List<String> {
95        val p = prefix.trim().lowercase()
96        if (p.isEmpty()) return emptyList()
97        val np = fold(p)
98
99        val candidates = HashMap<String, Int>()
100        fun consider(w: String, weight: Int) {
101            if (w.length <= p.length) return
102            val score = when {
103                w.startsWith(p) -> weight + EXACT_BONUS
104                fold(w).startsWith(np) -> weight
105                else -> return
106            }
107            if (score > (candidates[w] ?: Int.MIN_VALUE)) candidates[w] = score
108        }
109        fun scan(map: ConcurrentHashMap<String, Int>) = map.forEach { (w, weight) -> consider(w, weight) }
110        if (useEs) scan(baseEs)
111        if (useEn) scan(baseEn)
112        learned.forEach { (w, count) ->
113            // Solo palabras nuevas ya "promovidas"; las del diccionario suman refuerzo.
114            if (w.length > p.length && (count >= PROMOTE || inBase(w)) &&
115                (w.startsWith(p) || fold(w).startsWith(np))
116            ) {
117                candidates[w] = (candidates[w] ?: 0) + count * LEARN_INC
118            }
119        }
120
121        // Nivel 3: tolera 1 errata en el prefijo cuando hay pocas coincidencias limpias.
122        if (candidates.size < limit && np.length >= 3) {
123            fun scanFuzzy(map: ConcurrentHashMap<String, Int>) {
124                map.forEach { (w, weight) ->
125                    if (w.length > p.length && w !in candidates) {
126                        val nw = fold(w)
127                        if (within1(np, nw.take(np.length)) || within1(np, nw.take(np.length + 1))) {
128                            candidates[w] = weight - FUZZY_PENALTY
129                        }
130                    }
131                }
132            }
133            if (useEs) scanFuzzy(baseEs)
134            if (useEn) scanFuzzy(baseEn)
135        }
136
137        return candidates.entries
138            .sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key.length })
139            .map { it.key }
140            .take(limit)
141    }
142
143    /** Quita acentos/diéresis y plega ñ→n, ç→c para comparar de forma laxa. */
144    private fun fold(s: String): String = buildString(s.length) {
145        for (c in s) append(
146            when (c) {
147                'á', 'à', 'ä', 'â' -> 'a'
148                'é', 'è', 'ë', 'ê' -> 'e'
149                'í', 'ì', 'ï', 'î' -> 'i'
150                'ó', 'ò', 'ö', 'ô' -> 'o'
151                'ú', 'ù', 'ü', 'û' -> 'u'
152                'ñ' -> 'n'
153                'ç' -> 'c'
154                else -> c
155            },
156        )
157    }
158
159    /** ¿[a] se transforma en [b] con como mucho una edición (sustituir/insertar/borrar)? */
160    private fun within1(a: String, b: String): Boolean {
161        val la = a.length
162        val lb = b.length
163        if (la - lb > 1 || lb - la > 1) return false
164        var i = 0
165        var j = 0
166        var edits = 0
167        while (i < la && j < lb) {
168            if (a[i] == b[j]) {
169                i++; j++
170            } else {
171                if (++edits > 1) return false
172                when {
173                    la > lb -> i++
174                    la < lb -> j++
175                    else -> { i++; j++ }
176                }
177            }
178        }
179        return edits + (la - i) + (lb - j) <= 1
180    }
181
182    /** Palabras más probables tras [prev], según los bigramas aprendidos. */
183    fun nextWords(prev: String, limit: Int = 3): List<String> {
184        val a = clean(prev) ?: return emptyList()
185        val map = bigrams[a] ?: return emptyList()
186        return map.entries
187            // Evita propagar erratas: la siguiente debe ser palabra conocida.
188            .filter { inBase(it.key) || (learned[it.key] ?: 0) >= PROMOTE }
189            .sortedByDescending { it.value }
190            .map { it.key }
191            .take(limit)
192    }
193
194    /** Aprende/refuerza una palabra escrita por el usuario (se promueve sola al llegar a PROMOTE). */
195    fun learn(raw: String) {
196        val w = clean(raw) ?: return
197        learned.merge(w, 1) { a, _ -> a + 1 }
198        scope.launch(Dispatchers.IO) {
199            runCatching {
200                store.edit { prefs ->
201                    val key = intPreferencesKey(W + w)
202                    prefs[key] = (prefs[key] ?: 0) + 1
203                }
204            }
205        }
206    }
207
208    /** Registra que [next] siguió a [prev] (para la predicción de palabra siguiente). */
209    fun learnBigram(prev: String, next: String) {
210        val a = clean(prev) ?: return
211        val b = clean(next) ?: return
212        bigrams.getOrPut(a) { ConcurrentHashMap() }.merge(b, 1) { x, _ -> x + 1 }
213        scope.launch(Dispatchers.IO) {
214            runCatching {
215                store.edit { prefs ->
216                    val key = intPreferencesKey(B + a + SEP + b)
217                    prefs[key] = (prefs[key] ?: 0) + 1
218                }
219            }
220        }
221    }
222
223    private fun clean(raw: String): String? {
224        val w = raw.trim().lowercase()
225        return if (w.length >= 2 && w.all { it.isLetter() }) w else null
226    }
227
228    private companion object {
229        const val BASE_TOP = 20000      // ~tamaño de cada diccionario; da pesos descendentes por frecuencia
230        const val LEARN_INC = 2000      // cada uso pesa como subir ~2000 puestos de frecuencia
231        const val EXACT_BONUS = 50000   // un prefijo exacto (con acentos) gana a cualquier base
232        const val FUZZY_PENALTY = 100000 // una coincidencia con errata siempre va por debajo de las limpias
233        const val PROMOTE = 2
234        const val W = "w:"
235        const val B = "b:"
236        const val SEP = '>'   // separador prev/next; seguro porque las palabras son solo letras
237    }
238}