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/Connectors.js +105 -0
- package/Database.js +146 -0
- package/FileSystem.js +108 -0
- package/LICENSE +21 -0
- package/Parser.js +762 -0
- package/RAG.js +572 -0
- package/README.md +23 -0
- package/botql.js +789 -0
- package/package.json +34 -0
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
|
+
[](https://www.npmjs.com/package/botql)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://github.com/adilson889/botql/pulls)
|
|
6
|
+
[](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
|