Skip to main content
Glama
matcher.ts5.82 kB
import { ParsedRFP, HistoricalQuote, QuoteMatch } from './types'; import { normalizeRFP } from './parser'; import config from './config'; /** * Find similar historical quotes using rule-based similarity scoring */ export function findSimilarQuotes( parsedRfp: ParsedRFP, historicalQuotes: HistoricalQuote[] ): QuoteMatch[] { const normalized = normalizeRFP(parsedRfp); const matches: QuoteMatch[] = historicalQuotes.map(quote => { let score = 0; const matchingFields: string[] = []; const notes: string[] = []; // Material matching (weight: 0.35) if (quote.normalized.material && normalized.material) { const matSimilarity = calculateMaterialSimilarity( normalized.material, quote.normalized.material ); score += matSimilarity * 0.35; if (matSimilarity > 0.8) { matchingFields.push('material'); notes.push(`Material match: ${quote.normalized.material}`); } } // Process matching (weight: 0.30) const processOverlap = normalized.processes.filter(p => quote.normalized.processes.some(qp => qp.toLowerCase().includes(p) || p.includes(qp.toLowerCase()) ) ); if (normalized.processes.length > 0 && quote.normalized.processes.length > 0) { const processScore = processOverlap.length / Math.max(normalized.processes.length, quote.normalized.processes.length); score += processScore * 0.30; if (processScore > 0.5) { matchingFields.push('processes'); notes.push(`Process overlap: ${processOverlap.join(', ')}`); } } // Quantity range matching (weight: 0.20) const [rfpMin, rfpMax] = normalized.qtyRange; const [qMin, qMax] = quote.normalized.qtyRange; // Check if quantities overlap or are adjacent ranges if ((rfpMin <= qMax && rfpMax >= qMin) || Math.abs(rfpMin - qMax) <= 50 || Math.abs(rfpMax - qMin) <= 50) { score += 0.20; matchingFields.push('quantity'); notes.push(`Qty range: ${qMin}-${qMax} (RFP: ${parsedRfp.qty})`); } else { // Partial credit for same order of magnitude const rfpMid = (rfpMin + rfpMax) / 2; const qMid = (qMin + qMax) / 2; const ratio = Math.min(rfpMid, qMid) / Math.max(rfpMid, qMid); if (ratio > 0.3) { score += ratio * 0.10; } } // Tolerance matching (weight: 0.10) if (normalized.tolerances && quote.normalized.tolerances) { if (normalized.tolerances.includes(quote.normalized.tolerances) || quote.normalized.tolerances.includes(normalized.tolerances)) { score += 0.10; matchingFields.push('tolerances'); notes.push(`Tolerance: ${quote.normalized.tolerances}`); } } // Finish matching (weight: 0.05) if (normalized.finish && quote.normalized.finish) { if (normalized.finish.includes(quote.normalized.finish) || quote.normalized.finish.includes(normalized.finish)) { score += 0.05; matchingFields.push('finish'); notes.push(`Finish: ${quote.normalized.finish}`); } } return { quote, score: Math.min(1.0, score), // Cap at 1.0 matchingFields, notes: notes.join('; '), }; }) .filter(m => m.score > 0.3) // Only return matches with some similarity .sort((a, b) => b.score - a.score); return matches; } /** * Calculate material similarity score */ function calculateMaterialSimilarity(mat1: string, mat2: string): number { // Exact match if (mat1 === mat2) return 1.0; // Normalize material names const normalize = (m: string) => m .replace(/[-\s]/g, '') .replace(/stainless\s*steel/i, 'ss') .toLowerCase(); const n1 = normalize(mat1); const n2 = normalize(mat2); if (n1 === n2) return 1.0; // Check if one contains the other if (n1.includes(n2) || n2.includes(n1)) return 0.9; // Same material family const families = [ ['6061', '6063', '7075', 'aluminum', 'aluminium'], ['304', '316', '430', 'stainless', 'ss'], ['steel', 'mild steel', 'carbon steel'], ['titanium', 'ti'], ['brass', 'bronze'], ]; for (const family of families) { const in1 = family.some(f => n1.includes(f)); const in2 = family.some(f => n2.includes(f)); if (in1 && in2) return 0.7; } return 0.0; } /** * Get confidence level based on top match score */ export function getMatchConfidence(topScore: number): 'low' | 'medium' | 'high' { if (topScore >= config.similarityThresholds.high) return 'high'; if (topScore >= config.similarityThresholds.medium) return 'medium'; return 'low'; } /** * Format matches for display to engineer */ export function formatMatchesForReview(matches: QuoteMatch[]): string { if (matches.length === 0) { return 'No similar historical quotes found. This appears to be a new type of request.'; } let output = `Found ${matches.length} similar historical quote(s):\n\n`; matches.slice(0, 5).forEach((match, idx) => { output += `${idx + 1}. Quote ${match.quote.id} (${(match.score * 100).toFixed(1)}% match)\n`; output += ` Customer: ${match.quote.customerName || 'N/A'}\n`; output += ` Date: ${match.quote.quoteDate}\n`; output += ` Cost: $${match.quote.costPerUnit.toFixed(2)}/unit ($${match.quote.totalCost.toFixed(2)} total)\n`; output += ` Lead time: ${match.quote.leadDays} days`; if (match.quote.actualLeadDays) { output += ` (actual: ${match.quote.actualLeadDays} days)`; } output += `\n Matching: ${match.matchingFields.join(', ')}\n`; output += ` Notes: ${match.notes}\n`; if (match.quote.notes) { output += ` Historical notes: ${match.quote.notes}\n`; } output += '\n'; }); return output; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/r-long/mcp-quoting-system'

If you have feedback or need assistance with the MCP directory API, please join our Discord server