⎇ f37d04fbc73ba84633ad5ea9d762032f7cc3d958
keyboard/src/main/java/com/rizoma/trazo/data/SuggestionEngine.kt
History
raw
Trazo: ampliar diccionarios ES/EN + ajustes del motor de sugerencias
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}