botql 1.0.1 → 1.0.2
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/botql.browser.js +1921 -0
- package/package.json +1 -1
package/botql.browser.js
ADDED
|
@@ -0,0 +1,1921 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var BotQL = (() => {
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
5
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
6
|
+
}) : x)(function(x) {
|
|
7
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
8
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
9
|
+
});
|
|
10
|
+
var __commonJS = (cb, mod) => function __require2() {
|
|
11
|
+
try {
|
|
12
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
13
|
+
} catch (e) {
|
|
14
|
+
throw mod = 0, e;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Parser.js
|
|
19
|
+
var require_Parser = __commonJS({
|
|
20
|
+
"Parser.js"(exports, module) {
|
|
21
|
+
"use strict";
|
|
22
|
+
var TokenType = {
|
|
23
|
+
KEYWORD: "KEYWORD",
|
|
24
|
+
STRING: "STRING",
|
|
25
|
+
NUMBER: "NUMBER",
|
|
26
|
+
IDENT: "IDENT",
|
|
27
|
+
SYMBOL: "SYMBOL",
|
|
28
|
+
EOF: "EOF"
|
|
29
|
+
};
|
|
30
|
+
var KEYWORDS = /* @__PURE__ */ new Set([
|
|
31
|
+
"CREATE",
|
|
32
|
+
"BOT",
|
|
33
|
+
"PLATFORM",
|
|
34
|
+
"CONNECT",
|
|
35
|
+
"TABLE",
|
|
36
|
+
"PREVENT",
|
|
37
|
+
"DEFAULT",
|
|
38
|
+
"ON",
|
|
39
|
+
"START",
|
|
40
|
+
"MESSAGE",
|
|
41
|
+
"FROM",
|
|
42
|
+
"WHEN",
|
|
43
|
+
"CONTAINS",
|
|
44
|
+
"OR",
|
|
45
|
+
"OTHERWISE",
|
|
46
|
+
"KEYWORDS",
|
|
47
|
+
"THINK",
|
|
48
|
+
"WAITING",
|
|
49
|
+
"REPLY",
|
|
50
|
+
"TO",
|
|
51
|
+
"FORWARD",
|
|
52
|
+
"PARSE",
|
|
53
|
+
"SEND",
|
|
54
|
+
"INSERT",
|
|
55
|
+
"INTO",
|
|
56
|
+
"VALUES",
|
|
57
|
+
"UPDATE",
|
|
58
|
+
"SET",
|
|
59
|
+
"WHERE",
|
|
60
|
+
"RUN",
|
|
61
|
+
"IMPORT",
|
|
62
|
+
"AS",
|
|
63
|
+
"RESPONSE"
|
|
64
|
+
]);
|
|
65
|
+
var Token = class {
|
|
66
|
+
constructor(type, value, line) {
|
|
67
|
+
this.type = type;
|
|
68
|
+
this.value = value;
|
|
69
|
+
this.line = line;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
var Tokenizer = class {
|
|
73
|
+
constructor(source) {
|
|
74
|
+
this.source = source;
|
|
75
|
+
this.pos = 0;
|
|
76
|
+
this.line = 1;
|
|
77
|
+
this.tokens = [];
|
|
78
|
+
}
|
|
79
|
+
error(msg) {
|
|
80
|
+
throw new Error(`syntax error: ${msg}`);
|
|
81
|
+
}
|
|
82
|
+
peekChar(offset = 0) {
|
|
83
|
+
return this.source[this.pos + offset];
|
|
84
|
+
}
|
|
85
|
+
tokenize() {
|
|
86
|
+
while (this.pos < this.source.length) {
|
|
87
|
+
const c = this.peekChar();
|
|
88
|
+
if (c === "\n") {
|
|
89
|
+
this.line++;
|
|
90
|
+
this.pos++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (/\s/.test(c)) {
|
|
94
|
+
this.pos++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (c === "-" && this.peekChar(1) === "-") {
|
|
98
|
+
while (this.pos < this.source.length && this.peekChar() !== "\n") this.pos++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (c === '"' || c === "'") {
|
|
102
|
+
this.tokens.push(this.readString(c));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (/[0-9]/.test(c)) {
|
|
106
|
+
this.tokens.push(this.readNumber());
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if ("{}(),.=+*;".includes(c)) {
|
|
110
|
+
this.tokens.push(new Token(TokenType.SYMBOL, c, this.line));
|
|
111
|
+
this.pos++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (/[A-Za-z_]/.test(c)) {
|
|
115
|
+
const word = this.readWord();
|
|
116
|
+
this.tokens.push(word);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
this.error(`unexpected character: "${c}"`);
|
|
120
|
+
}
|
|
121
|
+
this.tokens.push(new Token(TokenType.EOF, null, this.line));
|
|
122
|
+
return this.tokens;
|
|
123
|
+
}
|
|
124
|
+
readString(quote) {
|
|
125
|
+
const startLine = this.line;
|
|
126
|
+
this.pos++;
|
|
127
|
+
let value = "";
|
|
128
|
+
while (this.pos < this.source.length && this.peekChar() !== quote) {
|
|
129
|
+
if (this.peekChar() === "\\") {
|
|
130
|
+
this.pos++;
|
|
131
|
+
value += this.peekChar();
|
|
132
|
+
this.pos++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (this.peekChar() === "\n") this.line++;
|
|
136
|
+
value += this.peekChar();
|
|
137
|
+
this.pos++;
|
|
138
|
+
}
|
|
139
|
+
if (this.peekChar() !== quote) {
|
|
140
|
+
this.error("unterminated string");
|
|
141
|
+
}
|
|
142
|
+
this.pos++;
|
|
143
|
+
return new Token(TokenType.STRING, value, startLine);
|
|
144
|
+
}
|
|
145
|
+
readNumber() {
|
|
146
|
+
const startLine = this.line;
|
|
147
|
+
let value = "";
|
|
148
|
+
while (this.pos < this.source.length && /[0-9.]/.test(this.peekChar())) {
|
|
149
|
+
value += this.peekChar();
|
|
150
|
+
this.pos++;
|
|
151
|
+
}
|
|
152
|
+
return new Token(TokenType.NUMBER, Number(value), startLine);
|
|
153
|
+
}
|
|
154
|
+
readWord() {
|
|
155
|
+
const startLine = this.line;
|
|
156
|
+
let value = "";
|
|
157
|
+
while (this.pos < this.source.length && /[A-Za-z0-9_]/.test(this.peekChar())) {
|
|
158
|
+
value += this.peekChar();
|
|
159
|
+
this.pos++;
|
|
160
|
+
}
|
|
161
|
+
const upper = value.toUpperCase();
|
|
162
|
+
if (KEYWORDS.has(upper) && value === upper) {
|
|
163
|
+
return new Token(TokenType.KEYWORD, upper, startLine);
|
|
164
|
+
}
|
|
165
|
+
return new Token(TokenType.IDENT, value, startLine);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
var Parser = class {
|
|
169
|
+
constructor(source) {
|
|
170
|
+
this.tokens = new Tokenizer(source).tokenize();
|
|
171
|
+
this.pos = 0;
|
|
172
|
+
}
|
|
173
|
+
error(msg) {
|
|
174
|
+
const tok = this.current();
|
|
175
|
+
throw new Error(`syntax error: ${msg}, near "${tok ? tok.value : "EOF"}"`);
|
|
176
|
+
}
|
|
177
|
+
current() {
|
|
178
|
+
return this.tokens[this.pos];
|
|
179
|
+
}
|
|
180
|
+
at(type, value) {
|
|
181
|
+
const tok = this.current();
|
|
182
|
+
if (tok.type !== type) return false;
|
|
183
|
+
if (value !== void 0 && tok.value !== value) return false;
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
atKeyword(...values) {
|
|
187
|
+
return this.at(TokenType.KEYWORD) && values.includes(this.current().value);
|
|
188
|
+
}
|
|
189
|
+
// Casa por valor, independente do token ser KEYWORD ou IDENT.
|
|
190
|
+
// Necessário para palavras como "SIGNAL", que tanto introduz um
|
|
191
|
+
// evento (ON SIGNAL FROM) como é usada como identificador (SIGNAL.PAIR).
|
|
192
|
+
atWord(...values) {
|
|
193
|
+
const tok = this.current();
|
|
194
|
+
return (tok.type === TokenType.KEYWORD || tok.type === TokenType.IDENT) && values.includes(tok.value);
|
|
195
|
+
}
|
|
196
|
+
advance() {
|
|
197
|
+
const tok = this.current();
|
|
198
|
+
if (tok.type !== TokenType.EOF) this.pos++;
|
|
199
|
+
return tok;
|
|
200
|
+
}
|
|
201
|
+
expect(type, value) {
|
|
202
|
+
if (!this.at(type, value)) {
|
|
203
|
+
this.error(`expected ${value || type}`);
|
|
204
|
+
}
|
|
205
|
+
return this.advance();
|
|
206
|
+
}
|
|
207
|
+
expectKeyword(value) {
|
|
208
|
+
return this.expect(TokenType.KEYWORD, value);
|
|
209
|
+
}
|
|
210
|
+
isAtEnd() {
|
|
211
|
+
return this.at(TokenType.EOF);
|
|
212
|
+
}
|
|
213
|
+
// ---- Programa ----
|
|
214
|
+
parseProgram() {
|
|
215
|
+
const statements = [];
|
|
216
|
+
while (!this.isAtEnd()) {
|
|
217
|
+
statements.push(this.parseStatement());
|
|
218
|
+
}
|
|
219
|
+
return { type: "Program", body: statements };
|
|
220
|
+
}
|
|
221
|
+
// ---- Bloco { ... } ou statement único ----
|
|
222
|
+
// Regra da linguagem: bloco com mais de uma ação usa chaves,
|
|
223
|
+
// uma única ação não precisa.
|
|
224
|
+
parseBody() {
|
|
225
|
+
if (this.at(TokenType.SYMBOL, "{")) {
|
|
226
|
+
this.advance();
|
|
227
|
+
const statements = [];
|
|
228
|
+
while (!this.at(TokenType.SYMBOL, "}")) {
|
|
229
|
+
if (this.isAtEnd()) this.error('unclosed block "{"');
|
|
230
|
+
statements.push(this.parseStatement());
|
|
231
|
+
}
|
|
232
|
+
this.advance();
|
|
233
|
+
return statements;
|
|
234
|
+
}
|
|
235
|
+
return [this.parseStatement()];
|
|
236
|
+
}
|
|
237
|
+
// ---- Dispatch de statement ----
|
|
238
|
+
parseStatement() {
|
|
239
|
+
if (this.atKeyword("CREATE")) return this.parseCreate();
|
|
240
|
+
if (this.atKeyword("PLATFORM")) return this.parsePlatform();
|
|
241
|
+
if (this.atKeyword("CONNECT")) return this.parseConnect();
|
|
242
|
+
if (this.atKeyword("ON")) return this.parseOn();
|
|
243
|
+
if (this.atKeyword("WHEN")) return this.parseWhen();
|
|
244
|
+
if (this.atKeyword("OTHERWISE")) return this.parseOtherwise();
|
|
245
|
+
if (this.atKeyword("REPLY")) return this.parseReply();
|
|
246
|
+
if (this.atKeyword("THINK")) return this.parseThink();
|
|
247
|
+
if (this.atKeyword("FORWARD")) return this.parseForward();
|
|
248
|
+
if (this.atKeyword("PARSE")) return this.parseParseSignal();
|
|
249
|
+
if (this.atKeyword("SEND")) return this.parseSend();
|
|
250
|
+
if (this.atKeyword("INSERT")) return this.parseInsert();
|
|
251
|
+
if (this.atKeyword("UPDATE")) return this.parseUpdate();
|
|
252
|
+
if (this.atKeyword("RUN")) return this.parseRunBot();
|
|
253
|
+
if (this.atKeyword("IMPORT")) return this.parseImport();
|
|
254
|
+
this.error("unexpected statement");
|
|
255
|
+
}
|
|
256
|
+
// ---- CREATE BOT / CREATE TABLE ----
|
|
257
|
+
parseCreate() {
|
|
258
|
+
this.expectKeyword("CREATE");
|
|
259
|
+
if (this.atKeyword("BOT")) {
|
|
260
|
+
this.advance();
|
|
261
|
+
const name = this.expect(TokenType.STRING).value;
|
|
262
|
+
return { type: "CreateBot", name };
|
|
263
|
+
}
|
|
264
|
+
if (this.atKeyword("TABLE")) {
|
|
265
|
+
this.advance();
|
|
266
|
+
const name = this.expect(TokenType.IDENT).value;
|
|
267
|
+
const columns = this.parseColumnList();
|
|
268
|
+
const preventDefault = this.tryParsePreventDefault();
|
|
269
|
+
const defaultMessage = this.tryParseDefaultMessage();
|
|
270
|
+
return { type: "CreateTable", name, columns, preventDefault, defaultMessage };
|
|
271
|
+
}
|
|
272
|
+
this.error("expected BOT or TABLE after CREATE");
|
|
273
|
+
}
|
|
274
|
+
parseColumnList() {
|
|
275
|
+
this.expect(TokenType.SYMBOL, "(");
|
|
276
|
+
const columns = [];
|
|
277
|
+
while (!this.at(TokenType.SYMBOL, ")")) {
|
|
278
|
+
const colName = this.expect(TokenType.IDENT).value;
|
|
279
|
+
const constraints = [];
|
|
280
|
+
let colType = null;
|
|
281
|
+
while (this.at(TokenType.IDENT) && !this.at(TokenType.SYMBOL, ",") && !this.at(TokenType.SYMBOL, ")")) {
|
|
282
|
+
const word = this.advance().value;
|
|
283
|
+
if (!colType) colType = word;
|
|
284
|
+
else constraints.push(word);
|
|
285
|
+
}
|
|
286
|
+
columns.push({ name: colName, columnType: colType, constraints });
|
|
287
|
+
if (this.at(TokenType.SYMBOL, ",")) this.advance();
|
|
288
|
+
}
|
|
289
|
+
this.expect(TokenType.SYMBOL, ")");
|
|
290
|
+
return columns;
|
|
291
|
+
}
|
|
292
|
+
tryParsePreventDefault() {
|
|
293
|
+
if (this.atKeyword("PREVENT")) {
|
|
294
|
+
this.advance();
|
|
295
|
+
this.expectKeyword("DEFAULT");
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
// ---- DEFAULT MESSAGE "texto" ----
|
|
301
|
+
// ---- DEFAULT MESSAGE (ficheiro.txt, N) ----
|
|
302
|
+
//
|
|
303
|
+
// Modificador do CREATE TABLE, ao lado de PREVENT DEFAULT: saudação
|
|
304
|
+
// automática enviada só na primeira mensagem de cada client/sender
|
|
305
|
+
// (ver botql.js, receiveMessage). Aceita texto direto ou a mesma forma
|
|
306
|
+
// de ficheiro indexado do REPLY — por isso reaproveita isReplyFileForm
|
|
307
|
+
// para o lookahead. "DEFAULT" aqui não é o mesmo "DEFAULT" de "PREVENT
|
|
308
|
+
// DEFAULT" — só é reconhecido como início de DEFAULT MESSAGE quando NÃO
|
|
309
|
+
// vem logo a seguir a PREVENT (tryParsePreventDefault já consumiu esse
|
|
310
|
+
// caso antes de chegarmos aqui).
|
|
311
|
+
tryParseDefaultMessage() {
|
|
312
|
+
if (!this.atKeyword("DEFAULT")) return null;
|
|
313
|
+
const next = this.tokens[this.pos + 1];
|
|
314
|
+
if (!next || next.type !== TokenType.KEYWORD || next.value !== "MESSAGE") return null;
|
|
315
|
+
this.advance();
|
|
316
|
+
this.advance();
|
|
317
|
+
if (this.isReplyFileForm()) {
|
|
318
|
+
this.expect(TokenType.SYMBOL, "(");
|
|
319
|
+
const file = this.parseFileName();
|
|
320
|
+
this.expect(TokenType.SYMBOL, ",");
|
|
321
|
+
const index = this.parseExpression();
|
|
322
|
+
this.expect(TokenType.SYMBOL, ")");
|
|
323
|
+
return { file, index };
|
|
324
|
+
}
|
|
325
|
+
const value = this.parseExpression();
|
|
326
|
+
return { value };
|
|
327
|
+
}
|
|
328
|
+
// ---- PLATFORM ----
|
|
329
|
+
parsePlatform() {
|
|
330
|
+
this.expectKeyword("PLATFORM");
|
|
331
|
+
const value = this.advance().value;
|
|
332
|
+
return { type: "Platform", value };
|
|
333
|
+
}
|
|
334
|
+
// ---- CONNECT SERVIÇO TIPO "credencial" ----
|
|
335
|
+
// ---- CONNECT RESPONSE alias ----
|
|
336
|
+
//
|
|
337
|
+
// A segunda forma liga o bot a uma IA usando um valor já importado (o
|
|
338
|
+
// alias de um IMPORT {..., N} AS alias) — só estabelece a ligação,
|
|
339
|
+
// não dispara nada sozinho (quem dispara é Response()/Response(alias),
|
|
340
|
+
// dentro de uma ação). RESPONSE é keyword fixa aqui, por isso da para
|
|
341
|
+
// distinguir logo no primeiro token, sem lookahead: a forma normal
|
|
342
|
+
// nunca começa por essa palavra.
|
|
343
|
+
parseConnect() {
|
|
344
|
+
this.expectKeyword("CONNECT");
|
|
345
|
+
if (this.atKeyword("RESPONSE")) {
|
|
346
|
+
this.advance();
|
|
347
|
+
const alias = this.expect(TokenType.IDENT).value;
|
|
348
|
+
return { type: "ConnectResponse", alias };
|
|
349
|
+
}
|
|
350
|
+
const service = this.advance().value;
|
|
351
|
+
const kind = this.advance().value;
|
|
352
|
+
const credential = this.expect(TokenType.STRING).value;
|
|
353
|
+
return { type: "Connect", service, kind, credential };
|
|
354
|
+
}
|
|
355
|
+
// ---- ON START | ON MESSAGE | ON SIGNAL FROM "..." ----
|
|
356
|
+
parseOn() {
|
|
357
|
+
this.expectKeyword("ON");
|
|
358
|
+
if (this.atKeyword("START")) {
|
|
359
|
+
this.advance();
|
|
360
|
+
const body = this.parseBody();
|
|
361
|
+
return { type: "On", event: "START", body };
|
|
362
|
+
}
|
|
363
|
+
if (this.atKeyword("MESSAGE")) {
|
|
364
|
+
this.advance();
|
|
365
|
+
const body = this.parseBody();
|
|
366
|
+
return { type: "On", event: "MESSAGE", body };
|
|
367
|
+
}
|
|
368
|
+
if (this.atWord("SIGNAL")) {
|
|
369
|
+
this.advance();
|
|
370
|
+
this.expectKeyword("FROM");
|
|
371
|
+
const source = this.expect(TokenType.STRING).value;
|
|
372
|
+
const body = this.parseBody();
|
|
373
|
+
return { type: "On", event: "SIGNAL", source, body };
|
|
374
|
+
}
|
|
375
|
+
this.error("unexpected event after ON");
|
|
376
|
+
}
|
|
377
|
+
// ---- WHEN CONTAINS "..." [OR CONTAINS "..."]* ----
|
|
378
|
+
parseWhen() {
|
|
379
|
+
this.expectKeyword("WHEN");
|
|
380
|
+
const conditions = [this.parseCondition()];
|
|
381
|
+
while (this.atKeyword("OR")) {
|
|
382
|
+
this.advance();
|
|
383
|
+
conditions.push(this.parseCondition());
|
|
384
|
+
}
|
|
385
|
+
const body = this.parseBody();
|
|
386
|
+
return { type: "When", conditions, body };
|
|
387
|
+
}
|
|
388
|
+
parseCondition() {
|
|
389
|
+
this.expectKeyword("CONTAINS");
|
|
390
|
+
if (this.at(TokenType.SYMBOL, "(")) {
|
|
391
|
+
const values = this.parseStringList();
|
|
392
|
+
return { op: "CONTAINS_ANY", values };
|
|
393
|
+
}
|
|
394
|
+
if (this.atKeyword("KEYWORDS")) {
|
|
395
|
+
this.advance();
|
|
396
|
+
this.expect(TokenType.SYMBOL, "(");
|
|
397
|
+
const path = this.parseFileName();
|
|
398
|
+
this.expect(TokenType.SYMBOL, ")");
|
|
399
|
+
return { op: "CONTAINS_KEYWORDS_FILE", path };
|
|
400
|
+
}
|
|
401
|
+
const value = this.expect(TokenType.STRING).value;
|
|
402
|
+
return { op: "CONTAINS", value };
|
|
403
|
+
}
|
|
404
|
+
// Lista de strings entre parênteses: ("a", "b", "c")
|
|
405
|
+
parseStringList() {
|
|
406
|
+
this.expect(TokenType.SYMBOL, "(");
|
|
407
|
+
const values = [];
|
|
408
|
+
while (!this.at(TokenType.SYMBOL, ")")) {
|
|
409
|
+
values.push(this.expect(TokenType.STRING).value);
|
|
410
|
+
if (this.at(TokenType.SYMBOL, ",")) this.advance();
|
|
411
|
+
}
|
|
412
|
+
this.expect(TokenType.SYMBOL, ")");
|
|
413
|
+
return values;
|
|
414
|
+
}
|
|
415
|
+
// Nome de ficheiro sem aspas, ex: saudacoes.txt ou lista_bot.sql.
|
|
416
|
+
// O tokenizer separa "saudacoes" (IDENT) e ".txt" em IDENT + SYMBOL('.')
|
|
417
|
+
// + IDENT, por isso remontamos aqui em vez de pedir um STRING.
|
|
418
|
+
parseFileName() {
|
|
419
|
+
let name = this.expect(TokenType.IDENT).value;
|
|
420
|
+
while (this.at(TokenType.SYMBOL, ".")) {
|
|
421
|
+
this.advance();
|
|
422
|
+
name += "." + this.expect(TokenType.IDENT).value;
|
|
423
|
+
}
|
|
424
|
+
return name;
|
|
425
|
+
}
|
|
426
|
+
// ---- OTHERWISE ----
|
|
427
|
+
parseOtherwise() {
|
|
428
|
+
this.expectKeyword("OTHERWISE");
|
|
429
|
+
const body = this.parseBody();
|
|
430
|
+
return { type: "Otherwise", body };
|
|
431
|
+
}
|
|
432
|
+
// ---- REPLY [TO alvo] expressão ----
|
|
433
|
+
// ---- REPLY [TO alvo] (ficheiro.txt, N) ----
|
|
434
|
+
//
|
|
435
|
+
// A segunda forma referencia uma entrada numerada de um ficheiro de
|
|
436
|
+
// respostas (uma linha "N- texto" por entrada). Distingue-se da
|
|
437
|
+
// expressão comum porque começa por "(" seguido de um nome de
|
|
438
|
+
// ficheiro (IDENT + "." + IDENT), e não por uma STRING/NUMBER/IDENT
|
|
439
|
+
// isolado — daí o lookahead antes de decidir qual ramo seguir.
|
|
440
|
+
// ---- THINK(ficheiro.txt) ----
|
|
441
|
+
// ---- THINK(ficheiro.txt) OR REPLY (fallback.txt, N) ----
|
|
442
|
+
// ---- THINK(ficheiro.txt) OR REPLY "texto fixo" ----
|
|
443
|
+
//
|
|
444
|
+
// Diferente de REPLY: THINK não responde com um texto já mapeado, faz
|
|
445
|
+
// retrieval local sobre um ficheiro de conhecimento (ver RAG.js) e só
|
|
446
|
+
// responde se achar um bloco com confiança suficiente. Antes de
|
|
447
|
+
// procurar, sinaliza onThinking (para a plataforma poder mostrar
|
|
448
|
+
// "Pensando..." enquanto isso, já que a busca — em ficheiros grandes —
|
|
449
|
+
// não é instantânea como um REPLY comum).
|
|
450
|
+
//
|
|
451
|
+
// O "OR REPLY ..." é o fallback: só corre se THINK não encontrar nada
|
|
452
|
+
// com confiança suficiente. Fica preso ao mesmo statement (não é um
|
|
453
|
+
// segundo statement solto no corpo do WHEN) porque só faz sentido
|
|
454
|
+
// junto — um REPLY fallback sem THINK antes seria só um REPLY normal.
|
|
455
|
+
parseThink() {
|
|
456
|
+
this.expectKeyword("THINK");
|
|
457
|
+
this.expect(TokenType.SYMBOL, "(");
|
|
458
|
+
const file = this.parseFileName();
|
|
459
|
+
this.expect(TokenType.SYMBOL, ")");
|
|
460
|
+
const waiting = this.tryParseWaiting();
|
|
461
|
+
let fallback = null;
|
|
462
|
+
if (this.atKeyword("OR")) {
|
|
463
|
+
this.advance();
|
|
464
|
+
fallback = this.parseReply();
|
|
465
|
+
}
|
|
466
|
+
return { type: "Think", file, waiting, fallback };
|
|
467
|
+
}
|
|
468
|
+
// ---- WAITING() | WAITING(ficheiro.txt) | WAITING(ficheiro.txt, N) ----
|
|
469
|
+
//
|
|
470
|
+
// Modificador do THINK, sempre logo a seguir a ele, antes do OR (ver
|
|
471
|
+
// parseThink acima). Os dois argumentos são opcionais e independentes:
|
|
472
|
+
// sem nenhum, o interpretador usa texto e tempo mínimo por omissão
|
|
473
|
+
// ("Pensando...", 3 segundos) — ver botql.js, resolveWaitingConfig.
|
|
474
|
+
tryParseWaiting() {
|
|
475
|
+
if (!this.atKeyword("WAITING")) return null;
|
|
476
|
+
this.advance();
|
|
477
|
+
this.expect(TokenType.SYMBOL, "(");
|
|
478
|
+
let file = null;
|
|
479
|
+
let seconds = null;
|
|
480
|
+
if (!this.at(TokenType.SYMBOL, ")")) {
|
|
481
|
+
file = this.parseFileName();
|
|
482
|
+
if (this.at(TokenType.SYMBOL, ",")) {
|
|
483
|
+
this.advance();
|
|
484
|
+
seconds = this.parseExpression();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
this.expect(TokenType.SYMBOL, ")");
|
|
488
|
+
return { file, seconds };
|
|
489
|
+
}
|
|
490
|
+
parseReply() {
|
|
491
|
+
this.expectKeyword("REPLY");
|
|
492
|
+
let target = null;
|
|
493
|
+
if (this.atKeyword("TO")) {
|
|
494
|
+
this.advance();
|
|
495
|
+
target = this.advance().value;
|
|
496
|
+
}
|
|
497
|
+
if (this.isReplyFileForm()) {
|
|
498
|
+
this.expect(TokenType.SYMBOL, "(");
|
|
499
|
+
const file = this.parseFileName();
|
|
500
|
+
this.expect(TokenType.SYMBOL, ",");
|
|
501
|
+
const index = this.parseExpression();
|
|
502
|
+
this.expect(TokenType.SYMBOL, ")");
|
|
503
|
+
return { type: "Reply", target, file, index };
|
|
504
|
+
}
|
|
505
|
+
const value = this.parseExpression();
|
|
506
|
+
return { type: "Reply", target, value };
|
|
507
|
+
}
|
|
508
|
+
// Lookahead: "(" IDENT "." IDENT "," ... — só a forma de ficheiro
|
|
509
|
+
// tem "." logo a seguir ao primeiro identificador dentro dos
|
|
510
|
+
// parênteses. Uma chamada de função normal, ex: (NOW()), nunca
|
|
511
|
+
// bate aqui.
|
|
512
|
+
isReplyFileForm() {
|
|
513
|
+
if (!this.at(TokenType.SYMBOL, "(")) return false;
|
|
514
|
+
const next = this.tokens[this.pos + 1];
|
|
515
|
+
const afterNext = this.tokens[this.pos + 2];
|
|
516
|
+
return !!next && next.type === TokenType.IDENT && !!afterNext && afterNext.type === TokenType.SYMBOL && afterNext.value === ".";
|
|
517
|
+
}
|
|
518
|
+
// ---- FORWARD TO "contacto" ----
|
|
519
|
+
parseForward() {
|
|
520
|
+
this.expectKeyword("FORWARD");
|
|
521
|
+
this.expectKeyword("TO");
|
|
522
|
+
const target = this.parseExpression();
|
|
523
|
+
return { type: "ForwardTo", target };
|
|
524
|
+
}
|
|
525
|
+
// ---- PARSE SIGNAL ----
|
|
526
|
+
parseParseSignal() {
|
|
527
|
+
this.expectKeyword("PARSE");
|
|
528
|
+
if (!this.atWord("SIGNAL")) this.error("expected SIGNAL after PARSE");
|
|
529
|
+
this.advance();
|
|
530
|
+
return { type: "ParseSignal" };
|
|
531
|
+
}
|
|
532
|
+
// ---- SEND TO destino ----
|
|
533
|
+
parseSend() {
|
|
534
|
+
this.expectKeyword("SEND");
|
|
535
|
+
this.expectKeyword("TO");
|
|
536
|
+
const target = this.advance().value;
|
|
537
|
+
return { type: "SendTo", target };
|
|
538
|
+
}
|
|
539
|
+
// ---- INSERT INTO tabela [(cols)] [VALUES (vals)] ----
|
|
540
|
+
parseInsert() {
|
|
541
|
+
this.expectKeyword("INSERT");
|
|
542
|
+
this.expectKeyword("INTO");
|
|
543
|
+
const table = this.expect(TokenType.IDENT).value;
|
|
544
|
+
let columns = null;
|
|
545
|
+
if (this.at(TokenType.SYMBOL, "(")) {
|
|
546
|
+
columns = this.parseExpressionList();
|
|
547
|
+
}
|
|
548
|
+
let values = null;
|
|
549
|
+
if (this.atKeyword("VALUES")) {
|
|
550
|
+
this.advance();
|
|
551
|
+
values = this.parseExpressionList();
|
|
552
|
+
}
|
|
553
|
+
return { type: "Insert", table, columns, values };
|
|
554
|
+
}
|
|
555
|
+
parseExpressionList() {
|
|
556
|
+
this.expect(TokenType.SYMBOL, "(");
|
|
557
|
+
const items = [];
|
|
558
|
+
while (!this.at(TokenType.SYMBOL, ")")) {
|
|
559
|
+
items.push(this.parseExpression());
|
|
560
|
+
if (this.at(TokenType.SYMBOL, ",")) this.advance();
|
|
561
|
+
}
|
|
562
|
+
this.expect(TokenType.SYMBOL, ")");
|
|
563
|
+
return items;
|
|
564
|
+
}
|
|
565
|
+
// ---- UPDATE tabela SET col = expr WHERE expr ----
|
|
566
|
+
parseUpdate() {
|
|
567
|
+
this.expectKeyword("UPDATE");
|
|
568
|
+
const table = this.expect(TokenType.IDENT).value;
|
|
569
|
+
this.expectKeyword("SET");
|
|
570
|
+
const column = this.expect(TokenType.IDENT).value;
|
|
571
|
+
this.expect(TokenType.SYMBOL, "=");
|
|
572
|
+
const value = this.parseExpression();
|
|
573
|
+
let where = null;
|
|
574
|
+
if (this.atKeyword("WHERE")) {
|
|
575
|
+
this.advance();
|
|
576
|
+
where = this.parseComparison();
|
|
577
|
+
}
|
|
578
|
+
return { type: "Update", table, set: { column, value }, where };
|
|
579
|
+
}
|
|
580
|
+
// ---- IMPORT {ficheiro.sql} ----
|
|
581
|
+
// ---- IMPORT {ficheiro.txt, N} ----
|
|
582
|
+
// ---- IMPORT {ficheiro.txt, N} AS alias ----
|
|
583
|
+
//
|
|
584
|
+
// A segunda forma importa só a entrada N de um ficheiro de valores
|
|
585
|
+
// (o mesmo formato "N- texto" usado por REPLY (ficheiro, N)), em vez
|
|
586
|
+
// do ficheiro inteiro. O índice é opcional, tal como em REPLY.
|
|
587
|
+
//
|
|
588
|
+
// AS alias só faz sentido junto com o índice (dá nome ao valor lido,
|
|
589
|
+
// para CONNECT RESPONSE/Response(...) e qualquer outra expressão
|
|
590
|
+
// referenciarem por esse nome em vez do nome generico "env"). Sem AS,
|
|
591
|
+
// mantém-se o comportamento original: o valor cai em env.NOME_FICHEIRO.
|
|
592
|
+
parseImport() {
|
|
593
|
+
this.expectKeyword("IMPORT");
|
|
594
|
+
this.expect(TokenType.SYMBOL, "{");
|
|
595
|
+
const path = this.parseFileName();
|
|
596
|
+
let index = null;
|
|
597
|
+
if (this.at(TokenType.SYMBOL, ",")) {
|
|
598
|
+
this.advance();
|
|
599
|
+
index = this.parseExpression();
|
|
600
|
+
}
|
|
601
|
+
this.expect(TokenType.SYMBOL, "}");
|
|
602
|
+
let alias = null;
|
|
603
|
+
if (this.atKeyword("AS")) {
|
|
604
|
+
this.advance();
|
|
605
|
+
alias = this.expect(TokenType.IDENT).value;
|
|
606
|
+
}
|
|
607
|
+
return { type: "Import", path, index, alias };
|
|
608
|
+
}
|
|
609
|
+
// ---- RUN BOT ----
|
|
610
|
+
parseRunBot() {
|
|
611
|
+
this.expectKeyword("RUN");
|
|
612
|
+
this.expectKeyword("BOT");
|
|
613
|
+
return { type: "RunBot" };
|
|
614
|
+
}
|
|
615
|
+
// ---- Expressões: literais, identificadores, chamadas, acesso a
|
|
616
|
+
// propriedade (obj.prop) e concatenação com "+" ----
|
|
617
|
+
parseExpression() {
|
|
618
|
+
let node = this.parsePrimary();
|
|
619
|
+
while (this.at(TokenType.SYMBOL, "+")) {
|
|
620
|
+
this.advance();
|
|
621
|
+
const right = this.parsePrimary();
|
|
622
|
+
node = { type: "Binary", op: "+", left: node, right };
|
|
623
|
+
}
|
|
624
|
+
return node;
|
|
625
|
+
}
|
|
626
|
+
// Igualdade, usada em WHERE (ex: id = LAST_INSERT_ID())
|
|
627
|
+
parseComparison() {
|
|
628
|
+
const left = this.parseExpression();
|
|
629
|
+
if (this.at(TokenType.SYMBOL, "=")) {
|
|
630
|
+
this.advance();
|
|
631
|
+
const right = this.parseExpression();
|
|
632
|
+
return { type: "Binary", op: "=", left, right };
|
|
633
|
+
}
|
|
634
|
+
return left;
|
|
635
|
+
}
|
|
636
|
+
parsePrimary() {
|
|
637
|
+
const tok = this.current();
|
|
638
|
+
if (tok.type === TokenType.STRING) {
|
|
639
|
+
this.advance();
|
|
640
|
+
return { type: "Literal", value: tok.value };
|
|
641
|
+
}
|
|
642
|
+
if (tok.type === TokenType.NUMBER) {
|
|
643
|
+
this.advance();
|
|
644
|
+
return { type: "Literal", value: tok.value };
|
|
645
|
+
}
|
|
646
|
+
if (tok.type === TokenType.IDENT) {
|
|
647
|
+
this.advance();
|
|
648
|
+
let node = { type: "Identifier", name: tok.value };
|
|
649
|
+
if (this.at(TokenType.SYMBOL, "(")) {
|
|
650
|
+
const args = this.parseExpressionList();
|
|
651
|
+
node = { type: "Call", callee: node.name, args };
|
|
652
|
+
}
|
|
653
|
+
while (this.at(TokenType.SYMBOL, ".")) {
|
|
654
|
+
this.advance();
|
|
655
|
+
const prop = this.expect(TokenType.IDENT).value;
|
|
656
|
+
node = { type: "Member", object: node, property: prop };
|
|
657
|
+
}
|
|
658
|
+
return node;
|
|
659
|
+
}
|
|
660
|
+
this.error("invalid expression");
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
module.exports = { Parser, Tokenizer, TokenType, KEYWORDS };
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// Database.js
|
|
668
|
+
var require_Database = __commonJS({
|
|
669
|
+
"Database.js"(exports, module) {
|
|
670
|
+
"use strict";
|
|
671
|
+
var CONTEXT_SCHEMA = [
|
|
672
|
+
{ name: "id", columnType: "INT", constraints: ["PRIMARY", "KEY", "AUTO_INCREMENT"] },
|
|
673
|
+
{ name: "client", columnType: "TEXT", constraints: [] },
|
|
674
|
+
{ name: "message", columnType: "TEXT", constraints: [] },
|
|
675
|
+
{ name: "reply", columnType: "TEXT", constraints: [] },
|
|
676
|
+
{ name: "created_at", columnType: "DATETIME", constraints: [] }
|
|
677
|
+
];
|
|
678
|
+
var MemoryDatabase = class {
|
|
679
|
+
constructor() {
|
|
680
|
+
this.tables = /* @__PURE__ */ new Map();
|
|
681
|
+
this.autoIncrement = /* @__PURE__ */ new Map();
|
|
682
|
+
}
|
|
683
|
+
createTable(name, columns, preventDefault) {
|
|
684
|
+
const resolvedColumns = name === "Context" && columns.length === 0 ? CONTEXT_SCHEMA : columns;
|
|
685
|
+
if (this.tables.has(name)) {
|
|
686
|
+
if (preventDefault) return;
|
|
687
|
+
throw new Error(`runtime error: table "${name}" already exists`);
|
|
688
|
+
}
|
|
689
|
+
this.tables.set(name, { columns: resolvedColumns, rows: [] });
|
|
690
|
+
this.autoIncrement.set(name, 0);
|
|
691
|
+
}
|
|
692
|
+
insert(table, columnNames, values) {
|
|
693
|
+
const t = this.tables.get(table);
|
|
694
|
+
if (!t) throw new Error(`runtime error: table "${table}" does not exist`);
|
|
695
|
+
const nextId = this.autoIncrement.get(table) + 1;
|
|
696
|
+
this.autoIncrement.set(table, nextId);
|
|
697
|
+
const row = { id: nextId };
|
|
698
|
+
columnNames.forEach((col, i) => {
|
|
699
|
+
row[col] = values[i];
|
|
700
|
+
});
|
|
701
|
+
t.rows.push(row);
|
|
702
|
+
return nextId;
|
|
703
|
+
}
|
|
704
|
+
update(table, column, value, whereColumn, whereValue) {
|
|
705
|
+
const t = this.tables.get(table);
|
|
706
|
+
if (!t) throw new Error(`runtime error: table "${table}" does not exist`);
|
|
707
|
+
const row = whereColumn ? t.rows.find((r) => r[whereColumn] === whereValue) : t.rows[t.rows.length - 1];
|
|
708
|
+
if (row) row[column] = value;
|
|
709
|
+
return row || null;
|
|
710
|
+
}
|
|
711
|
+
getRows(table) {
|
|
712
|
+
const t = this.tables.get(table);
|
|
713
|
+
return t ? t.rows.slice() : [];
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
function mapColumnType(columnType) {
|
|
717
|
+
const type = (columnType || "").toUpperCase();
|
|
718
|
+
if (type === "INT" || type === "INTEGER") return "INTEGER";
|
|
719
|
+
if (type === "DATETIME" || type === "DATE") return "TEXT";
|
|
720
|
+
return "TEXT";
|
|
721
|
+
}
|
|
722
|
+
function buildColumnDef(col) {
|
|
723
|
+
const type = mapColumnType(col.columnType);
|
|
724
|
+
const isPrimary = col.constraints.includes("PRIMARY") && col.constraints.includes("KEY");
|
|
725
|
+
const isAutoIncrement = col.constraints.includes("AUTO_INCREMENT");
|
|
726
|
+
let def = `${col.name} ${type}`;
|
|
727
|
+
if (isPrimary) def += " PRIMARY KEY";
|
|
728
|
+
if (isPrimary && isAutoIncrement) def += " AUTOINCREMENT";
|
|
729
|
+
return def;
|
|
730
|
+
}
|
|
731
|
+
var SQLiteDatabase = class {
|
|
732
|
+
/**
|
|
733
|
+
* @param {string} filePath Caminho do ficheiro .sqlite, ou ":memory:" para um banco temporário.
|
|
734
|
+
*/
|
|
735
|
+
constructor(filePath = ":memory:") {
|
|
736
|
+
const { DatabaseSync } = __require("node:sqlite");
|
|
737
|
+
this.driver = new DatabaseSync(filePath);
|
|
738
|
+
}
|
|
739
|
+
createTable(name, columns, preventDefault) {
|
|
740
|
+
const resolvedColumns = name === "Context" && columns.length === 0 ? CONTEXT_SCHEMA : columns;
|
|
741
|
+
const columnDefs = resolvedColumns.map(buildColumnDef).join(", ");
|
|
742
|
+
const ifNotExists = preventDefault ? "IF NOT EXISTS " : "";
|
|
743
|
+
this.driver.exec(`CREATE TABLE ${ifNotExists}${name} (${columnDefs})`);
|
|
744
|
+
}
|
|
745
|
+
insert(table, columnNames, values) {
|
|
746
|
+
const placeholders = columnNames.map(() => "?").join(", ");
|
|
747
|
+
const sql = `INSERT INTO ${table} (${columnNames.join(", ")}) VALUES (${placeholders})`;
|
|
748
|
+
const info = this.driver.prepare(sql).run(...values);
|
|
749
|
+
return Number(info.lastInsertRowid);
|
|
750
|
+
}
|
|
751
|
+
update(table, column, value, whereColumn, whereValue) {
|
|
752
|
+
if (whereColumn) {
|
|
753
|
+
const sql = `UPDATE ${table} SET ${column} = ? WHERE ${whereColumn} = ?`;
|
|
754
|
+
this.driver.prepare(sql).run(value, whereValue);
|
|
755
|
+
} else {
|
|
756
|
+
const sql = `UPDATE ${table} SET ${column} = ? WHERE id = (SELECT MAX(id) FROM ${table})`;
|
|
757
|
+
this.driver.prepare(sql).run(value);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
getRows(table) {
|
|
761
|
+
return this.driver.prepare(`SELECT * FROM ${table}`).all();
|
|
762
|
+
}
|
|
763
|
+
close() {
|
|
764
|
+
this.driver.close();
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
module.exports = { MemoryDatabase, SQLiteDatabase };
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
// FileSystem.js
|
|
772
|
+
var require_FileSystem = __commonJS({
|
|
773
|
+
"FileSystem.js"(exports, module) {
|
|
774
|
+
"use strict";
|
|
775
|
+
var MemoryFileSystem = class {
|
|
776
|
+
constructor(files = {}) {
|
|
777
|
+
this.files = new Map(
|
|
778
|
+
Object.entries(files).map(([k, v]) => [this._normalize(k), v])
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
_normalize(p) {
|
|
782
|
+
return String(p).replace(/^\.\//, "").replace(/\/+/g, "/").trim();
|
|
783
|
+
}
|
|
784
|
+
setFile(path, content) {
|
|
785
|
+
this.files.set(this._normalize(path), content);
|
|
786
|
+
}
|
|
787
|
+
exists(path) {
|
|
788
|
+
return this.files.has(this._normalize(path));
|
|
789
|
+
}
|
|
790
|
+
readFile(path) {
|
|
791
|
+
const key = this._normalize(path);
|
|
792
|
+
if (!this.files.has(key)) {
|
|
793
|
+
throw new Error(`BotQL: ficheiro n\xE3o encontrado: "${path}"`);
|
|
794
|
+
}
|
|
795
|
+
return this.files.get(key);
|
|
796
|
+
}
|
|
797
|
+
// basePath e' ignorado de proposito: no browser nao ha nocao real de
|
|
798
|
+
// diretorio corrente, os ficheiros importados sao identificados pelo
|
|
799
|
+
// nome/caminho tal como o utilizador os registou.
|
|
800
|
+
resolve(basePath, relativePath) {
|
|
801
|
+
return this._normalize(relativePath);
|
|
802
|
+
}
|
|
803
|
+
dirname(filePath) {
|
|
804
|
+
const norm = this._normalize(filePath);
|
|
805
|
+
const idx = norm.lastIndexOf("/");
|
|
806
|
+
return idx === -1 ? "" : norm.slice(0, idx);
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
var NodeFileSystem = class {
|
|
810
|
+
constructor() {
|
|
811
|
+
this._fs = __require("fs");
|
|
812
|
+
this._path = __require("path");
|
|
813
|
+
}
|
|
814
|
+
exists(path) {
|
|
815
|
+
return this._fs.existsSync(path);
|
|
816
|
+
}
|
|
817
|
+
readFile(path) {
|
|
818
|
+
return this._fs.readFileSync(path, "utf8");
|
|
819
|
+
}
|
|
820
|
+
resolve(basePath, relativePath) {
|
|
821
|
+
return this._path.resolve(basePath, relativePath);
|
|
822
|
+
}
|
|
823
|
+
dirname(filePath) {
|
|
824
|
+
return this._path.dirname(filePath);
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
var isNode = typeof process !== "undefined" && process.versions && !!process.versions.node;
|
|
828
|
+
function createDefaultFileSystem() {
|
|
829
|
+
return isNode ? new NodeFileSystem() : new MemoryFileSystem();
|
|
830
|
+
}
|
|
831
|
+
var api = { MemoryFileSystem, NodeFileSystem, createDefaultFileSystem };
|
|
832
|
+
if (typeof module !== "undefined" && module.exports) {
|
|
833
|
+
module.exports = api;
|
|
834
|
+
} else {
|
|
835
|
+
exports.BotQLFileSystem = api;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
// RAG.js
|
|
841
|
+
var require_RAG = __commonJS({
|
|
842
|
+
"RAG.js"(exports, module) {
|
|
843
|
+
"use strict";
|
|
844
|
+
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
845
|
+
"a",
|
|
846
|
+
"o",
|
|
847
|
+
"as",
|
|
848
|
+
"os",
|
|
849
|
+
"de",
|
|
850
|
+
"da",
|
|
851
|
+
"do",
|
|
852
|
+
"das",
|
|
853
|
+
"dos",
|
|
854
|
+
"e",
|
|
855
|
+
"ou",
|
|
856
|
+
"que",
|
|
857
|
+
"um",
|
|
858
|
+
"uma",
|
|
859
|
+
"uns",
|
|
860
|
+
"umas",
|
|
861
|
+
"em",
|
|
862
|
+
"no",
|
|
863
|
+
"na",
|
|
864
|
+
"nos",
|
|
865
|
+
"nas",
|
|
866
|
+
"por",
|
|
867
|
+
"para",
|
|
868
|
+
"com",
|
|
869
|
+
"sem",
|
|
870
|
+
"se",
|
|
871
|
+
"foi",
|
|
872
|
+
"ser",
|
|
873
|
+
"sao",
|
|
874
|
+
"esta",
|
|
875
|
+
"estao",
|
|
876
|
+
"ao",
|
|
877
|
+
"aos",
|
|
878
|
+
"mas",
|
|
879
|
+
"como",
|
|
880
|
+
"tem",
|
|
881
|
+
"ter",
|
|
882
|
+
"nao",
|
|
883
|
+
"sim",
|
|
884
|
+
"meu",
|
|
885
|
+
"minha",
|
|
886
|
+
"seu",
|
|
887
|
+
"sua",
|
|
888
|
+
"eu",
|
|
889
|
+
"tu",
|
|
890
|
+
"ele",
|
|
891
|
+
"ela",
|
|
892
|
+
"nos",
|
|
893
|
+
"vos",
|
|
894
|
+
"eles",
|
|
895
|
+
"elas",
|
|
896
|
+
"isso",
|
|
897
|
+
"isto",
|
|
898
|
+
"aquilo",
|
|
899
|
+
"quando",
|
|
900
|
+
"onde",
|
|
901
|
+
"porque",
|
|
902
|
+
"qual",
|
|
903
|
+
"quais",
|
|
904
|
+
"muito",
|
|
905
|
+
"muita",
|
|
906
|
+
"ja",
|
|
907
|
+
"so"
|
|
908
|
+
]);
|
|
909
|
+
function normalizar(texto) {
|
|
910
|
+
return texto.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
911
|
+
}
|
|
912
|
+
var SUFIXOS_ADJETIVO_ADVERBIO = ["issimamente", "issimo", "issima", "mente"];
|
|
913
|
+
var SUFIXOS_NOMINALIZACAO = ["acoes", "acao", "imentos", "imento", "idades", "idade"];
|
|
914
|
+
function stem(palavra) {
|
|
915
|
+
let p = palavra;
|
|
916
|
+
for (const suf of SUFIXOS_ADJETIVO_ADVERBIO) {
|
|
917
|
+
if (p.length > suf.length + 3 && p.endsWith(suf)) {
|
|
918
|
+
p = p.slice(0, -suf.length);
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
for (const suf of SUFIXOS_NOMINALIZACAO) {
|
|
923
|
+
if (p.length > suf.length + 3 && p.endsWith(suf)) {
|
|
924
|
+
p = p.slice(0, -suf.length);
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if (p.length > 4 && p.endsWith("s") && !p.endsWith("ns")) {
|
|
929
|
+
p = p.slice(0, -1);
|
|
930
|
+
}
|
|
931
|
+
return p;
|
|
932
|
+
}
|
|
933
|
+
function tokenizar(texto) {
|
|
934
|
+
return normalizar(texto).toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((palavra) => palavra.length > 1 && !STOPWORDS.has(palavra)).map(stem);
|
|
935
|
+
}
|
|
936
|
+
function distanciaEdicao(a, b) {
|
|
937
|
+
if (a === b) return 0;
|
|
938
|
+
const la = a.length;
|
|
939
|
+
const lb = b.length;
|
|
940
|
+
if (la === 0) return lb;
|
|
941
|
+
if (lb === 0) return la;
|
|
942
|
+
let linhaAnterior = new Array(lb + 1);
|
|
943
|
+
for (let j = 0; j <= lb; j++) linhaAnterior[j] = j;
|
|
944
|
+
for (let i = 1; i <= la; i++) {
|
|
945
|
+
const linhaAtual = [i];
|
|
946
|
+
for (let j = 1; j <= lb; j++) {
|
|
947
|
+
const custo = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
948
|
+
linhaAtual[j] = Math.min(
|
|
949
|
+
linhaAtual[j - 1] + 1,
|
|
950
|
+
linhaAnterior[j] + 1,
|
|
951
|
+
linhaAnterior[j - 1] + custo
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
linhaAnterior = linhaAtual;
|
|
955
|
+
}
|
|
956
|
+
return linhaAnterior[lb];
|
|
957
|
+
}
|
|
958
|
+
function distanciaMaximaTolerada(tamanho) {
|
|
959
|
+
if (tamanho <= 4) return 0;
|
|
960
|
+
if (tamanho <= 7) return 1;
|
|
961
|
+
return 2;
|
|
962
|
+
}
|
|
963
|
+
var BM25_K1 = 1.5;
|
|
964
|
+
var BM25_B = 0.75;
|
|
965
|
+
var PESO_BM25 = 1;
|
|
966
|
+
var PESO_FRASE = 2.5;
|
|
967
|
+
var PESO_FUZZY = 0.4;
|
|
968
|
+
var CONFIANCA_ALTA = 0.55;
|
|
969
|
+
var CONFIANCA_MINIMA = 0.15;
|
|
970
|
+
var MARGEM_MINIMA = 0.12;
|
|
971
|
+
var SIMILARIDADE_DUPLICADA = 0.75;
|
|
972
|
+
function extrairNomesProprios(texto) {
|
|
973
|
+
const palavras = texto.split(/\s+/);
|
|
974
|
+
const encontrados = [];
|
|
975
|
+
for (let i = 1; i < palavras.length; i++) {
|
|
976
|
+
const limpa = palavras[i].replace(/^[(["']+|[.,;:!?)\]"']+$/g, "");
|
|
977
|
+
if (/^[A-ZÀ-Ý][a-zà-ÿ]+$/.test(limpa)) {
|
|
978
|
+
encontrados.push(limpa);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return encontrados;
|
|
982
|
+
}
|
|
983
|
+
var KnowledgeIndex = class _KnowledgeIndex {
|
|
984
|
+
/**
|
|
985
|
+
* @param {string} sourceText Conteúdo do ficheiro de conhecimento.
|
|
986
|
+
* @param {Object} [options]
|
|
987
|
+
* @param {Record<string,string>} [options.synonyms] Mapa de termo -> termo
|
|
988
|
+
* canónico, para ligar manualmente palavras que o stemmer não junta
|
|
989
|
+
* sozinho (ex: { horas: 'horario' }).
|
|
990
|
+
*/
|
|
991
|
+
constructor(sourceText, options = {}) {
|
|
992
|
+
this.synonyms = {};
|
|
993
|
+
for (const [de, para] of Object.entries(options.synonyms || {})) {
|
|
994
|
+
this.synonyms[stem(normalizar(de.toLowerCase()))] = stem(normalizar(para.toLowerCase()));
|
|
995
|
+
}
|
|
996
|
+
this.blocks = _KnowledgeIndex.parseBlocks(sourceText);
|
|
997
|
+
this._buildIndex();
|
|
998
|
+
}
|
|
999
|
+
static parseBlocks(sourceText) {
|
|
1000
|
+
return sourceText.split(/\n\s*\n/).map((bloco) => bloco.trim()).filter((bloco) => bloco.length > 0);
|
|
1001
|
+
}
|
|
1002
|
+
_aplicarSinonimos(tokens) {
|
|
1003
|
+
return tokens.map((t) => this.synonyms[t] || t);
|
|
1004
|
+
}
|
|
1005
|
+
_buildIndex() {
|
|
1006
|
+
this.docs = this.blocks.map((bloco) => tokenizar(bloco));
|
|
1007
|
+
this.docTextNormalizado = this.blocks.map((bloco) => normalizar(bloco).toLowerCase());
|
|
1008
|
+
this.docFreq = /* @__PURE__ */ new Map();
|
|
1009
|
+
this.vocabulario = /* @__PURE__ */ new Set();
|
|
1010
|
+
for (const tokens of this.docs) {
|
|
1011
|
+
const vistas = new Set(tokens);
|
|
1012
|
+
for (const palavra of vistas) {
|
|
1013
|
+
this.docFreq.set(palavra, (this.docFreq.get(palavra) || 0) + 1);
|
|
1014
|
+
this.vocabulario.add(palavra);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
this.listaVocabulario = Array.from(this.vocabulario);
|
|
1018
|
+
this.totalDocs = this.docs.length;
|
|
1019
|
+
this.avgDocLen = this.totalDocs === 0 ? 0 : this.docs.reduce((soma, tokens) => soma + tokens.length, 0) / this.totalDocs;
|
|
1020
|
+
}
|
|
1021
|
+
_idf(palavra) {
|
|
1022
|
+
const n = this.docFreq.get(palavra) || 0;
|
|
1023
|
+
if (n === 0) return 0;
|
|
1024
|
+
return Math.log(1 + (this.totalDocs - n + 0.5) / (n + 0.5));
|
|
1025
|
+
}
|
|
1026
|
+
_termoMaisProximo(termo) {
|
|
1027
|
+
const tolerancia = distanciaMaximaTolerada(termo.length);
|
|
1028
|
+
if (tolerancia === 0) return null;
|
|
1029
|
+
let melhor = null;
|
|
1030
|
+
let melhorDist = tolerancia + 1;
|
|
1031
|
+
for (const candidato of this.listaVocabulario) {
|
|
1032
|
+
if (Math.abs(candidato.length - termo.length) > tolerancia) continue;
|
|
1033
|
+
const d = distanciaEdicao(termo, candidato);
|
|
1034
|
+
if (d < melhorDist) {
|
|
1035
|
+
melhorDist = d;
|
|
1036
|
+
melhor = candidato;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return melhorDist <= tolerancia ? melhor : null;
|
|
1040
|
+
}
|
|
1041
|
+
_scoreBM25(queryTokens, docIndex) {
|
|
1042
|
+
const tokens = this.docs[docIndex];
|
|
1043
|
+
if (tokens.length === 0) return 0;
|
|
1044
|
+
const termFreq = /* @__PURE__ */ new Map();
|
|
1045
|
+
for (const t of tokens) termFreq.set(t, (termFreq.get(t) || 0) + 1);
|
|
1046
|
+
let score = 0;
|
|
1047
|
+
for (const termo of queryTokens) {
|
|
1048
|
+
let tf = termFreq.get(termo) || 0;
|
|
1049
|
+
let idf = this._idf(termo);
|
|
1050
|
+
let peso = 1;
|
|
1051
|
+
if (tf === 0) {
|
|
1052
|
+
const proximo = this._termoMaisProximo(termo);
|
|
1053
|
+
if (proximo === null) continue;
|
|
1054
|
+
tf = termFreq.get(proximo) || 0;
|
|
1055
|
+
if (tf === 0) continue;
|
|
1056
|
+
idf = this._idf(proximo);
|
|
1057
|
+
peso = PESO_FUZZY;
|
|
1058
|
+
}
|
|
1059
|
+
const numerador = tf * (BM25_K1 + 1);
|
|
1060
|
+
const denominador = tf + BM25_K1 * (1 - BM25_B + BM25_B * (tokens.length / this.avgDocLen));
|
|
1061
|
+
score += peso * idf * (numerador / denominador);
|
|
1062
|
+
}
|
|
1063
|
+
return score;
|
|
1064
|
+
}
|
|
1065
|
+
_bonusFrase(message, docIndex) {
|
|
1066
|
+
const msgNorm = normalizar(message).toLowerCase().replace(/[^a-z0-9\s]/g, " ");
|
|
1067
|
+
const palavras = msgNorm.split(/\s+/).filter((p) => p.length > 1);
|
|
1068
|
+
if (palavras.length < 2) return 0;
|
|
1069
|
+
const textoBloco = this.docTextNormalizado[docIndex];
|
|
1070
|
+
let bonus = 0;
|
|
1071
|
+
for (let tamanho = Math.min(6, palavras.length); tamanho >= 2; tamanho--) {
|
|
1072
|
+
for (let i = 0; i + tamanho <= palavras.length; i++) {
|
|
1073
|
+
const frase = palavras.slice(i, i + tamanho).join(" ");
|
|
1074
|
+
if (textoBloco.includes(frase)) {
|
|
1075
|
+
bonus += tamanho * tamanho;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return bonus;
|
|
1080
|
+
}
|
|
1081
|
+
_scoreDoc(message, queryTokens, docIndex) {
|
|
1082
|
+
const bm25 = this._scoreBM25(queryTokens, docIndex);
|
|
1083
|
+
const frase = this._bonusFrase(message, docIndex);
|
|
1084
|
+
return PESO_BM25 * bm25 + PESO_FRASE * frase;
|
|
1085
|
+
}
|
|
1086
|
+
// Calcula o score de todos os blocos para a mensagem e devolve ordenado
|
|
1087
|
+
// do maior para o menor. Base partilhada por search() e analyze().
|
|
1088
|
+
_rankTodos(message) {
|
|
1089
|
+
const queryTokens = this._aplicarSinonimos(tokenizar(message));
|
|
1090
|
+
if (queryTokens.length === 0) return [];
|
|
1091
|
+
const resultados = [];
|
|
1092
|
+
for (let i = 0; i < this.totalDocs; i++) {
|
|
1093
|
+
resultados.push({ index: i, score: this._scoreDoc(message, queryTokens, i) });
|
|
1094
|
+
}
|
|
1095
|
+
resultados.sort((a, b) => b.score - a.score);
|
|
1096
|
+
return resultados;
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Procura o bloco mais relevante para a mensagem recebida.
|
|
1100
|
+
*
|
|
1101
|
+
* @returns {{text: string, score: number, confidence: number, index: number} | null}
|
|
1102
|
+
* `confidence` está sempre entre 0 e 1 — não é probabilidade
|
|
1103
|
+
* estatística real, é uma escala interpretável para decidir um
|
|
1104
|
+
* limiar no `.sql` (ex: "só responde se confidence > 0.3").
|
|
1105
|
+
*/
|
|
1106
|
+
search(message) {
|
|
1107
|
+
if (this.totalDocs === 0) return null;
|
|
1108
|
+
const ranking = this._rankTodos(message);
|
|
1109
|
+
if (ranking.length === 0) return null;
|
|
1110
|
+
const melhor = ranking[0];
|
|
1111
|
+
if (melhor.score <= 0) return null;
|
|
1112
|
+
return {
|
|
1113
|
+
text: this.blocks[melhor.index],
|
|
1114
|
+
score: melhor.score,
|
|
1115
|
+
confidence: melhor.score / (melhor.score + 3),
|
|
1116
|
+
index: melhor.index
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
// Jaccard sobre os tokens (já com stem aplicado) de dois blocos já
|
|
1120
|
+
// indexados. Usado só pra detetar "mesmo conteúdo repetido" (valor
|
|
1121
|
+
// alto) — dois blocos sobre o mesmo assunto mas com detalhes
|
|
1122
|
+
// diferentes normalmente NÃO têm jaccard alto (a maior parte da frase
|
|
1123
|
+
// difere), por isso não serve pra decidir se vale a pena tentar unir.
|
|
1124
|
+
_similaridadeBlocos(indexA, indexB) {
|
|
1125
|
+
const a = new Set(this.docs[indexA]);
|
|
1126
|
+
const b = new Set(this.docs[indexB]);
|
|
1127
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
1128
|
+
let intersecao = 0;
|
|
1129
|
+
for (const t of a) if (b.has(t)) intersecao++;
|
|
1130
|
+
const uniao = a.size + b.size - intersecao;
|
|
1131
|
+
return uniao === 0 ? 0 : intersecao / uniao;
|
|
1132
|
+
}
|
|
1133
|
+
// Quantos tokens (com stem) os dois blocos partilham, em termos
|
|
1134
|
+
// absolutos. Usado como gatilho pra tentar unir: basta partilharem UM
|
|
1135
|
+
// termo de assunto ("entregamos") — quem garante que a fusão é segura
|
|
1136
|
+
// não é isto, é a regra de "exatamente um nome próprio diferente em
|
|
1137
|
+
// cada bloco" dentro de _tentarUnir.
|
|
1138
|
+
_termosPartilhados(indexA, indexB) {
|
|
1139
|
+
const a = new Set(this.docs[indexA]);
|
|
1140
|
+
const b = this.docs[indexB];
|
|
1141
|
+
let count = 0;
|
|
1142
|
+
for (const t of b) if (a.has(t)) count++;
|
|
1143
|
+
return count;
|
|
1144
|
+
}
|
|
1145
|
+
// Compara dois blocos concorrentes pelo nome próprio que cada um
|
|
1146
|
+
// menciona, pra decidir se são "a mesma informação" ou "informação
|
|
1147
|
+
// complementar que dá pra unir":
|
|
1148
|
+
// - mesmo nome próprio nos dois (ex: os dois falam de Luanda)
|
|
1149
|
+
// -> { tipo: 'duplicado' }: é a mesma coisa dita de formas
|
|
1150
|
+
// diferentes, não há o que unir, usa qualquer um dos dois
|
|
1151
|
+
// - nomes próprios diferentes (ex: Luanda vs Huambo)
|
|
1152
|
+
// -> { tipo: 'unido', texto: '...' }: funde numa frase só
|
|
1153
|
+
// - não dá pra identificar com segurança (nenhum nome próprio, ou
|
|
1154
|
+
// mais de um em algum dos blocos) -> null: quem chama decide o
|
|
1155
|
+
// que fazer a seguir (normalmente: reanalisar, ou cair no fallback)
|
|
1156
|
+
_tentarUnir(textoA, textoB) {
|
|
1157
|
+
const entidadesA = extrairNomesProprios(textoA);
|
|
1158
|
+
const entidadesB = extrairNomesProprios(textoB);
|
|
1159
|
+
if (entidadesA.length !== 1 || entidadesB.length !== 1) return null;
|
|
1160
|
+
const entidadeA = entidadesA[0];
|
|
1161
|
+
const entidadeB = entidadesB[0];
|
|
1162
|
+
if (normalizar(entidadeA).toLowerCase() === normalizar(entidadeB).toLowerCase()) {
|
|
1163
|
+
return { tipo: "duplicado" };
|
|
1164
|
+
}
|
|
1165
|
+
const primeiraPalavra = textoA.trim().split(/\s+/)[0].replace(/[.,;:!?]+$/, "");
|
|
1166
|
+
return { tipo: "unido", texto: `${primeiraPalavra} em v\xE1rios lugares, como ${entidadeA} e ${entidadeB}.` };
|
|
1167
|
+
}
|
|
1168
|
+
// Segunda tentativa de ranking, usada só quando a primeira ficou
|
|
1169
|
+
// ambígua: descarta metade dos termos da query (os de menor IDF, ou
|
|
1170
|
+
// seja, os mais genéricos/comuns) e refaz o ranking só com os termos
|
|
1171
|
+
// mais raros/decisivos. Uma query mais focada às vezes desempata o
|
|
1172
|
+
// que uma query "cheia" deixa embolado.
|
|
1173
|
+
_reanalisarFocado(message) {
|
|
1174
|
+
const tokens = this._aplicarSinonimos(tokenizar(message));
|
|
1175
|
+
if (tokens.length <= 2) return null;
|
|
1176
|
+
const comIdf = tokens.map((t) => ({ termo: t, idf: this._idf(t) }));
|
|
1177
|
+
comIdf.sort((a, b) => b.idf - a.idf);
|
|
1178
|
+
const focados = comIdf.slice(0, Math.max(1, Math.ceil(tokens.length / 2))).map((x) => x.termo);
|
|
1179
|
+
const resultados = [];
|
|
1180
|
+
for (let i = 0; i < this.totalDocs; i++) {
|
|
1181
|
+
resultados.push({ index: i, score: this._scoreDoc(message, focados, i) });
|
|
1182
|
+
}
|
|
1183
|
+
resultados.sort((a, b) => b.score - a.score);
|
|
1184
|
+
return resultados;
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Versão completa da busca: além do melhor bloco, avalia a evidência
|
|
1188
|
+
* (melhor x segundo colocado) e devolve uma decisão explícita, em vez
|
|
1189
|
+
* de deixar o `.sql` decidir tudo com um único limiar de confidence.
|
|
1190
|
+
*
|
|
1191
|
+
* EvidenceEvaluator: compara o melhor resultado com o segundo. Se os
|
|
1192
|
+
* dois estão muito próximos, o motor não tem certeza de qual bloco
|
|
1193
|
+
* responde à pergunta — mesmo que o score absoluto seja alto.
|
|
1194
|
+
*
|
|
1195
|
+
* ConfidenceEngine: mesma fórmula de sempre (score / (score + 3)),
|
|
1196
|
+
* aplicada só ao melhor resultado.
|
|
1197
|
+
*
|
|
1198
|
+
* DecisionEngine: cruza confidence com margem para decidir entre:
|
|
1199
|
+
* - RESPONDER confidence alta e o melhor bloco se destaca do 2o,
|
|
1200
|
+
* OU os dois concorrentes foram reconciliados (ver
|
|
1201
|
+
* abaixo) — nesse caso `texto` já vem pronto pra usar
|
|
1202
|
+
* - REANALISAR os blocos concorrentes são sobre assuntos diferentes
|
|
1203
|
+
* demais pra reconciliar, e nem a retentativa focada
|
|
1204
|
+
* resolveu — o `.sql` decide o que fazer (normalmente
|
|
1205
|
+
* cair no fallback do OR REPLY)
|
|
1206
|
+
* - UNKNOWN confidence baixa demais, não há bloco que sirva
|
|
1207
|
+
*
|
|
1208
|
+
* Reconciliação (só entra quando o resultado não é decisivo de cara):
|
|
1209
|
+
* 1. Se o melhor e o segundo colocado são basicamente o mesmo
|
|
1210
|
+
* conteúdo (alta similaridade) — não há nada pra unir, usa o
|
|
1211
|
+
* melhor tal como está.
|
|
1212
|
+
* 2. Se são blocos diferentes mas do mesmo assunto, e cada um tem
|
|
1213
|
+
* exatamente um nome próprio diferente (ex: "entregamos em
|
|
1214
|
+
* Luanda" / "entregamos no Huambo") — tenta fundir numa frase só
|
|
1215
|
+
* ("entregamos em vários lugares, como Luanda e Huambo").
|
|
1216
|
+
* 3. Se nada disso se aplica, tenta de novo com uma versão mais
|
|
1217
|
+
* enxuta da pergunta (só os termos mais decisivos) antes de
|
|
1218
|
+
* desistir.
|
|
1219
|
+
*
|
|
1220
|
+
* @returns {{
|
|
1221
|
+
* decision: 'RESPONDER'|'REANALISAR'|'UNKNOWN',
|
|
1222
|
+
* confidence: number,
|
|
1223
|
+
* margem: number,
|
|
1224
|
+
* texto: string | null,
|
|
1225
|
+
* unificado: boolean,
|
|
1226
|
+
* reanalisado: boolean,
|
|
1227
|
+
* melhor: {text: string, score: number, index: number} | null,
|
|
1228
|
+
* segundo: {text: string, score: number, index: number} | null
|
|
1229
|
+
* }}
|
|
1230
|
+
*/
|
|
1231
|
+
analyze(message) {
|
|
1232
|
+
const vazio = { decision: "UNKNOWN", confidence: 0, margem: 0, texto: null, unificado: false, reanalisado: false, melhor: null, segundo: null };
|
|
1233
|
+
if (this.totalDocs === 0) return vazio;
|
|
1234
|
+
const ranking = this._rankTodos(message);
|
|
1235
|
+
if (ranking.length === 0 || ranking[0].score <= 0) return vazio;
|
|
1236
|
+
const melhor = ranking[0];
|
|
1237
|
+
const segundo = ranking[1] || { index: -1, score: 0 };
|
|
1238
|
+
const margem = (melhor.score - segundo.score) / melhor.score;
|
|
1239
|
+
const confidence = melhor.score / (melhor.score + 3);
|
|
1240
|
+
const melhorInfo = { text: this.blocks[melhor.index], score: melhor.score, index: melhor.index };
|
|
1241
|
+
const segundoInfo = segundo.index >= 0 ? { text: this.blocks[segundo.index], score: segundo.score, index: segundo.index } : null;
|
|
1242
|
+
if (confidence < CONFIANCA_MINIMA) return vazio;
|
|
1243
|
+
if (confidence >= CONFIANCA_ALTA && margem >= MARGEM_MINIMA) {
|
|
1244
|
+
return {
|
|
1245
|
+
decision: "RESPONDER",
|
|
1246
|
+
confidence,
|
|
1247
|
+
margem,
|
|
1248
|
+
texto: melhorInfo.text,
|
|
1249
|
+
unificado: false,
|
|
1250
|
+
reanalisado: false,
|
|
1251
|
+
melhor: melhorInfo,
|
|
1252
|
+
segundo: segundoInfo
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
if (segundoInfo) {
|
|
1256
|
+
const similaridade = this._similaridadeBlocos(melhor.index, segundo.index);
|
|
1257
|
+
if (similaridade >= SIMILARIDADE_DUPLICADA) {
|
|
1258
|
+
return {
|
|
1259
|
+
decision: "RESPONDER",
|
|
1260
|
+
confidence,
|
|
1261
|
+
margem,
|
|
1262
|
+
texto: melhorInfo.text,
|
|
1263
|
+
unificado: false,
|
|
1264
|
+
reanalisado: false,
|
|
1265
|
+
melhor: melhorInfo,
|
|
1266
|
+
segundo: segundoInfo
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
const termosPartilhados = this._termosPartilhados(melhor.index, segundo.index);
|
|
1270
|
+
if (termosPartilhados >= 1) {
|
|
1271
|
+
const resultado = this._tentarUnir(melhorInfo.text, segundoInfo.text);
|
|
1272
|
+
if (resultado && resultado.tipo === "duplicado") {
|
|
1273
|
+
return {
|
|
1274
|
+
decision: "RESPONDER",
|
|
1275
|
+
confidence,
|
|
1276
|
+
margem,
|
|
1277
|
+
texto: melhorInfo.text,
|
|
1278
|
+
unificado: false,
|
|
1279
|
+
reanalisado: false,
|
|
1280
|
+
melhor: melhorInfo,
|
|
1281
|
+
segundo: segundoInfo
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
if (resultado && resultado.tipo === "unido") {
|
|
1285
|
+
return {
|
|
1286
|
+
decision: "RESPONDER",
|
|
1287
|
+
confidence,
|
|
1288
|
+
margem,
|
|
1289
|
+
texto: resultado.texto,
|
|
1290
|
+
unificado: true,
|
|
1291
|
+
reanalisado: false,
|
|
1292
|
+
melhor: melhorInfo,
|
|
1293
|
+
segundo: segundoInfo
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
const tentativa2 = this._reanalisarFocado(message);
|
|
1299
|
+
if (tentativa2 && tentativa2.length > 0 && tentativa2[0].score > 0) {
|
|
1300
|
+
const melhor2 = tentativa2[0];
|
|
1301
|
+
const segundo2 = tentativa2[1] || { index: -1, score: 0 };
|
|
1302
|
+
const margem2 = (melhor2.score - segundo2.score) / melhor2.score;
|
|
1303
|
+
const confidence2 = melhor2.score / (melhor2.score + 3);
|
|
1304
|
+
if (confidence2 >= CONFIANCA_ALTA && margem2 >= MARGEM_MINIMA) {
|
|
1305
|
+
return {
|
|
1306
|
+
decision: "RESPONDER",
|
|
1307
|
+
confidence: confidence2,
|
|
1308
|
+
margem: margem2,
|
|
1309
|
+
texto: this.blocks[melhor2.index],
|
|
1310
|
+
unificado: false,
|
|
1311
|
+
reanalisado: true,
|
|
1312
|
+
melhor: { text: this.blocks[melhor2.index], score: melhor2.score, index: melhor2.index },
|
|
1313
|
+
segundo: segundo2.index >= 0 ? { text: this.blocks[segundo2.index], score: segundo2.score, index: segundo2.index } : null
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return {
|
|
1318
|
+
decision: "REANALISAR",
|
|
1319
|
+
confidence,
|
|
1320
|
+
margem,
|
|
1321
|
+
texto: null,
|
|
1322
|
+
unificado: false,
|
|
1323
|
+
reanalisado: false,
|
|
1324
|
+
melhor: melhorInfo,
|
|
1325
|
+
segundo: segundoInfo
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
};
|
|
1329
|
+
var KnowledgeCache = class {
|
|
1330
|
+
constructor(fileSystem) {
|
|
1331
|
+
this.fileSystem = fileSystem;
|
|
1332
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
1333
|
+
}
|
|
1334
|
+
get(resolvedPath, options) {
|
|
1335
|
+
if (this.cache.has(resolvedPath)) return this.cache.get(resolvedPath);
|
|
1336
|
+
if (!this.fileSystem.exists(resolvedPath)) {
|
|
1337
|
+
throw new Error(`BotQL/THINK: ficheiro de conhecimento n\xE3o encontrado: "${resolvedPath}"`);
|
|
1338
|
+
}
|
|
1339
|
+
const texto = this.fileSystem.readFile(resolvedPath);
|
|
1340
|
+
const index = new KnowledgeIndex(texto, options);
|
|
1341
|
+
this.cache.set(resolvedPath, index);
|
|
1342
|
+
return index;
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
module.exports = { KnowledgeIndex, KnowledgeCache, tokenizar, normalizar, stem, distanciaEdicao };
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
// botql.js
|
|
1350
|
+
var require_botql = __commonJS({
|
|
1351
|
+
"botql.js"(exports, module) {
|
|
1352
|
+
var { Parser } = require_Parser();
|
|
1353
|
+
var { MemoryDatabase } = require_Database();
|
|
1354
|
+
var { createDefaultFileSystem } = require_FileSystem();
|
|
1355
|
+
var { KnowledgeCache } = require_RAG();
|
|
1356
|
+
var WAITING_TEXTO_PADRAO = "Pensando...";
|
|
1357
|
+
var WAITING_SEGUNDOS_PADRAO = 3;
|
|
1358
|
+
var BotQLInterpreter = class _BotQLInterpreter {
|
|
1359
|
+
constructor(options = {}) {
|
|
1360
|
+
this.db = options.db || new MemoryDatabase();
|
|
1361
|
+
this.onReply = options.onReply || null;
|
|
1362
|
+
this.onForward = options.onForward || null;
|
|
1363
|
+
this.onSend = options.onSend || null;
|
|
1364
|
+
this.signalParser = options.signalParser || ((raw) => raw);
|
|
1365
|
+
this.onThinking = options.onThinking || null;
|
|
1366
|
+
this.botName = null;
|
|
1367
|
+
this.platform = null;
|
|
1368
|
+
this.connections = [];
|
|
1369
|
+
this.handlers = { START: [], MESSAGE: [], SIGNAL: [] };
|
|
1370
|
+
this.running = false;
|
|
1371
|
+
this.responseConnections = /* @__PURE__ */ new Set();
|
|
1372
|
+
this.onResponse = options.onResponse || null;
|
|
1373
|
+
this.defaultMessageConfig = null;
|
|
1374
|
+
this.nativeFuncs = /* @__PURE__ */ new Map();
|
|
1375
|
+
this.registerFunction("NOW", () => (/* @__PURE__ */ new Date()).toISOString());
|
|
1376
|
+
this.fileSystem = options.fileSystem || createDefaultFileSystem();
|
|
1377
|
+
const defaultBasePath = typeof process !== "undefined" && process.cwd ? process.cwd() : "";
|
|
1378
|
+
this._basePathStack = [options.basePath || defaultBasePath];
|
|
1379
|
+
this._importedFiles = /* @__PURE__ */ new Set();
|
|
1380
|
+
this._rootBasePath = options.basePath || defaultBasePath;
|
|
1381
|
+
this._keywordsFileCache = /* @__PURE__ */ new Map();
|
|
1382
|
+
this._replyFileCache = /* @__PURE__ */ new Map();
|
|
1383
|
+
this.envValues = /* @__PURE__ */ new Map();
|
|
1384
|
+
this.knowledgeCache = new KnowledgeCache(this.fileSystem);
|
|
1385
|
+
}
|
|
1386
|
+
static fromSource(source, options = {}) {
|
|
1387
|
+
const ast = new Parser(source).parseProgram();
|
|
1388
|
+
const interpreter = new _BotQLInterpreter(options);
|
|
1389
|
+
interpreter.load(ast);
|
|
1390
|
+
return interpreter;
|
|
1391
|
+
}
|
|
1392
|
+
// Lê um ficheiro .sql do disco (ou de outro fileSystem injetado) e usa
|
|
1393
|
+
// a sua pasta como base para resolver IMPORTs relativos dentro dele.
|
|
1394
|
+
static fromFile(filePath, options = {}) {
|
|
1395
|
+
const fileSystem = options.fileSystem || createDefaultFileSystem();
|
|
1396
|
+
const resolved = fileSystem.resolve("", filePath);
|
|
1397
|
+
const source = fileSystem.readFile(resolved);
|
|
1398
|
+
return _BotQLInterpreter.fromSource(source, {
|
|
1399
|
+
...options,
|
|
1400
|
+
fileSystem,
|
|
1401
|
+
basePath: fileSystem.dirname(resolved)
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
registerFunction(name, fn) {
|
|
1405
|
+
this.nativeFuncs.set(name, fn);
|
|
1406
|
+
}
|
|
1407
|
+
// ---- Carregamento da AST: statements de topo, fora de eventos ----
|
|
1408
|
+
//
|
|
1409
|
+
// IMPORT é sempre resolvido antes de qualquer outro comando, independente
|
|
1410
|
+
// de onde aparece no ficheiro (tal como o SOURCE do MySQL ou o \i do
|
|
1411
|
+
// psql) — por isso o corpo é primeiro "achatado": todo o conteúdo vindo
|
|
1412
|
+
// de ficheiros importados entra ANTES dos statements do próprio ficheiro,
|
|
1413
|
+
// recursivamente, mesmo que o IMPORT apareça no meio ou no fim do texto.
|
|
1414
|
+
load(ast, basePath = this._basePathStack[this._basePathStack.length - 1]) {
|
|
1415
|
+
const flatBody = this._flattenImports(ast.body, basePath);
|
|
1416
|
+
for (const node of flatBody) {
|
|
1417
|
+
switch (node.type) {
|
|
1418
|
+
case "CreateBot":
|
|
1419
|
+
this.botName = node.name;
|
|
1420
|
+
break;
|
|
1421
|
+
case "Platform":
|
|
1422
|
+
this.platform = node.value;
|
|
1423
|
+
break;
|
|
1424
|
+
case "Connect":
|
|
1425
|
+
this.connections.push({
|
|
1426
|
+
service: node.service,
|
|
1427
|
+
kind: node.kind,
|
|
1428
|
+
credential: node.credential
|
|
1429
|
+
});
|
|
1430
|
+
break;
|
|
1431
|
+
case "ConnectResponse":
|
|
1432
|
+
if (!this.envValues.has(node.alias)) {
|
|
1433
|
+
throw new Error(`runtime error: CONNECT RESPONSE failed, alias "${node.alias}" was not imported (use IMPORT {..., N} AS ${node.alias})`);
|
|
1434
|
+
}
|
|
1435
|
+
this.responseConnections.add(node.alias);
|
|
1436
|
+
break;
|
|
1437
|
+
case "CreateTable":
|
|
1438
|
+
this.db.createTable(node.name, node.columns, node.preventDefault);
|
|
1439
|
+
if (node.defaultMessage) {
|
|
1440
|
+
const senderColumn = node.name === "Context" ? "client" : (node.columns.find((c) => c.name === "client" || c.name === "sender") || {}).name;
|
|
1441
|
+
if (!senderColumn) {
|
|
1442
|
+
throw new Error(`runtime error: DEFAULT MESSAGE on table "${node.name}" requires a "client" or "sender" column`);
|
|
1443
|
+
}
|
|
1444
|
+
this.defaultMessageConfig = { table: node.name, senderColumn, ...node.defaultMessage };
|
|
1445
|
+
}
|
|
1446
|
+
break;
|
|
1447
|
+
case "On":
|
|
1448
|
+
this.handlers[node.event].push(node);
|
|
1449
|
+
break;
|
|
1450
|
+
case "RunBot":
|
|
1451
|
+
this.running = true;
|
|
1452
|
+
break;
|
|
1453
|
+
default:
|
|
1454
|
+
throw new Error(`runtime error: unsupported top-level statement: ${node.type}`);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
// Devolve um array de statements sem nenhum nó 'Import': o conteúdo de
|
|
1459
|
+
// cada ficheiro importado é lido, parseado e achatado recursivamente
|
|
1460
|
+
// primeiro (imports dentro de imports também respeitam a regra), e só
|
|
1461
|
+
// depois vêm os statements que pertencem a este próprio ficheiro.
|
|
1462
|
+
//
|
|
1463
|
+
// IMPORT {ficheiro.sql} (sem índice) — comportamento original: lê o
|
|
1464
|
+
// ficheiro inteiro como código BotQL e junta ao programa.
|
|
1465
|
+
//
|
|
1466
|
+
// IMPORT {ficheiro.txt, N} (com índice) — não é código: lê só a
|
|
1467
|
+
// entrada N do ficheiro (mesmo formato "N- valor" do REPLY indexado)
|
|
1468
|
+
// e guarda em this.envValues. Com AS alias, a chave é o alias (fica
|
|
1469
|
+
// acessível diretamente pelo nome, ex: K, CONNECT RESPONSE K); sem
|
|
1470
|
+
// AS, mantém-se o comportamento original — chave é o nome do
|
|
1471
|
+
// ficheiro sem extensão, só acessível via env.NOME_FICHEIRO. Um
|
|
1472
|
+
// ficheiro sem AS não pode ser usado por CONNECT RESPONSE (exige
|
|
1473
|
+
// alias explícito, para não colidir com outros IMPORTs do mesmo
|
|
1474
|
+
// ficheiro em índices diferentes).
|
|
1475
|
+
// Não produz nenhum statement — é resolvido e removido do AST aqui
|
|
1476
|
+
// mesmo, antes de correr qualquer evento.
|
|
1477
|
+
_flattenImports(body, basePath) {
|
|
1478
|
+
const fromImports = [];
|
|
1479
|
+
const ownStatements = [];
|
|
1480
|
+
for (const node of body) {
|
|
1481
|
+
if (node.type !== "Import") {
|
|
1482
|
+
ownStatements.push(node);
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
if (node.index !== null && node.index !== void 0) {
|
|
1486
|
+
const index = this.evalExpr(node.index, this.createContext());
|
|
1487
|
+
const entries = this._loadIndexedFile(node.path, basePath);
|
|
1488
|
+
const value = entries.get(Number(index));
|
|
1489
|
+
if (value === void 0) {
|
|
1490
|
+
throw new Error(`runtime error: IMPORT failed, entry ${index} not found in "${node.path}"`);
|
|
1491
|
+
}
|
|
1492
|
+
const key = node.alias || node.path.replace(/\.[^.]+$/, "");
|
|
1493
|
+
this.envValues.set(key, value);
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
const fullPath = this.fileSystem.resolve(basePath, node.path);
|
|
1497
|
+
if (this._importedFiles.has(fullPath)) continue;
|
|
1498
|
+
if (!this.fileSystem.exists(fullPath)) {
|
|
1499
|
+
throw new Error(`runtime error: IMPORT failed, file not found: "${fullPath}"`);
|
|
1500
|
+
}
|
|
1501
|
+
this._importedFiles.add(fullPath);
|
|
1502
|
+
const source = this.fileSystem.readFile(fullPath);
|
|
1503
|
+
const importedAst = new Parser(source).parseProgram();
|
|
1504
|
+
const nestedFlat = this._flattenImports(importedAst.body, this.fileSystem.dirname(fullPath));
|
|
1505
|
+
fromImports.push(...nestedFlat);
|
|
1506
|
+
}
|
|
1507
|
+
return [...fromImports, ...ownStatements];
|
|
1508
|
+
}
|
|
1509
|
+
// ---- Contexto de execução de um evento ----
|
|
1510
|
+
createContext(base = {}) {
|
|
1511
|
+
return {
|
|
1512
|
+
vars: {
|
|
1513
|
+
client: base.client || null,
|
|
1514
|
+
message: base.message || null,
|
|
1515
|
+
lastMsg: null,
|
|
1516
|
+
SIGNAL: null
|
|
1517
|
+
},
|
|
1518
|
+
rawSignal: base.rawSignal || null,
|
|
1519
|
+
lastInsertId: null
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
// ---- Ciclo de vida do bot ----
|
|
1523
|
+
async start() {
|
|
1524
|
+
for (const handler of this.handlers.START) {
|
|
1525
|
+
const ctx = this.createContext();
|
|
1526
|
+
await this.run(handler.body, ctx);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
async receiveMessage(client, message) {
|
|
1530
|
+
const ctx = this.createContext({ client, message });
|
|
1531
|
+
const cumprimentou = await this.greetIfNew(client);
|
|
1532
|
+
if (cumprimentou) return ctx;
|
|
1533
|
+
for (const handler of this.handlers.MESSAGE) {
|
|
1534
|
+
await this.run(handler.body, ctx);
|
|
1535
|
+
}
|
|
1536
|
+
return ctx;
|
|
1537
|
+
}
|
|
1538
|
+
// Mostra a DEFAULT MESSAGE para este client, se ainda não for
|
|
1539
|
+
// conhecido — sem precisar de nenhuma mensagem recebida. Usado por
|
|
1540
|
+
// receiveMessage() (caso real, reativo) e também pode ser chamado
|
|
1541
|
+
// diretamente pelo editor/preview ao abrir o chat, já que aí não há a
|
|
1542
|
+
// restrição das plataformas reais de só poder responder depois do
|
|
1543
|
+
// utilizador escrever primeiro. Devolve true se cumprimentou (e por
|
|
1544
|
+
// isso nada mais deve correr nesse turno), false caso contrário —
|
|
1545
|
+
// incluindo quando não há DEFAULT MESSAGE nenhuma configurada.
|
|
1546
|
+
async greetIfNew(client) {
|
|
1547
|
+
if (!this.defaultMessageConfig) return false;
|
|
1548
|
+
const { table, senderColumn } = this.defaultMessageConfig;
|
|
1549
|
+
const jaConhecido = this.db.getRows(table).some((row) => row[senderColumn] === client);
|
|
1550
|
+
if (jaConhecido) return false;
|
|
1551
|
+
const ctx = this.createContext({ client });
|
|
1552
|
+
const text = this.resolveDefaultMessageText(this.defaultMessageConfig, ctx);
|
|
1553
|
+
ctx.vars.lastMsg = text;
|
|
1554
|
+
this.db.insert(table, [senderColumn], [client]);
|
|
1555
|
+
if (this.onReply) {
|
|
1556
|
+
await this.onReply({ target: null, text, client });
|
|
1557
|
+
}
|
|
1558
|
+
return true;
|
|
1559
|
+
}
|
|
1560
|
+
// Resolve o texto do DEFAULT MESSAGE: mesma dualidade texto-direto vs
|
|
1561
|
+
// ficheiro indexado que o REPLY já tem (ver resolveReplyFromFile).
|
|
1562
|
+
resolveDefaultMessageText(config, ctx) {
|
|
1563
|
+
if (config.file) {
|
|
1564
|
+
return this.resolveReplyFromFile(config.file, config.index, ctx);
|
|
1565
|
+
}
|
|
1566
|
+
return String(this.evalExpr(config.value, ctx));
|
|
1567
|
+
}
|
|
1568
|
+
async receiveSignal(source, rawSignal) {
|
|
1569
|
+
const ctx = this.createContext({ rawSignal });
|
|
1570
|
+
for (const handler of this.handlers.SIGNAL) {
|
|
1571
|
+
if (handler.source !== source) continue;
|
|
1572
|
+
await this.run(handler.body, ctx);
|
|
1573
|
+
}
|
|
1574
|
+
return ctx;
|
|
1575
|
+
}
|
|
1576
|
+
// ---- Execução de uma lista de statements (corpo de um bloco) ----
|
|
1577
|
+
async run(statements, ctx) {
|
|
1578
|
+
let groupMatched = false;
|
|
1579
|
+
for (let i = 0; i < statements.length; i++) {
|
|
1580
|
+
const stmt = statements[i];
|
|
1581
|
+
if (stmt.type === "When") {
|
|
1582
|
+
if (i === 0 || statements[i - 1].type !== "When") groupMatched = false;
|
|
1583
|
+
const isMatch = stmt.conditions.some((cond) => this.evalCondition(cond, ctx));
|
|
1584
|
+
if (isMatch) {
|
|
1585
|
+
await this.run(stmt.body, ctx);
|
|
1586
|
+
groupMatched = true;
|
|
1587
|
+
}
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
if (stmt.type === "Otherwise") {
|
|
1591
|
+
if (!groupMatched) await this.run(stmt.body, ctx);
|
|
1592
|
+
groupMatched = false;
|
|
1593
|
+
continue;
|
|
1594
|
+
}
|
|
1595
|
+
await this.execStatement(stmt, ctx);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
evalCondition(cond, ctx) {
|
|
1599
|
+
if (typeof ctx.vars.message !== "string") return false;
|
|
1600
|
+
const message = ctx.vars.message.toLowerCase();
|
|
1601
|
+
if (cond.op === "CONTAINS") {
|
|
1602
|
+
return message.includes(cond.value.toLowerCase());
|
|
1603
|
+
}
|
|
1604
|
+
if (cond.op === "CONTAINS_ANY") {
|
|
1605
|
+
return cond.values.some((v) => message.includes(v.toLowerCase()));
|
|
1606
|
+
}
|
|
1607
|
+
if (cond.op === "CONTAINS_KEYWORDS_FILE") {
|
|
1608
|
+
const words = this._loadKeywordsFile(cond.path);
|
|
1609
|
+
return words.some((v) => message.includes(v.toLowerCase()));
|
|
1610
|
+
}
|
|
1611
|
+
throw new Error(`runtime error: unsupported condition: ${cond.op}`);
|
|
1612
|
+
}
|
|
1613
|
+
// Lê um ficheiro de keywords: uma palavra/frase por linha, nada mais.
|
|
1614
|
+
// Sem comentários nem qualquer outra sintaxe misturada de propósito —
|
|
1615
|
+
// um ficheiro só serve para um fim, evita ambiguidade sobre o que é
|
|
1616
|
+
// keyword e o que não é. Resultado fica em cache — chamado a cada
|
|
1617
|
+
// mensagem recebida, não pode reler disco/rede sempre.
|
|
1618
|
+
_loadKeywordsFile(path) {
|
|
1619
|
+
if (this._keywordsFileCache.has(path)) {
|
|
1620
|
+
return this._keywordsFileCache.get(path);
|
|
1621
|
+
}
|
|
1622
|
+
const fullPath = this.fileSystem.resolve(this._rootBasePath, path);
|
|
1623
|
+
if (!this.fileSystem.exists(fullPath)) {
|
|
1624
|
+
throw new Error(`runtime error: KEYWORDS failed, file not found: "${fullPath}"`);
|
|
1625
|
+
}
|
|
1626
|
+
const raw = this.fileSystem.readFile(fullPath);
|
|
1627
|
+
const words = raw.split("\n").map((linha) => linha.trim()).filter((linha) => linha.length > 0);
|
|
1628
|
+
this._keywordsFileCache.set(path, words);
|
|
1629
|
+
return words;
|
|
1630
|
+
}
|
|
1631
|
+
// Resolve o texto e o tempo mínimo do WAITING(...) de um THINK. Sem
|
|
1632
|
+
// WAITING nenhum no .sql (stmt.waiting === null), ou com WAITING()
|
|
1633
|
+
// vazio, usa os valores por omissão. O ficheiro do WAITING é lido tal
|
|
1634
|
+
// e qual (texto/HTML livre) — ao contrário do REPLY/DEFAULT MESSAGE
|
|
1635
|
+
// indexados, não é o formato "N- valor".
|
|
1636
|
+
resolveWaitingConfig(waiting, ctx) {
|
|
1637
|
+
if (!waiting) return { text: WAITING_TEXTO_PADRAO, seconds: WAITING_SEGUNDOS_PADRAO };
|
|
1638
|
+
let text = WAITING_TEXTO_PADRAO;
|
|
1639
|
+
if (waiting.file) {
|
|
1640
|
+
const fullPath = this.fileSystem.resolve(this._rootBasePath, waiting.file);
|
|
1641
|
+
if (!this.fileSystem.exists(fullPath)) {
|
|
1642
|
+
throw new Error(`runtime error: WAITING failed, file not found: "${fullPath}"`);
|
|
1643
|
+
}
|
|
1644
|
+
text = this.fileSystem.readFile(fullPath).trim();
|
|
1645
|
+
}
|
|
1646
|
+
let seconds = WAITING_SEGUNDOS_PADRAO;
|
|
1647
|
+
if (waiting.seconds) {
|
|
1648
|
+
seconds = Number(this.evalExpr(waiting.seconds, ctx));
|
|
1649
|
+
}
|
|
1650
|
+
return { text, seconds };
|
|
1651
|
+
}
|
|
1652
|
+
// Resolve REPLY (ficheiro.txt, indice): lê o índice (número ou
|
|
1653
|
+
// expressão que resolve a número) e devolve o texto da entrada
|
|
1654
|
+
// correspondente do ficheiro de respostas.
|
|
1655
|
+
resolveReplyFromFile(file, indexNode, ctx) {
|
|
1656
|
+
const index = this.evalExpr(indexNode, ctx);
|
|
1657
|
+
const entries = this._loadIndexedFile(file, this._rootBasePath);
|
|
1658
|
+
const text = entries.get(Number(index));
|
|
1659
|
+
if (text === void 0) {
|
|
1660
|
+
throw new Error(`runtime error: REPLY failed, entry ${index} not found in "${file}"`);
|
|
1661
|
+
}
|
|
1662
|
+
return text;
|
|
1663
|
+
}
|
|
1664
|
+
// Lê um ficheiro de entradas numeradas. Duas formas, na mesma
|
|
1665
|
+
// entrada N, nunca misturadas:
|
|
1666
|
+
//
|
|
1667
|
+
// N- texto — uma linha só (forma original, continua igual)
|
|
1668
|
+
// N-{ ... } — bloco delimitado por chaves, pode ter
|
|
1669
|
+
// varias linhas e HTML/Markdown/CSS por
|
|
1670
|
+
// dentro. So fecha na "}" que corresponde a
|
|
1671
|
+
// "{" que abriu — chaves internas (ex: um
|
|
1672
|
+
// style="{color:red}" dentro do HTML) contam
|
|
1673
|
+
// para o aninhamento e NAO fecham a entrada
|
|
1674
|
+
// cedo. Rigoroso: uma "{" sem a "}"
|
|
1675
|
+
// correspondente antes do fim do ficheiro e'
|
|
1676
|
+
// erro, nunca silenciosamente ignorado.
|
|
1677
|
+
//
|
|
1678
|
+
// Resultado e' um Map indice -> valor, em cache — usado tanto por
|
|
1679
|
+
// REPLY (ficheiro, N) como por IMPORT {ficheiro, N}.
|
|
1680
|
+
_loadIndexedFile(path, basePath) {
|
|
1681
|
+
if (this._replyFileCache.has(path)) {
|
|
1682
|
+
return this._replyFileCache.get(path);
|
|
1683
|
+
}
|
|
1684
|
+
const fullPath = this.fileSystem.resolve(basePath, path);
|
|
1685
|
+
if (!this.fileSystem.exists(fullPath)) {
|
|
1686
|
+
throw new Error(`runtime error: file not found: "${fullPath}"`);
|
|
1687
|
+
}
|
|
1688
|
+
const raw = this.fileSystem.readFile(fullPath);
|
|
1689
|
+
const entries = this._parseIndexedEntries(raw, path);
|
|
1690
|
+
this._replyFileCache.set(path, entries);
|
|
1691
|
+
return entries;
|
|
1692
|
+
}
|
|
1693
|
+
// Parser char-by-char do formato de entradas numeradas. Percorre o
|
|
1694
|
+
// ficheiro procurando "N-" no inicio de uma linha (ignorando espacos);
|
|
1695
|
+
// o que vem a seguir decide a forma:
|
|
1696
|
+
// "{" logo a seguir -> bloco: conta chaves ate a correspondente
|
|
1697
|
+
// fechar, valor e' o conteudo entre elas (sem as chaves),
|
|
1698
|
+
// aparado nas pontas.
|
|
1699
|
+
// qualquer outra coisa -> forma de uma linha: valor e' o resto da
|
|
1700
|
+
// linha, aparado.
|
|
1701
|
+
_parseIndexedEntries(raw, path) {
|
|
1702
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1703
|
+
const len = raw.length;
|
|
1704
|
+
let i = 0;
|
|
1705
|
+
while (i < len) {
|
|
1706
|
+
const lineStart = i;
|
|
1707
|
+
let j = lineStart;
|
|
1708
|
+
while (j < len && (raw[j] === " " || raw[j] === " ")) j++;
|
|
1709
|
+
const numMatch = /^\d+/.exec(raw.slice(j));
|
|
1710
|
+
if (!numMatch || raw[j + numMatch[0].length] !== "-") {
|
|
1711
|
+
const nl2 = raw.indexOf("\n", lineStart);
|
|
1712
|
+
i = nl2 === -1 ? len : nl2 + 1;
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
const index = Number(numMatch[0]);
|
|
1716
|
+
let k = j + numMatch[0].length + 1;
|
|
1717
|
+
if (raw[k] === "{") {
|
|
1718
|
+
let depth = 1;
|
|
1719
|
+
const contentStart = k + 1;
|
|
1720
|
+
let p = contentStart;
|
|
1721
|
+
while (p < len && depth > 0) {
|
|
1722
|
+
if (raw[p] === "{") depth++;
|
|
1723
|
+
else if (raw[p] === "}") depth--;
|
|
1724
|
+
p++;
|
|
1725
|
+
}
|
|
1726
|
+
if (depth !== 0) {
|
|
1727
|
+
throw new Error(`runtime error: unclosed "{" for entry ${index} in "${path}"`);
|
|
1728
|
+
}
|
|
1729
|
+
const value2 = raw.slice(contentStart, p - 1).trim();
|
|
1730
|
+
entries.set(index, value2);
|
|
1731
|
+
i = p;
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
const nl = raw.indexOf("\n", k);
|
|
1735
|
+
const lineEnd = nl === -1 ? len : nl;
|
|
1736
|
+
const value = raw.slice(k, lineEnd).trim();
|
|
1737
|
+
entries.set(index, value);
|
|
1738
|
+
i = nl === -1 ? len : nl + 1;
|
|
1739
|
+
}
|
|
1740
|
+
return entries;
|
|
1741
|
+
}
|
|
1742
|
+
// ---- Execução de um statement de ação ----
|
|
1743
|
+
async execStatement(stmt, ctx) {
|
|
1744
|
+
switch (stmt.type) {
|
|
1745
|
+
case "Reply": {
|
|
1746
|
+
const text = stmt.file ? this.resolveReplyFromFile(stmt.file, stmt.index, ctx) : String(await this.evalReplyValue(stmt.value, ctx));
|
|
1747
|
+
ctx.vars.lastMsg = text;
|
|
1748
|
+
if (this.onReply) {
|
|
1749
|
+
await this.onReply({ target: stmt.target, text, client: ctx.vars.client });
|
|
1750
|
+
}
|
|
1751
|
+
break;
|
|
1752
|
+
}
|
|
1753
|
+
case "Think": {
|
|
1754
|
+
const waiting = this.resolveWaitingConfig(stmt.waiting, ctx);
|
|
1755
|
+
const inicio = Date.now();
|
|
1756
|
+
if (this.onThinking) {
|
|
1757
|
+
await this.onThinking({ client: ctx.vars.client, text: waiting.text });
|
|
1758
|
+
}
|
|
1759
|
+
const fullPath = this.fileSystem.resolve(this._rootBasePath, stmt.file);
|
|
1760
|
+
const index = this.knowledgeCache.get(fullPath);
|
|
1761
|
+
const resultado = index.analyze(ctx.vars.message);
|
|
1762
|
+
const decorrido = Date.now() - inicio;
|
|
1763
|
+
const faltam = waiting.seconds * 1e3 - decorrido;
|
|
1764
|
+
if (faltam > 0) {
|
|
1765
|
+
await new Promise((resolve) => setTimeout(resolve, faltam));
|
|
1766
|
+
}
|
|
1767
|
+
if (resultado.decision === "RESPONDER") {
|
|
1768
|
+
const text = resultado.texto;
|
|
1769
|
+
ctx.vars.lastMsg = text;
|
|
1770
|
+
if (this.onReply) {
|
|
1771
|
+
await this.onReply({ target: null, text, client: ctx.vars.client });
|
|
1772
|
+
}
|
|
1773
|
+
} else if (stmt.fallback) {
|
|
1774
|
+
await this.execStatement(stmt.fallback, ctx);
|
|
1775
|
+
}
|
|
1776
|
+
break;
|
|
1777
|
+
}
|
|
1778
|
+
case "ForwardTo": {
|
|
1779
|
+
const target = String(this.evalExpr(stmt.target, ctx));
|
|
1780
|
+
if (this.onForward) {
|
|
1781
|
+
await this.onForward({ target, client: ctx.vars.client });
|
|
1782
|
+
}
|
|
1783
|
+
break;
|
|
1784
|
+
}
|
|
1785
|
+
case "ParseSignal": {
|
|
1786
|
+
ctx.vars.SIGNAL = await this.signalParser(ctx.rawSignal);
|
|
1787
|
+
break;
|
|
1788
|
+
}
|
|
1789
|
+
case "SendTo": {
|
|
1790
|
+
if (this.onSend) {
|
|
1791
|
+
await this.onSend({ target: stmt.target, signal: ctx.vars.SIGNAL });
|
|
1792
|
+
}
|
|
1793
|
+
break;
|
|
1794
|
+
}
|
|
1795
|
+
case "Insert": {
|
|
1796
|
+
this.execInsert(stmt, ctx);
|
|
1797
|
+
break;
|
|
1798
|
+
}
|
|
1799
|
+
case "Update": {
|
|
1800
|
+
this.execUpdate(stmt, ctx);
|
|
1801
|
+
break;
|
|
1802
|
+
}
|
|
1803
|
+
default:
|
|
1804
|
+
throw new Error(`runtime error: unsupported statement inside event: ${stmt.type}`);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
execInsert(stmt, ctx) {
|
|
1808
|
+
let columnNames;
|
|
1809
|
+
let values;
|
|
1810
|
+
if (stmt.table === "Context" && (!stmt.columns || stmt.columns.length === 0) && !stmt.values) {
|
|
1811
|
+
columnNames = ["client", "message", "created_at"];
|
|
1812
|
+
values = [ctx.vars.client, ctx.vars.message, (/* @__PURE__ */ new Date()).toISOString()];
|
|
1813
|
+
} else {
|
|
1814
|
+
columnNames = (stmt.columns || []).map((c) => c.name);
|
|
1815
|
+
values = (stmt.values || []).map((v) => this.evalExpr(v, ctx));
|
|
1816
|
+
}
|
|
1817
|
+
ctx.lastInsertId = this.db.insert(stmt.table, columnNames, values);
|
|
1818
|
+
}
|
|
1819
|
+
execUpdate(stmt, ctx) {
|
|
1820
|
+
const value = this.evalExpr(stmt.set.value, ctx);
|
|
1821
|
+
let whereColumn = null;
|
|
1822
|
+
let whereValue = null;
|
|
1823
|
+
if (stmt.where) {
|
|
1824
|
+
whereColumn = stmt.where.left.name;
|
|
1825
|
+
whereValue = this.evalExpr(stmt.where.right, ctx);
|
|
1826
|
+
}
|
|
1827
|
+
this.db.update(stmt.table, stmt.set.column, value, whereColumn, whereValue);
|
|
1828
|
+
}
|
|
1829
|
+
// ---- Avaliação de expressões ----
|
|
1830
|
+
// Ponto de entrada usado só por REPLY <expr>: trata Response()/
|
|
1831
|
+
// Response(alias) como caso especial assíncrono (chama this.onResponse,
|
|
1832
|
+
// que fala de verdade com a IA ligada) e delega qualquer outra
|
|
1833
|
+
// expressão ao evalExpr síncrono normal. Response() não pode ser
|
|
1834
|
+
// composto dentro de + (ex: REPLY "x" + Response() não é suportado) —
|
|
1835
|
+
// só faz sentido como o valor direto do REPLY, conforme a doc.
|
|
1836
|
+
async evalReplyValue(node, ctx) {
|
|
1837
|
+
if (node.type === "Call" && node.callee === "Response") {
|
|
1838
|
+
return this.execResponse(node, ctx);
|
|
1839
|
+
}
|
|
1840
|
+
return this.evalExpr(node, ctx);
|
|
1841
|
+
}
|
|
1842
|
+
// Response() / Response(alias): dispara o ciclo com a IA ligada via
|
|
1843
|
+
// CONNECT RESPONSE — envia a mensagem atual, espera, devolve o texto.
|
|
1844
|
+
// Sem argumento só é válido com exatamente uma ligação (ambíguo com
|
|
1845
|
+
// mais que uma); com argumento, o alias tem de corresponder a um
|
|
1846
|
+
// CONNECT RESPONSE já feito.
|
|
1847
|
+
async execResponse(node, ctx) {
|
|
1848
|
+
if (!this.onResponse) {
|
|
1849
|
+
throw new Error("runtime error: Response() failed, no AI connected (missing onResponse handler)");
|
|
1850
|
+
}
|
|
1851
|
+
let alias;
|
|
1852
|
+
if (node.args.length === 0) {
|
|
1853
|
+
if (this.responseConnections.size === 0) {
|
|
1854
|
+
throw new Error("runtime error: Response() failed, no CONNECT RESPONSE found");
|
|
1855
|
+
}
|
|
1856
|
+
if (this.responseConnections.size > 1) {
|
|
1857
|
+
throw new Error(`runtime error: Response() is ambiguous with multiple CONNECT RESPONSE (${[...this.responseConnections].join(", ")}); use Response(alias)`);
|
|
1858
|
+
}
|
|
1859
|
+
alias = [...this.responseConnections][0];
|
|
1860
|
+
} else {
|
|
1861
|
+
const argNode = node.args[0];
|
|
1862
|
+
if (argNode.type !== "Identifier") {
|
|
1863
|
+
throw new Error("runtime error: Response(alias) expects an alias name, not a string or expression");
|
|
1864
|
+
}
|
|
1865
|
+
alias = argNode.name;
|
|
1866
|
+
if (!this.responseConnections.has(alias)) {
|
|
1867
|
+
throw new Error(`runtime error: Response(${alias}) failed, no CONNECT RESPONSE ${alias} found`);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
const token = this.envValues.get(alias);
|
|
1871
|
+
const text = await this.onResponse({ alias, token, message: ctx.vars.message, client: ctx.vars.client });
|
|
1872
|
+
return text;
|
|
1873
|
+
}
|
|
1874
|
+
evalExpr(node, ctx) {
|
|
1875
|
+
switch (node.type) {
|
|
1876
|
+
case "Literal":
|
|
1877
|
+
return node.value;
|
|
1878
|
+
case "Identifier": {
|
|
1879
|
+
if (node.name === "env") {
|
|
1880
|
+
return Object.fromEntries(this.envValues);
|
|
1881
|
+
}
|
|
1882
|
+
if (Object.prototype.hasOwnProperty.call(ctx.vars, node.name)) {
|
|
1883
|
+
return ctx.vars[node.name];
|
|
1884
|
+
}
|
|
1885
|
+
if (this.envValues.has(node.name)) {
|
|
1886
|
+
return this.envValues.get(node.name);
|
|
1887
|
+
}
|
|
1888
|
+
throw new Error(`runtime error: unknown identifier: "${node.name}"`);
|
|
1889
|
+
}
|
|
1890
|
+
case "Member": {
|
|
1891
|
+
const obj = this.evalExpr(node.object, ctx);
|
|
1892
|
+
if (obj === null || obj === void 0) return void 0;
|
|
1893
|
+
return obj[node.property];
|
|
1894
|
+
}
|
|
1895
|
+
case "Call": {
|
|
1896
|
+
if (node.callee === "LAST_INSERT_ID") return ctx.lastInsertId;
|
|
1897
|
+
const fn = this.nativeFuncs.get(node.callee);
|
|
1898
|
+
if (!fn) throw new Error(`runtime error: unknown function: "${node.callee}"`);
|
|
1899
|
+
const args = node.args.map((a) => this.evalExpr(a, ctx));
|
|
1900
|
+
return fn(...args);
|
|
1901
|
+
}
|
|
1902
|
+
case "Binary": {
|
|
1903
|
+
const left = this.evalExpr(node.left, ctx);
|
|
1904
|
+
const right = this.evalExpr(node.right, ctx);
|
|
1905
|
+
if (node.op === "+") {
|
|
1906
|
+
if (typeof left === "number" && typeof right === "number") return left + right;
|
|
1907
|
+
return String(left) + String(right);
|
|
1908
|
+
}
|
|
1909
|
+
if (node.op === "=") return left === right;
|
|
1910
|
+
throw new Error(`runtime error: unsupported operator: "${node.op}"`);
|
|
1911
|
+
}
|
|
1912
|
+
default:
|
|
1913
|
+
throw new Error(`runtime error: unsupported expression: ${node.type}`);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
1917
|
+
module.exports = { BotQLInterpreter, MemoryDatabase };
|
|
1918
|
+
}
|
|
1919
|
+
});
|
|
1920
|
+
return require_botql();
|
|
1921
|
+
})();
|