botql 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/RAG.js ADDED
@@ -0,0 +1,572 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * RAG.js — Retrieval local sobre ficheiros de conhecimento .txt
5
+ *
6
+ * Usado pelo REPLY THINK(ficheiro.txt): não liga a nenhuma IA, não sai da
7
+ * máquina, não usa embeddings nem rede. É recuperação de texto por
8
+ * múltiplos sinais combinados, todos calculáveis localmente:
9
+ *
10
+ * 1. BM25 — pesa cada palavra da mensagem pela raridade dela no
11
+ * ficheiro (uma palavra rara que aparece é mais decisiva que uma
12
+ * comum), e satura a contagem (a 5ª repetição da mesma palavra já
13
+ * conta pouco mais que a 4ª).
14
+ * 2. Stemming leve em português — normaliza plurais e sufixos comuns
15
+ * ("produtos" e "produto" contam como a mesma palavra) para não
16
+ * perder pontos só por causa de flexão gramatical.
17
+ * 3. Bónus de frase exata — se um pedaço de 2+ palavras da mensagem
18
+ * aparece tal e qual no bloco, isso conta mais do que as mesmas
19
+ * palavras espalhadas e desconexas.
20
+ * 4. Tolerância a erros de escrita — uma palavra da mensagem que não
21
+ * bate em nada mas está a 1-2 letras de distância de uma palavra do
22
+ * bloco (Levenshtein) ainda conta, com peso reduzido.
23
+ * 5. Confiança normalizada (0 a 1) — para o `.sql` poder decidir, por
24
+ * exemplo, só usar a resposta se a confiança for superior a 0.3, e
25
+ * cair no OTHERWISE caso contrário.
26
+ *
27
+ * O que isto NÃO é: compreensão de linguagem. Não percebe sinónimos que
28
+ * não partilhem raiz nem letras parecidas ("horas" e "horário" continuam
29
+ * a ser tratadas como palavras diferentes — são raízes diferentes, não
30
+ * uma questão de sufixo). Para esses casos, o parâmetro `synonyms` (ver
31
+ * KnowledgeIndex) permite ligar manualmente um termo a outro.
32
+ *
33
+ * Formato do ficheiro de conhecimento: blocos de texto livre, separados
34
+ * por uma linha em branco.
35
+ *
36
+ * Uso:
37
+ * const { KnowledgeIndex } = require('./RAG.js');
38
+ * const index = new KnowledgeIndex(textoDoFicheiro);
39
+ * const resultado = index.search('vocês entregam fora de Luanda?');
40
+ * // { text: '...', score: 3.4, confidence: 0.77, index: 2 }
41
+ * // ou null se a mensagem não partilhar nada com nenhum bloco
42
+ */
43
+
44
+ const STOPWORDS = new Set([
45
+ 'a', 'o', 'as', 'os', 'de', 'da', 'do', 'das', 'dos', 'e', 'ou',
46
+ 'que', 'um', 'uma', 'uns', 'umas', 'em', 'no', 'na', 'nos', 'nas',
47
+ 'por', 'para', 'com', 'sem', 'se', 'foi', 'ser', 'sao', 'esta',
48
+ 'estao', 'ao', 'aos', 'mas', 'como', 'tem', 'ter', 'nao',
49
+ 'sim', 'meu', 'minha', 'seu', 'sua', 'eu', 'tu', 'ele', 'ela',
50
+ 'nos', 'vos', 'eles', 'elas', 'isso', 'isto', 'aquilo', 'quando',
51
+ 'onde', 'porque', 'qual', 'quais', 'muito', 'muita', 'ja', 'so'
52
+ ]);
53
+
54
+ // ===== Normalização e stemming leve (PT) =====
55
+
56
+ function normalizar(texto) {
57
+ return texto.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
58
+ }
59
+
60
+ // Stemmer conservador: só remove sufixos muito regulares e de baixo risco
61
+ // de juntar palavras que não deviam ("produtos" -> "produto" é seguro;
62
+ // não tenta reduzir verbos a infinitivo, porque isso erra fácil e junta
63
+ // palavras com sentidos diferentes).
64
+ const SUFIXOS_ADJETIVO_ADVERBIO = ['issimamente', 'issimo', 'issima', 'mente'];
65
+ const SUFIXOS_NOMINALIZACAO = ['acoes', 'acao', 'imentos', 'imento', 'idades', 'idade'];
66
+
67
+ function stem(palavra) {
68
+ let p = palavra;
69
+
70
+ for (const suf of SUFIXOS_ADJETIVO_ADVERBIO) {
71
+ if (p.length > suf.length + 3 && p.endsWith(suf)) {
72
+ p = p.slice(0, -suf.length);
73
+ break;
74
+ }
75
+ }
76
+ for (const suf of SUFIXOS_NOMINALIZACAO) {
77
+ if (p.length > suf.length + 3 && p.endsWith(suf)) {
78
+ p = p.slice(0, -suf.length);
79
+ break;
80
+ }
81
+ }
82
+ // Plural regular: "produtos" -> "produto", "entregas" -> "entrega".
83
+ if (p.length > 4 && p.endsWith('s') && !p.endsWith('ns')) {
84
+ p = p.slice(0, -1);
85
+ }
86
+ return p;
87
+ }
88
+
89
+ function tokenizar(texto) {
90
+ return normalizar(texto)
91
+ .toLowerCase()
92
+ .replace(/[^a-z0-9\s]/g, ' ')
93
+ .split(/\s+/)
94
+ .filter((palavra) => palavra.length > 1 && !STOPWORDS.has(palavra))
95
+ .map(stem);
96
+ }
97
+
98
+ // ===== Distância de edição (Levenshtein), para tolerar erros de escrita =====
99
+
100
+ function distanciaEdicao(a, b) {
101
+ if (a === b) return 0;
102
+ const la = a.length;
103
+ const lb = b.length;
104
+ if (la === 0) return lb;
105
+ if (lb === 0) return la;
106
+
107
+ let linhaAnterior = new Array(lb + 1);
108
+ for (let j = 0; j <= lb; j++) linhaAnterior[j] = j;
109
+
110
+ for (let i = 1; i <= la; i++) {
111
+ const linhaAtual = [i];
112
+ for (let j = 1; j <= lb; j++) {
113
+ const custo = a[i - 1] === b[j - 1] ? 0 : 1;
114
+ linhaAtual[j] = Math.min(
115
+ linhaAtual[j - 1] + 1,
116
+ linhaAnterior[j] + 1,
117
+ linhaAnterior[j - 1] + custo
118
+ );
119
+ }
120
+ linhaAnterior = linhaAtual;
121
+ }
122
+ return linhaAnterior[lb];
123
+ }
124
+
125
+ function distanciaMaximaTolerada(tamanho) {
126
+ if (tamanho <= 4) return 0;
127
+ if (tamanho <= 7) return 1;
128
+ return 2;
129
+ }
130
+
131
+ // ===== BM25 =====
132
+
133
+ const BM25_K1 = 1.5;
134
+ const BM25_B = 0.75;
135
+
136
+ const PESO_BM25 = 1;
137
+ const PESO_FRASE = 2.5;
138
+ const PESO_FUZZY = 0.4;
139
+
140
+ // ===== Limiares do DecisionEngine (usados só em analyze(), não em search()) =====
141
+
142
+ // confidence minima para considerar RESPONDER direto
143
+ const CONFIANCA_ALTA = 0.55;
144
+ // abaixo disto, mesmo sem concorrente, e considerado ruido -> UNKNOWN
145
+ const CONFIANCA_MINIMA = 0.15;
146
+ // margem relativa minima entre 1o e 2o colocado: quanto o melhor precisa
147
+ // estar na frente do segundo (em % do proprio score) para ser decisivo.
148
+ // margem baixa = os dois blocos disputam a resposta -> REANALISAR
149
+ const MARGEM_MINIMA = 0.12;
150
+
151
+ // similaridade (Jaccard sobre tokens) a partir da qual dois blocos
152
+ // concorrentes sao considerados "o mesmo conteudo, dito duas vezes"
153
+ // -> nesse caso nao ha o que unir, so responde com o melhor tal como esta
154
+ const SIMILARIDADE_DUPLICADA = 0.75;
155
+
156
+ // ===== Deteção de entidades simples (nomes proprios) para a fusão =====
157
+ //
158
+ // Heuristica propositalmente simples (sem NER de verdade): uma palavra
159
+ // capitalizada que não é a primeira da frase é tratada como nome próprio
160
+ // (local, produto, pessoa). Funciona bem para o caso comum de FAQs
161
+ // ("entregamos em Luanda" / "entregamos no Huambo"), mas não é infalível
162
+ // — frases com mais de um nome próprio, ou sem nenhum, simplesmente não
163
+ // entram no caminho de fusão (ver _tentarUnir).
164
+
165
+ function extrairNomesProprios(texto) {
166
+ const palavras = texto.split(/\s+/);
167
+ const encontrados = [];
168
+ for (let i = 1; i < palavras.length; i++) {
169
+ const limpa = palavras[i].replace(/^[(["']+|[.,;:!?)\]"']+$/g, '');
170
+ if (/^[A-ZÀ-Ý][a-zà-ÿ]+$/.test(limpa)) {
171
+ encontrados.push(limpa);
172
+ }
173
+ }
174
+ return encontrados;
175
+ }
176
+
177
+ class KnowledgeIndex {
178
+ /**
179
+ * @param {string} sourceText Conteúdo do ficheiro de conhecimento.
180
+ * @param {Object} [options]
181
+ * @param {Record<string,string>} [options.synonyms] Mapa de termo -> termo
182
+ * canónico, para ligar manualmente palavras que o stemmer não junta
183
+ * sozinho (ex: { horas: 'horario' }).
184
+ */
185
+ constructor(sourceText, options = {}) {
186
+ this.synonyms = {};
187
+ for (const [de, para] of Object.entries(options.synonyms || {})) {
188
+ this.synonyms[stem(normalizar(de.toLowerCase()))] = stem(normalizar(para.toLowerCase()));
189
+ }
190
+
191
+ this.blocks = KnowledgeIndex.parseBlocks(sourceText);
192
+ this._buildIndex();
193
+ }
194
+
195
+ static parseBlocks(sourceText) {
196
+ return sourceText
197
+ .split(/\n\s*\n/)
198
+ .map((bloco) => bloco.trim())
199
+ .filter((bloco) => bloco.length > 0);
200
+ }
201
+
202
+ _aplicarSinonimos(tokens) {
203
+ return tokens.map((t) => this.synonyms[t] || t);
204
+ }
205
+
206
+ _buildIndex() {
207
+ this.docs = this.blocks.map((bloco) => tokenizar(bloco));
208
+ this.docTextNormalizado = this.blocks.map((bloco) => normalizar(bloco).toLowerCase());
209
+
210
+ this.docFreq = new Map();
211
+ this.vocabulario = new Set();
212
+ for (const tokens of this.docs) {
213
+ const vistas = new Set(tokens);
214
+ for (const palavra of vistas) {
215
+ this.docFreq.set(palavra, (this.docFreq.get(palavra) || 0) + 1);
216
+ this.vocabulario.add(palavra);
217
+ }
218
+ }
219
+ this.listaVocabulario = Array.from(this.vocabulario);
220
+
221
+ this.totalDocs = this.docs.length;
222
+ this.avgDocLen = this.totalDocs === 0
223
+ ? 0
224
+ : this.docs.reduce((soma, tokens) => soma + tokens.length, 0) / this.totalDocs;
225
+ }
226
+
227
+ _idf(palavra) {
228
+ const n = this.docFreq.get(palavra) || 0;
229
+ if (n === 0) return 0;
230
+ return Math.log(1 + (this.totalDocs - n + 0.5) / (n + 0.5));
231
+ }
232
+
233
+ _termoMaisProximo(termo) {
234
+ const tolerancia = distanciaMaximaTolerada(termo.length);
235
+ if (tolerancia === 0) return null;
236
+
237
+ let melhor = null;
238
+ let melhorDist = tolerancia + 1;
239
+ for (const candidato of this.listaVocabulario) {
240
+ if (Math.abs(candidato.length - termo.length) > tolerancia) continue;
241
+ const d = distanciaEdicao(termo, candidato);
242
+ if (d < melhorDist) {
243
+ melhorDist = d;
244
+ melhor = candidato;
245
+ }
246
+ }
247
+ return melhorDist <= tolerancia ? melhor : null;
248
+ }
249
+
250
+ _scoreBM25(queryTokens, docIndex) {
251
+ const tokens = this.docs[docIndex];
252
+ if (tokens.length === 0) return 0;
253
+
254
+ const termFreq = new Map();
255
+ for (const t of tokens) termFreq.set(t, (termFreq.get(t) || 0) + 1);
256
+
257
+ let score = 0;
258
+ for (const termo of queryTokens) {
259
+ let tf = termFreq.get(termo) || 0;
260
+ let idf = this._idf(termo);
261
+ let peso = 1;
262
+
263
+ if (tf === 0) {
264
+ const proximo = this._termoMaisProximo(termo);
265
+ if (proximo === null) continue;
266
+ tf = termFreq.get(proximo) || 0;
267
+ if (tf === 0) continue;
268
+ idf = this._idf(proximo);
269
+ peso = PESO_FUZZY;
270
+ }
271
+
272
+ const numerador = tf * (BM25_K1 + 1);
273
+ const denominador = tf + BM25_K1 * (1 - BM25_B + BM25_B * (tokens.length / this.avgDocLen));
274
+ score += peso * idf * (numerador / denominador);
275
+ }
276
+ return score;
277
+ }
278
+
279
+ _bonusFrase(message, docIndex) {
280
+ const msgNorm = normalizar(message).toLowerCase().replace(/[^a-z0-9\s]/g, ' ');
281
+ const palavras = msgNorm.split(/\s+/).filter((p) => p.length > 1);
282
+ if (palavras.length < 2) return 0;
283
+
284
+ const textoBloco = this.docTextNormalizado[docIndex];
285
+ let bonus = 0;
286
+
287
+ for (let tamanho = Math.min(6, palavras.length); tamanho >= 2; tamanho--) {
288
+ for (let i = 0; i + tamanho <= palavras.length; i++) {
289
+ const frase = palavras.slice(i, i + tamanho).join(' ');
290
+ if (textoBloco.includes(frase)) {
291
+ bonus += tamanho * tamanho;
292
+ }
293
+ }
294
+ }
295
+ return bonus;
296
+ }
297
+
298
+ _scoreDoc(message, queryTokens, docIndex) {
299
+ const bm25 = this._scoreBM25(queryTokens, docIndex);
300
+ const frase = this._bonusFrase(message, docIndex);
301
+ return PESO_BM25 * bm25 + PESO_FRASE * frase;
302
+ }
303
+
304
+ // Calcula o score de todos os blocos para a mensagem e devolve ordenado
305
+ // do maior para o menor. Base partilhada por search() e analyze().
306
+ _rankTodos(message) {
307
+ const queryTokens = this._aplicarSinonimos(tokenizar(message));
308
+ if (queryTokens.length === 0) return [];
309
+
310
+ const resultados = [];
311
+ for (let i = 0; i < this.totalDocs; i++) {
312
+ resultados.push({ index: i, score: this._scoreDoc(message, queryTokens, i) });
313
+ }
314
+ resultados.sort((a, b) => b.score - a.score);
315
+ return resultados;
316
+ }
317
+
318
+ /**
319
+ * Procura o bloco mais relevante para a mensagem recebida.
320
+ *
321
+ * @returns {{text: string, score: number, confidence: number, index: number} | null}
322
+ * `confidence` está sempre entre 0 e 1 — não é probabilidade
323
+ * estatística real, é uma escala interpretável para decidir um
324
+ * limiar no `.sql` (ex: "só responde se confidence > 0.3").
325
+ */
326
+ search(message) {
327
+ if (this.totalDocs === 0) return null;
328
+
329
+ const ranking = this._rankTodos(message);
330
+ if (ranking.length === 0) return null;
331
+
332
+ const melhor = ranking[0];
333
+ if (melhor.score <= 0) return null;
334
+
335
+ return {
336
+ text: this.blocks[melhor.index],
337
+ score: melhor.score,
338
+ confidence: melhor.score / (melhor.score + 3),
339
+ index: melhor.index
340
+ };
341
+ }
342
+
343
+ // Jaccard sobre os tokens (já com stem aplicado) de dois blocos já
344
+ // indexados. Usado só pra detetar "mesmo conteúdo repetido" (valor
345
+ // alto) — dois blocos sobre o mesmo assunto mas com detalhes
346
+ // diferentes normalmente NÃO têm jaccard alto (a maior parte da frase
347
+ // difere), por isso não serve pra decidir se vale a pena tentar unir.
348
+ _similaridadeBlocos(indexA, indexB) {
349
+ const a = new Set(this.docs[indexA]);
350
+ const b = new Set(this.docs[indexB]);
351
+ if (a.size === 0 || b.size === 0) return 0;
352
+ let intersecao = 0;
353
+ for (const t of a) if (b.has(t)) intersecao++;
354
+ const uniao = a.size + b.size - intersecao;
355
+ return uniao === 0 ? 0 : intersecao / uniao;
356
+ }
357
+
358
+ // Quantos tokens (com stem) os dois blocos partilham, em termos
359
+ // absolutos. Usado como gatilho pra tentar unir: basta partilharem UM
360
+ // termo de assunto ("entregamos") — quem garante que a fusão é segura
361
+ // não é isto, é a regra de "exatamente um nome próprio diferente em
362
+ // cada bloco" dentro de _tentarUnir.
363
+ _termosPartilhados(indexA, indexB) {
364
+ const a = new Set(this.docs[indexA]);
365
+ const b = this.docs[indexB];
366
+ let count = 0;
367
+ for (const t of b) if (a.has(t)) count++;
368
+ return count;
369
+ }
370
+
371
+ // Compara dois blocos concorrentes pelo nome próprio que cada um
372
+ // menciona, pra decidir se são "a mesma informação" ou "informação
373
+ // complementar que dá pra unir":
374
+ // - mesmo nome próprio nos dois (ex: os dois falam de Luanda)
375
+ // -> { tipo: 'duplicado' }: é a mesma coisa dita de formas
376
+ // diferentes, não há o que unir, usa qualquer um dos dois
377
+ // - nomes próprios diferentes (ex: Luanda vs Huambo)
378
+ // -> { tipo: 'unido', texto: '...' }: funde numa frase só
379
+ // - não dá pra identificar com segurança (nenhum nome próprio, ou
380
+ // mais de um em algum dos blocos) -> null: quem chama decide o
381
+ // que fazer a seguir (normalmente: reanalisar, ou cair no fallback)
382
+ _tentarUnir(textoA, textoB) {
383
+ const entidadesA = extrairNomesProprios(textoA);
384
+ const entidadesB = extrairNomesProprios(textoB);
385
+
386
+ if (entidadesA.length !== 1 || entidadesB.length !== 1) return null;
387
+
388
+ const entidadeA = entidadesA[0];
389
+ const entidadeB = entidadesB[0];
390
+
391
+ if (normalizar(entidadeA).toLowerCase() === normalizar(entidadeB).toLowerCase()) {
392
+ return { tipo: 'duplicado' };
393
+ }
394
+
395
+ // usa a primeira palavra do bloco de maior score como a "ação" comum
396
+ // (ex: "Entregamos"), assumindo que é o verbo que abre a frase
397
+ const primeiraPalavra = textoA.trim().split(/\s+/)[0].replace(/[.,;:!?]+$/, '');
398
+
399
+ return { tipo: 'unido', texto: `${primeiraPalavra} em vários lugares, como ${entidadeA} e ${entidadeB}.` };
400
+ }
401
+
402
+ // Segunda tentativa de ranking, usada só quando a primeira ficou
403
+ // ambígua: descarta metade dos termos da query (os de menor IDF, ou
404
+ // seja, os mais genéricos/comuns) e refaz o ranking só com os termos
405
+ // mais raros/decisivos. Uma query mais focada às vezes desempata o
406
+ // que uma query "cheia" deixa embolado.
407
+ _reanalisarFocado(message) {
408
+ const tokens = this._aplicarSinonimos(tokenizar(message));
409
+ if (tokens.length <= 2) return null; // já é curta, não dá pra focar mais
410
+
411
+ const comIdf = tokens.map((t) => ({ termo: t, idf: this._idf(t) }));
412
+ comIdf.sort((a, b) => b.idf - a.idf);
413
+ const focados = comIdf.slice(0, Math.max(1, Math.ceil(tokens.length / 2))).map((x) => x.termo);
414
+
415
+ const resultados = [];
416
+ for (let i = 0; i < this.totalDocs; i++) {
417
+ resultados.push({ index: i, score: this._scoreDoc(message, focados, i) });
418
+ }
419
+ resultados.sort((a, b) => b.score - a.score);
420
+ return resultados;
421
+ }
422
+
423
+ /**
424
+ * Versão completa da busca: além do melhor bloco, avalia a evidência
425
+ * (melhor x segundo colocado) e devolve uma decisão explícita, em vez
426
+ * de deixar o `.sql` decidir tudo com um único limiar de confidence.
427
+ *
428
+ * EvidenceEvaluator: compara o melhor resultado com o segundo. Se os
429
+ * dois estão muito próximos, o motor não tem certeza de qual bloco
430
+ * responde à pergunta — mesmo que o score absoluto seja alto.
431
+ *
432
+ * ConfidenceEngine: mesma fórmula de sempre (score / (score + 3)),
433
+ * aplicada só ao melhor resultado.
434
+ *
435
+ * DecisionEngine: cruza confidence com margem para decidir entre:
436
+ * - RESPONDER confidence alta e o melhor bloco se destaca do 2o,
437
+ * OU os dois concorrentes foram reconciliados (ver
438
+ * abaixo) — nesse caso `texto` já vem pronto pra usar
439
+ * - REANALISAR os blocos concorrentes são sobre assuntos diferentes
440
+ * demais pra reconciliar, e nem a retentativa focada
441
+ * resolveu — o `.sql` decide o que fazer (normalmente
442
+ * cair no fallback do OR REPLY)
443
+ * - UNKNOWN confidence baixa demais, não há bloco que sirva
444
+ *
445
+ * Reconciliação (só entra quando o resultado não é decisivo de cara):
446
+ * 1. Se o melhor e o segundo colocado são basicamente o mesmo
447
+ * conteúdo (alta similaridade) — não há nada pra unir, usa o
448
+ * melhor tal como está.
449
+ * 2. Se são blocos diferentes mas do mesmo assunto, e cada um tem
450
+ * exatamente um nome próprio diferente (ex: "entregamos em
451
+ * Luanda" / "entregamos no Huambo") — tenta fundir numa frase só
452
+ * ("entregamos em vários lugares, como Luanda e Huambo").
453
+ * 3. Se nada disso se aplica, tenta de novo com uma versão mais
454
+ * enxuta da pergunta (só os termos mais decisivos) antes de
455
+ * desistir.
456
+ *
457
+ * @returns {{
458
+ * decision: 'RESPONDER'|'REANALISAR'|'UNKNOWN',
459
+ * confidence: number,
460
+ * margem: number,
461
+ * texto: string | null,
462
+ * unificado: boolean,
463
+ * reanalisado: boolean,
464
+ * melhor: {text: string, score: number, index: number} | null,
465
+ * segundo: {text: string, score: number, index: number} | null
466
+ * }}
467
+ */
468
+ analyze(message) {
469
+ const vazio = { decision: 'UNKNOWN', confidence: 0, margem: 0, texto: null, unificado: false, reanalisado: false, melhor: null, segundo: null };
470
+ if (this.totalDocs === 0) return vazio;
471
+
472
+ const ranking = this._rankTodos(message);
473
+ if (ranking.length === 0 || ranking[0].score <= 0) return vazio;
474
+
475
+ const melhor = ranking[0];
476
+ const segundo = ranking[1] || { index: -1, score: 0 };
477
+
478
+ const margem = (melhor.score - segundo.score) / melhor.score;
479
+ const confidence = melhor.score / (melhor.score + 3);
480
+
481
+ const melhorInfo = { text: this.blocks[melhor.index], score: melhor.score, index: melhor.index };
482
+ const segundoInfo = segundo.index >= 0
483
+ ? { text: this.blocks[segundo.index], score: segundo.score, index: segundo.index }
484
+ : null;
485
+
486
+ if (confidence < CONFIANCA_MINIMA) return vazio;
487
+
488
+ if (confidence >= CONFIANCA_ALTA && margem >= MARGEM_MINIMA) {
489
+ return {
490
+ decision: 'RESPONDER', confidence, margem, texto: melhorInfo.text,
491
+ unificado: false, reanalisado: false, melhor: melhorInfo, segundo: segundoInfo
492
+ };
493
+ }
494
+
495
+ // zona ambígua: melhor e segundo disputam a resposta
496
+ if (segundoInfo) {
497
+ const similaridade = this._similaridadeBlocos(melhor.index, segundo.index);
498
+
499
+ if (similaridade >= SIMILARIDADE_DUPLICADA) {
500
+ // mesmo conteúdo, dito de duas formas — mantém a frase igual
501
+ return {
502
+ decision: 'RESPONDER', confidence, margem, texto: melhorInfo.text,
503
+ unificado: false, reanalisado: false, melhor: melhorInfo, segundo: segundoInfo
504
+ };
505
+ }
506
+
507
+ const termosPartilhados = this._termosPartilhados(melhor.index, segundo.index);
508
+ if (termosPartilhados >= 1) {
509
+ const resultado = this._tentarUnir(melhorInfo.text, segundoInfo.text);
510
+ if (resultado && resultado.tipo === 'duplicado') {
511
+ return {
512
+ decision: 'RESPONDER', confidence, margem, texto: melhorInfo.text,
513
+ unificado: false, reanalisado: false, melhor: melhorInfo, segundo: segundoInfo
514
+ };
515
+ }
516
+ if (resultado && resultado.tipo === 'unido') {
517
+ return {
518
+ decision: 'RESPONDER', confidence, margem, texto: resultado.texto,
519
+ unificado: true, reanalisado: false, melhor: melhorInfo, segundo: segundoInfo
520
+ };
521
+ }
522
+ }
523
+ }
524
+
525
+ // nem duplicado nem fundível: tenta de novo com a query mais focada
526
+ const tentativa2 = this._reanalisarFocado(message);
527
+ if (tentativa2 && tentativa2.length > 0 && tentativa2[0].score > 0) {
528
+ const melhor2 = tentativa2[0];
529
+ const segundo2 = tentativa2[1] || { index: -1, score: 0 };
530
+ const margem2 = (melhor2.score - segundo2.score) / melhor2.score;
531
+ const confidence2 = melhor2.score / (melhor2.score + 3);
532
+
533
+ if (confidence2 >= CONFIANCA_ALTA && margem2 >= MARGEM_MINIMA) {
534
+ return {
535
+ decision: 'RESPONDER', confidence: confidence2, margem: margem2,
536
+ texto: this.blocks[melhor2.index], unificado: false, reanalisado: true,
537
+ melhor: { text: this.blocks[melhor2.index], score: melhor2.score, index: melhor2.index },
538
+ segundo: segundo2.index >= 0
539
+ ? { text: this.blocks[segundo2.index], score: segundo2.score, index: segundo2.index }
540
+ : null
541
+ };
542
+ }
543
+ }
544
+
545
+ return {
546
+ decision: 'REANALISAR', confidence, margem, texto: null,
547
+ unificado: false, reanalisado: false, melhor: melhorInfo, segundo: segundoInfo
548
+ };
549
+ }
550
+ }
551
+
552
+ class KnowledgeCache {
553
+ constructor(fileSystem) {
554
+ this.fileSystem = fileSystem;
555
+ this.cache = new Map();
556
+ }
557
+
558
+ get(resolvedPath, options) {
559
+ if (this.cache.has(resolvedPath)) return this.cache.get(resolvedPath);
560
+
561
+ if (!this.fileSystem.exists(resolvedPath)) {
562
+ throw new Error(`BotQL/THINK: ficheiro de conhecimento não encontrado: "${resolvedPath}"`);
563
+ }
564
+
565
+ const texto = this.fileSystem.readFile(resolvedPath);
566
+ const index = new KnowledgeIndex(texto, options);
567
+ this.cache.set(resolvedPath, index);
568
+ return index;
569
+ }
570
+ }
571
+
572
+ module.exports = { KnowledgeIndex, KnowledgeCache, tokenizar, normalizar, stem, distanciaEdicao };
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # BotQL — Bot Query Language
2
+
3
+ [![npm version](https://img.shields.io/npm/v/botql.svg)](https://www.npmjs.com/package/botql)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/adilson889/botql/pulls)
6
+ [![BotQL](https://img.shields.io/badge/BotQL-rules%20language-blue.svg)](https://github.com/adilson889/botql)
7
+
8
+ **BotQL** é uma linguagem de regras simples, inspirada em SQL, para criar bots sem precisar de escrever código tradicional. Todos os comandos são escritos em **MAIÚSCULAS**, e blocos com mais de uma ação usam chaves `{ }`.
9
+
10
+ Vive no mesmo ficheiro `.sql`, com comandos reais de banco de dados — o bot age e persiste dados na mesma linguagem, sem sair do BotQL. O bot corre localmente, no computador ou servidor do próprio utilizador, e não em servidores geridos por terceiros.
11
+
12
+ Não precisas de saber programar para escrever BotQL; precisas apenas de saber o que queres que o teu bot faça.
13
+
14
+ ---
15
+
16
+ ## Documentação Completa
17
+
18
+ Ver **[docs/GETSTARTED.md](docs/GETSTARTED.md)** para o guia completo.
19
+
20
+ ## Instalação
21
+
22
+ ```bash
23
+ npm install botql