@quantyapp/quanty-mcp-server 1.0.11 → 1.2.6

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.
@@ -0,0 +1,437 @@
1
+ import { supabase } from './supabase.js';
2
+ // ================ TEXT NORMALIZATION ================
3
+ /**
4
+ * Normaliza texto removendo acentos e convertendo para minúsculas
5
+ */
6
+ function normalizeText(text) {
7
+ if (!text)
8
+ return '';
9
+ return text
10
+ .toLowerCase()
11
+ .normalize('NFD')
12
+ .replace(/[\u0300-\u036f]/g, '') // Remove acentos
13
+ .replace(/[^\w\s]/g, ' ') // Remove pontuação
14
+ .replace(/\s+/g, ' ') // Colapsa espaços múltiplos
15
+ .trim();
16
+ }
17
+ /**
18
+ * Dicionário de sinônimos e abreviações comuns em orçamentação
19
+ */
20
+ const SYNONYMS = {
21
+ 'concreto': ['conc', 'concr', 'cto'],
22
+ 'argamassa': ['arg', 'argam'],
23
+ 'metro': ['m', 'mt', 'mts'],
24
+ 'centimetro': ['cm', 'cent'],
25
+ 'milimetro': ['mm', 'milim'],
26
+ 'quilograma': ['kg', 'quilo', 'kilo'],
27
+ 'tonelada': ['t', 'ton'],
28
+ 'litro': ['l', 'lt', 'lts'],
29
+ 'unidade': ['un', 'und', 'unid', 'peca', 'pc', 'pç'],
30
+ 'verba': ['vb', 'vrb'],
31
+ 'conjunto': ['cj', 'conj'],
32
+ 'hora': ['h', 'hr', 'hs'],
33
+ 'dia': ['d'],
34
+ 'mes': ['m', 'meses'],
35
+ 'equipamento': ['equip', 'eqp', 'eq'],
36
+ 'material': ['mat', 'mater'],
37
+ 'servico': ['serv', 'svc'],
38
+ 'maodeobra': ['mo', 'mao de obra', 'mão de obra'],
39
+ 'transporte': ['transp', 'trans'],
40
+ 'instalacao': ['inst', 'instal'],
41
+ 'eletrico': ['eletr', 'elet', 'elétrico'],
42
+ 'hidraulico': ['hidr', 'hidra', 'hidráulico'],
43
+ 'tubo': ['tb', 'tub'],
44
+ 'conexao': ['cnx', 'conex', 'conexão'],
45
+ 'fundacao': ['fund', 'fundação'],
46
+ 'estrutura': ['estr', 'estrut'],
47
+ 'alvenaria': ['alv', 'alven'],
48
+ 'revestimento': ['revest', 'rev'],
49
+ 'pintura': ['pint', 'pin'],
50
+ 'impermeabilizacao': ['imper', 'impermeabilização'],
51
+ 'esquadria': ['esq', 'esquad'],
52
+ 'cobertura': ['cob', 'cobert'],
53
+ 'administracao': ['adm', 'admin', 'administração'],
54
+ 'canteiro': ['cant'],
55
+ 'mobilizacao': ['mob', 'mobil', 'mobilização'],
56
+ 'desmobilizacao': ['desmob', 'desmobil', 'desmobilização'],
57
+ };
58
+ /**
59
+ * Expande sinônimos e abreviações no texto
60
+ */
61
+ function expandSynonyms(text) {
62
+ let normalized = normalizeText(text);
63
+ // Substitui abreviações pelos termos completos
64
+ for (const [fullTerm, abbreviations] of Object.entries(SYNONYMS)) {
65
+ for (const abbrev of abbreviations) {
66
+ // Substitui apenas palavras inteiras
67
+ const regex = new RegExp(`\\b${abbrev}\\b`, 'gi');
68
+ normalized = normalized.replace(regex, fullTerm);
69
+ }
70
+ }
71
+ return normalized;
72
+ }
73
+ /**
74
+ * Extrai features de uma descrição de item
75
+ */
76
+ function extractFeatures(description) {
77
+ const normalized = expandSynonyms(description);
78
+ const words = normalized.split(' ').filter(w => w.length > 2);
79
+ // Extrai dimensões (ex: 20x15, 10cm, 2.5m)
80
+ const dimensionPattern = /(\d+(?:[.,]\d+)?)\s*x\s*(\d+(?:[.,]\d+)?)|(\d+(?:[.,]\d+)?)\s*(cm|mm|m|pol)/gi;
81
+ const dimensions = [];
82
+ let match;
83
+ while ((match = dimensionPattern.exec(description)) !== null) {
84
+ dimensions.push(match[0]);
85
+ }
86
+ // Extrai números isolados
87
+ const numberPattern = /\b(\d+(?:[.,]\d+)?)\b/g;
88
+ const numbers = [];
89
+ while ((match = numberPattern.exec(normalized)) !== null) {
90
+ numbers.push(parseFloat(match[1].replace(',', '.')));
91
+ }
92
+ // Identifica materiais comuns
93
+ const materialKeywords = [
94
+ 'concreto', 'aco', 'ferro', 'argamassa', 'cimento', 'areia', 'brita',
95
+ 'madeira', 'vidro', 'aluminio', 'pvc', 'cobre', 'ceramica', 'porcelanato',
96
+ 'granito', 'marmore', 'gesso', 'drywall', 'tijolo', 'bloco'
97
+ ];
98
+ const materials = words.filter(w => materialKeywords.includes(w));
99
+ // Identifica categoria
100
+ const categoryKeywords = {
101
+ 'fundacao': ['fundacao', 'estaca', 'radier', 'sapata', 'tubulao'],
102
+ 'estrutura': ['estrutura', 'pilar', 'viga', 'laje', 'concreto armado'],
103
+ 'alvenaria': ['alvenaria', 'parede', 'tijolo', 'bloco'],
104
+ 'revestimento': ['revestimento', 'reboco', 'chapisco', 'ceramica', 'piso'],
105
+ 'pintura': ['pintura', 'tinta', 'latex', 'acrilica', 'massa corrida'],
106
+ 'instalacao_eletrica': ['eletrico', 'fio', 'cabo', 'tomada', 'interruptor', 'disjuntor'],
107
+ 'instalacao_hidraulica': ['hidraulico', 'tubo', 'conexao', 'registro', 'valvula'],
108
+ 'impermeabilizacao': ['impermeabilizacao', 'manta', 'asfaltica'],
109
+ 'esquadria': ['esquadria', 'porta', 'janela', 'vidro'],
110
+ 'cobertura': ['cobertura', 'telha', 'calha', 'rufo'],
111
+ 'administracao': ['administracao', 'canteiro', 'mobilizacao', 'desmobilizacao', 'engenheiro']
112
+ };
113
+ let category = 'outros';
114
+ for (const [cat, keywords] of Object.entries(categoryKeywords)) {
115
+ if (keywords.some(kw => normalized.includes(kw))) {
116
+ category = cat;
117
+ break;
118
+ }
119
+ }
120
+ // Keywords são palavras significativas (não stop words)
121
+ const stopWords = ['de', 'da', 'do', 'para', 'com', 'em', 'por', 'uma', 'um', 'que', 'e', 'ou'];
122
+ const keywords = words.filter(w => !stopWords.includes(w) && w.length > 2);
123
+ return { keywords, dimensions, numbers, materials, category };
124
+ }
125
+ // ================ SIMILARITY METRICS ================
126
+ /**
127
+ * Calcula distância de Levenshtein entre duas strings
128
+ */
129
+ function levenshteinDistance(a, b) {
130
+ const matrix = [];
131
+ for (let i = 0; i <= b.length; i++) {
132
+ matrix[i] = [i];
133
+ }
134
+ for (let j = 0; j <= a.length; j++) {
135
+ matrix[0][j] = j;
136
+ }
137
+ for (let i = 1; i <= b.length; i++) {
138
+ for (let j = 1; j <= a.length; j++) {
139
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
140
+ matrix[i][j] = matrix[i - 1][j - 1];
141
+ }
142
+ else {
143
+ matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
144
+ }
145
+ }
146
+ }
147
+ return matrix[b.length][a.length];
148
+ }
149
+ /**
150
+ * Calcula similaridade fuzzy (baseada em Levenshtein)
151
+ */
152
+ function fuzzySimilarity(a, b) {
153
+ const normA = expandSynonyms(a);
154
+ const normB = expandSynonyms(b);
155
+ if (normA === normB)
156
+ return 100;
157
+ const maxLen = Math.max(normA.length, normB.length);
158
+ if (maxLen === 0)
159
+ return 100;
160
+ const distance = levenshteinDistance(normA, normB);
161
+ return Math.round((1 - distance / maxLen) * 100);
162
+ }
163
+ /**
164
+ * Calcula similaridade baseada em palavras-chave (Jaccard)
165
+ */
166
+ function keywordSimilarity(a, b) {
167
+ const featuresA = extractFeatures(a);
168
+ const featuresB = extractFeatures(b);
169
+ const setA = new Set(featuresA.keywords);
170
+ const setB = new Set(featuresB.keywords);
171
+ const intersection = new Set([...setA].filter(x => setB.has(x)));
172
+ const union = new Set([...setA, ...setB]);
173
+ if (union.size === 0)
174
+ return 0;
175
+ return Math.round((intersection.size / union.size) * 100);
176
+ }
177
+ /**
178
+ * Calcula similaridade TF-IDF simplificada
179
+ */
180
+ function tfidfSimilarity(a, b) {
181
+ const wordsA = expandSynonyms(a).split(' ').filter(w => w.length > 2);
182
+ const wordsB = expandSynonyms(b).split(' ').filter(w => w.length > 2);
183
+ // Conta frequência de palavras
184
+ const freqA = {};
185
+ const freqB = {};
186
+ for (const w of wordsA)
187
+ freqA[w] = (freqA[w] || 0) + 1;
188
+ for (const w of wordsB)
189
+ freqB[w] = (freqB[w] || 0) + 1;
190
+ // Calcula produto escalar normalizado (cosine similarity)
191
+ const allWords = new Set([...Object.keys(freqA), ...Object.keys(freqB)]);
192
+ let dotProduct = 0;
193
+ let normA = 0;
194
+ let normB = 0;
195
+ for (const word of allWords) {
196
+ const vA = freqA[word] || 0;
197
+ const vB = freqB[word] || 0;
198
+ dotProduct += vA * vB;
199
+ normA += vA * vA;
200
+ normB += vB * vB;
201
+ }
202
+ if (normA === 0 || normB === 0)
203
+ return 0;
204
+ return Math.round((dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))) * 100);
205
+ }
206
+ /**
207
+ * Calcula similaridade combinada (ensemble)
208
+ */
209
+ export function calculateSimilarity(a, b) {
210
+ const fuzzy = fuzzySimilarity(a, b);
211
+ const keyword = keywordSimilarity(a, b);
212
+ const tfidf = tfidfSimilarity(a, b);
213
+ const featuresA = extractFeatures(a);
214
+ const featuresB = extractFeatures(b);
215
+ const categoryMatch = featuresA.category === featuresB.category;
216
+ // Pesos ajustados
217
+ const weights = { fuzzy: 0.3, keyword: 0.4, tfidf: 0.3 };
218
+ // Bonus por categoria igual
219
+ const categoryBonus = categoryMatch ? 10 : 0;
220
+ const overall = Math.min(100, Math.round(fuzzy * weights.fuzzy +
221
+ keyword * weights.keyword +
222
+ tfidf * weights.tfidf +
223
+ categoryBonus));
224
+ return {
225
+ overall,
226
+ fuzzy,
227
+ keyword,
228
+ tfidf,
229
+ category: {
230
+ a: featuresA.category,
231
+ b: featuresB.category,
232
+ match: categoryMatch
233
+ }
234
+ };
235
+ }
236
+ /**
237
+ * Busca itens similares em outros orçamentos
238
+ */
239
+ export async function findSimilarInBudgets(auth, description, excludeBudgetId, limit = 10, minSimilarity = 50) {
240
+ // Busca orçamentos do tenant/usuário
241
+ const { data: budgets, error } = await supabase
242
+ .from('budgets')
243
+ .select('id, title, data')
244
+ .or(`tenant_id.eq.${auth.tenantId},user_id.eq.${auth.userId}`)
245
+ .eq('deleted', false)
246
+ .limit(50); // Limita para performance
247
+ if (error || !budgets)
248
+ return [];
249
+ const results = [];
250
+ for (const budget of budgets) {
251
+ if (budget.id === excludeBudgetId)
252
+ continue;
253
+ const rows = budget.data?.rows || [];
254
+ for (const row of rows) {
255
+ if (!row.description)
256
+ continue;
257
+ const sim = calculateSimilarity(description, row.description);
258
+ if (sim.overall >= minSimilarity) {
259
+ results.push({
260
+ id: row.id,
261
+ code: row.code || '',
262
+ description: row.description,
263
+ unit: row.unit || '',
264
+ unitCost: row.unitCost || 0,
265
+ source: 'budget',
266
+ sourceId: budget.id,
267
+ sourceName: budget.title,
268
+ similarity: sim.overall,
269
+ similarityDetails: {
270
+ fuzzy: sim.fuzzy,
271
+ keyword: sim.keyword,
272
+ tfidf: sim.tfidf
273
+ }
274
+ });
275
+ }
276
+ }
277
+ }
278
+ // Ordena por similaridade e limita
279
+ return results
280
+ .sort((a, b) => b.similarity - a.similarity)
281
+ .slice(0, limit);
282
+ }
283
+ /**
284
+ * Busca itens similares em bancos de composições
285
+ */
286
+ export async function findSimilarInBanks(auth, description, bankIds, limit = 10, minSimilarity = 50) {
287
+ // Busca bancos disponíveis
288
+ let query = supabase
289
+ .from('composition_banks')
290
+ .select('id, title, data, visibility');
291
+ if (bankIds && bankIds.length > 0) {
292
+ query = query.in('id', bankIds);
293
+ }
294
+ else {
295
+ // Busca bancos públicos ou do tenant/usuário
296
+ query = query.or(`visibility.eq.public,tenant_id.eq.${auth.tenantId},user_id.eq.${auth.userId}`);
297
+ }
298
+ const { data: banks, error } = await query.limit(20);
299
+ if (error || !banks)
300
+ return [];
301
+ const results = [];
302
+ for (const bank of banks) {
303
+ const categories = bank.data?.categories || [];
304
+ // Função recursiva para extrair itens
305
+ const extractItems = (nodes) => {
306
+ const items = [];
307
+ for (const node of nodes) {
308
+ if (node.items) {
309
+ items.push(...node.items);
310
+ }
311
+ if (node.children) {
312
+ items.push(...extractItems(node.children));
313
+ }
314
+ }
315
+ return items;
316
+ };
317
+ const items = extractItems(categories);
318
+ for (const item of items) {
319
+ const itemDesc = item.description || item.name || item.title || '';
320
+ if (!itemDesc)
321
+ continue;
322
+ const sim = calculateSimilarity(description, itemDesc);
323
+ if (sim.overall >= minSimilarity) {
324
+ results.push({
325
+ id: item.id,
326
+ code: item.code || '',
327
+ description: itemDesc,
328
+ unit: item.unit || item.unidade || '',
329
+ unitCost: item.unitCost || item.unit_cost || item.custoUnitario || 0,
330
+ source: 'bank',
331
+ sourceId: bank.id,
332
+ sourceName: bank.title,
333
+ similarity: sim.overall,
334
+ similarityDetails: {
335
+ fuzzy: sim.fuzzy,
336
+ keyword: sim.keyword,
337
+ tfidf: sim.tfidf
338
+ }
339
+ });
340
+ }
341
+ }
342
+ }
343
+ // Ordena por similaridade e limita
344
+ return results
345
+ .sort((a, b) => b.similarity - a.similarity)
346
+ .slice(0, limit);
347
+ }
348
+ /**
349
+ * Busca unificada em orçamentos e bancos
350
+ */
351
+ export async function findSimilarItems(auth, description, options = {}) {
352
+ const { searchBudgets = true, searchBanks = true, excludeBudgetId, bankIds, limit = 10, minSimilarity = 50 } = options;
353
+ const [budgetResults, bankResults] = await Promise.all([
354
+ searchBudgets ? findSimilarInBudgets(auth, description, excludeBudgetId, limit, minSimilarity) : [],
355
+ searchBanks ? findSimilarInBanks(auth, description, bankIds, limit, minSimilarity) : []
356
+ ]);
357
+ // Combina e ordena os top matches
358
+ const allResults = [...budgetResults, ...bankResults];
359
+ const topMatches = allResults
360
+ .sort((a, b) => b.similarity - a.similarity)
361
+ .slice(0, limit);
362
+ return {
363
+ budgetResults,
364
+ bankResults,
365
+ topMatches
366
+ };
367
+ }
368
+ /**
369
+ * Compara preços de itens similares
370
+ */
371
+ export async function comparePrices(auth, description, currentPrice, options = {}) {
372
+ const { topMatches } = await findSimilarItems(auth, description, {
373
+ searchBudgets: true,
374
+ searchBanks: true,
375
+ excludeBudgetId: options.excludeBudgetId,
376
+ limit: options.limit || 20,
377
+ minSimilarity: options.minSimilarity || 60
378
+ });
379
+ // Filtra itens com preço válido
380
+ const withPrice = topMatches.filter(m => m.unitCost > 0);
381
+ if (withPrice.length === 0) {
382
+ return {
383
+ currentPrice,
384
+ matches: [],
385
+ statistics: {
386
+ minPrice: 0,
387
+ maxPrice: 0,
388
+ avgPrice: 0,
389
+ medianPrice: 0,
390
+ recommendation: 'Não foram encontrados itens similares com preço para comparação.'
391
+ }
392
+ };
393
+ }
394
+ // Calcula diferença de preço para cada match
395
+ const matchesWithDiff = withPrice.map(m => ({
396
+ ...m,
397
+ priceDiff: m.unitCost - currentPrice,
398
+ priceDiffPercent: currentPrice > 0 ? Math.round(((m.unitCost - currentPrice) / currentPrice) * 100) : 0
399
+ }));
400
+ // Estatísticas
401
+ const prices = withPrice.map(m => m.unitCost).sort((a, b) => a - b);
402
+ const minPrice = prices[0];
403
+ const maxPrice = prices[prices.length - 1];
404
+ const avgPrice = prices.reduce((a, b) => a + b, 0) / prices.length;
405
+ const medianPrice = prices.length % 2 === 0
406
+ ? (prices[prices.length / 2 - 1] + prices[prices.length / 2]) / 2
407
+ : prices[Math.floor(prices.length / 2)];
408
+ // Recomendação
409
+ let recommendation = '';
410
+ const diffFromAvg = ((currentPrice - avgPrice) / avgPrice) * 100;
411
+ if (currentPrice < minPrice * 0.8) {
412
+ recommendation = `⚠️ Preço muito abaixo do mercado (${Math.abs(Math.round(diffFromAvg))}% abaixo da média). Verifique se está correto.`;
413
+ }
414
+ else if (currentPrice > maxPrice * 1.2) {
415
+ recommendation = `⚠️ Preço muito acima do mercado (${Math.round(diffFromAvg)}% acima da média). Considere revisar.`;
416
+ }
417
+ else if (Math.abs(diffFromAvg) <= 10) {
418
+ recommendation = `✅ Preço dentro da média de mercado.`;
419
+ }
420
+ else if (diffFromAvg > 0) {
421
+ recommendation = `📊 Preço ${Math.round(diffFromAvg)}% acima da média, mas dentro da faixa de mercado.`;
422
+ }
423
+ else {
424
+ recommendation = `📊 Preço ${Math.abs(Math.round(diffFromAvg))}% abaixo da média, mas dentro da faixa de mercado.`;
425
+ }
426
+ return {
427
+ currentPrice,
428
+ matches: matchesWithDiff,
429
+ statistics: {
430
+ minPrice: Math.round(minPrice * 100) / 100,
431
+ maxPrice: Math.round(maxPrice * 100) / 100,
432
+ avgPrice: Math.round(avgPrice * 100) / 100,
433
+ medianPrice: Math.round(medianPrice * 100) / 100,
434
+ recommendation
435
+ }
436
+ };
437
+ }