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;
}