/** * Deterministic Fact Extractor (Layer 1) * * Zero-LLM dependency. Extracts all quantitative and structural facts * from contract clause text using regex + normalization + canonicalization. * * Produces a FactTable: an array of structured facts, each tagged with * its source clause index, semantic category, and canonical value. */ // ── Word-to-Number Map ────────────────────────────────────────────────────── const WORD_NUMBERS = { zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20, thirty: 30, forty: 40, fifty: 50, sixty: 60, seventy: 70, eighty: 80, ninety: 90, hundred: 100, thousand: 1000, million: 1000000, billion: 1000000000, lakh: 100000, crore: 10000000, }; const WORD_NUMBER_PATTERN = new RegExp( '\\b(' + Object.keys(WORD_NUMBERS).join('|') + ')(?:[- ](?:' + Object.keys(WORD_NUMBERS).join('|') + '))*\\b', 'gi' ); function parseWordNumber(phrase) { const words = phrase.toLowerCase().split(/[- ]+/); let total = 0; let current = 0; for (const w of words) { const val = WORD_NUMBERS[w]; if (val === undefined) continue; if (val === 100) { current = (current === 0 ? 1 : current) * 100; } else if (val >= 1000) { current = (current === 0 ? 1 : current) * val; total += current; current = 0; } else { current += val; } } return total + current; } // ── Duration Unit Normalization ───────────────────────────────────────────── const DURATION_UNITS = { day: 'days', days: 'days', 'calendar day': 'days', 'calendar days': 'days', 'business day': 'business_days', 'business days': 'business_days', 'working day': 'business_days', 'working days': 'business_days', week: 'weeks', weeks: 'weeks', month: 'months', months: 'months', year: 'years', years: 'years', hour: 'hours', hours: 'hours', }; // Convert all durations to a common unit (days) for comparison function durationToDays(value, unit) { switch (unit) { case 'hours': return value / 24; case 'days': return value; case 'business_days': return value * 1.4; // ~7/5 ratio case 'weeks': return value * 7; case 'months': return value * 30; case 'years': return value * 365; default: return value; } } // ── Semantic Category Detection ───────────────────────────────────────────── const CATEGORY_PATTERNS = [ { category: 'total_fee', patterns: [/total\s+(?:contract\s+)?(?:value|fee|price|amount|consideration)/i, /(?:aggregate|fixed)\s+(?:fee|price|value|consideration)/i, /contract\s+(?:value|price)/i, /(?:annual|yearly|total)\s+(?:subscription\s+)?fee/i] }, { category: 'milestone_fee', patterns: [/milestone/i, /(?:phase|stage|tranche)\s+\d/i, /payment\s+schedule/i, /installment/i, /(?:monthly|quarterly|bi-weekly|weekly)\s+(?:installment|payment)/i] }, { category: 'acceptance_period', patterns: [/acceptance\s+(?:period|window|deadline)/i, /(?:review|testing|inspection)\s+period/i, /(?:review|test|inspect)\s+(?:the\s+)?deliverable/i, /(?:review|test)\s+(?:and\s+(?:review|test)\s+)?(?:all\s+)?deliverable/i, /(?:review|test|inspect|validate)\s+.*?deliverable/i] }, { category: 'deemed_acceptance', patterns: [/deemed\s+(?:accepted|acceptance)/i, /automatically\s+(?:accepted|deemed)/i, /(?:shall\s+be\s+)?deemed\s+to\s+(?:have\s+been\s+)?accept/i] }, { category: 'notice_period', patterns: [/(?:notice|notification)\s+(?:period|deadline|window)/i, /(?:within|no\s+later\s+than)\s+.*?(?:notify|report|inform)/i, /(?:notify|report|inform)\s+.*?(?:within|no\s+later)/i] }, { category: 'security_notice', patterns: [/(?:security|breach|incident)\s+(?:notification|notice|report)/i, /(?:notify|report)\s+.*?(?:security|breach|incident)/i, /security\s+(?:breach|incident)/i, /(?:security|breach).*?(?:within|no\s+later)/i] }, { category: 'retention_period', patterns: [/(?:record|data)\s+retention/i, /(?:retain|maintain|preserve)\s+.*?(?:records|data|documents|confidentiality|confidential\s+information)/i, /retention\s+period/i, /records?\s+for\s+(?:a\s+)?(?:minimum\s+)?(?:period|duration)/i, /(?:confidential|confidentiality)\s+.*?(?:period|years?|months?)/i, /(?:records?|data|documents?|logs?)\s+(?:shall\s+be\s+|must\s+be\s+|are\s+)?(?:retained|maintained|preserved|kept)/i, /(?:retained|maintained|preserved|kept)\s+for\s+(?:a\s+)?(?:minimum\s+)?(?:period|duration)?\s*(?:of\s+)?\d/i] }, { category: 'liability_cap', patterns: [/(?:aggregate|total|cumulative)\s+liability/i, /liability\s+(?:shall\s+)?(?:not\s+)?exceed/i, /(?:cap|limit|ceiling)\s+(?:on\s+)?(?:liability|damages)/i] }, { category: 'termination', patterns: [/(?:upon|after|following)\s+termination/i, /terminat(?:e|ion)\s+(?:for\s+)?(?:convenience|cause)/i] }, { category: 'refund', patterns: [/refund\s+(?:all|entire|full)/i, /(?:return|repay)\s+(?:all\s+)?(?:fees|payments|amounts)/i] }, { category: 'payment_on_term', patterns: [/(?:pay|compensate)\s+.*?(?:services?\s+(?:performed|rendered|completed))/i, /(?:payment|compensation)\s+for\s+(?:work|services)/i] }, { category: 'contract_term', patterns: [/(?:initial|contract)\s+term/i, /term\s+of\s+(?:this\s+)?agreement/i, /(?:duration|period)\s+of\s+(?:this\s+)?(?:agreement|contract)/i] }, { category: 'auto_renewal', patterns: [/auto(?:matically)?[- ]?renew/i, /(?:successive|additional)\s+(?:period|term)/i, /(?:renew|extend)\s+(?:automatically|for\s+(?:successive|additional))/i] }, { category: 'jurisdiction', patterns: [/(?:exclusive\s+)?jurisdiction/i, /(?:governed?\s+by\s+(?:the\s+)?laws?\s+of)/i, /courts?\s+(?:of|located\s+in|sitting\s+in)/i] }, { category: 'interest_rate', patterns: [/interest\s+(?:rate|charge|payment|accru)/i, /(?:late\s+payments?|default|penalty)\s+interest/i, /interest\s+at\s+(?:the\s+)?(?:rate\s+(?:of\s+)?)?\d/i, /accru\w*\s+interest/i, /(?:interest|penalty)\s+rate\b/i, /\b(?:rate\s+of\s+)\d+(?:\.\d+)?\s*%/i, /\binterest\b.*?\baccru\w*\b.*?\bat\b.*?\d+(?:\.\d+)?\s*%/i, /\binterest\b.*?\bat\b.*?\d+(?:\.\d+)?\s*%\s*(?:per\s+annum|p\.?a\.?)?/i] }, { category: 'completion_date', patterns: [/(?:project|work)\s+completion\s+(?:date|deadline)/i, /complet(?:e|ion)\s+(?:by|on|before|no\s+later)/i] }, { category: 'delivery_date', patterns: [/(?:final\s+)?deliver(?:y|able)\s+(?:date|deadline)/i, /deliver(?:y|ables?)\s+(?:by|on|before|no\s+later)/i] }, { category: 'precedence', patterns: [/(?:shall\s+)?(?:prevail|take\s+precedence|supersede|control)/i, /(?:in\s+(?:the\s+)?event\s+of\s+(?:a\s+)?)?conflict/i] }, ]; function detectCategory(text, clauseIndex) { const lower = text.toLowerCase(); const matched = []; for (const cp of CATEGORY_PATTERNS) { for (const pat of cp.patterns) { if (pat.test(lower)) { matched.push(cp.category); break; } } } return matched.length > 0 ? matched : ['uncategorized']; } // ── Currency Extraction ───────────────────────────────────────────────────── const CURRENCY_PATTERNS = [ // $500,000 or USD 500,000 or US$ 500,000 /(?:\$|USD\s*|US\$\s*)([0-9]{1,3}(?:,?[0-9]{3})*(?:\.[0-9]{1,2})?)/gi, // INR 5,00,000 or ₹5,00,000 or ₹1,00,00,000 (Indian notation, supports up to crore level) /(?:INR\s*|₹\s*)([0-9]{1,3}(?:,?[0-9]{2,3})*(?:,?[0-9]{3})(?:\.[0-9]{1,2})?)/gi, // 500k, 1.5M, 2.5B (shorthand) /(?:\$|USD\s*|INR\s*|₹\s*)?([0-9]+(?:\.[0-9]+)?)\s*([kKmMbB])\b/gi, // Spelled-out: "Five Hundred Thousand Dollars" // Handled separately via word number parser ]; function extractCurrencyFacts(text, clauseIndex, clauseContext) { const facts = []; const matchedPositions = new Set(); // Track positions to avoid double-counting // Pattern 1: $500,000 / USD 500,000 let match; const pat1 = /(?:\$|USD\s*|US\$\s*)([0-9]{1,3}(?:,?[0-9]{3})*(?:\.[0-9]{1,2})?)/gi; while ((match = pat1.exec(text)) !== null) { const raw = match[0]; const value = Number(match[1].replace(/,/g, '')); if (!isNaN(value) && value > 0) { matchedPositions.add(match.index); facts.push({ type: 'currency', value, unit: 'USD', raw, clauseIndex }); } } // Pattern 2: INR / ₹ (Indian) — with explicit prefix // Supports lakh (10,00,000), crore (1,00,00,000), and higher values. // {1,3} initial digits handles 1-crore = "1,00,00,000" (1 digit initial) const pat2 = /(?:INR\s*|₹\s*)([0-9]{1,3}(?:,?[0-9]{2,3})*(?:,?[0-9]{3})(?:\.[0-9]{1,2})?)/gi; while ((match = pat2.exec(text)) !== null) { const raw = match[0]; const value = Number(match[1].replace(/,/g, '')); if (!isNaN(value) && value > 0) { matchedPositions.add(match.index); facts.push({ type: 'currency', value, unit: 'INR', raw, clauseIndex }); } } // Pattern 3: Shorthand (500k, 1.5M) const pat3 = /(?:\$|USD\s*|INR\s*|₹\s*)?([0-9]+(?:\.[0-9]+)?)\s*([kKmMbB])\b/gi; while ((match = pat3.exec(text)) !== null) { const raw = match[0]; let value = Number(match[1].replace(/,/g, '')); const suffix = match[2].toLowerCase(); if (suffix === 'k') value *= 1000; else if (suffix === 'm') value *= 1000000; else if (suffix === 'b') value *= 1000000000; if (!isNaN(value) && value > 0) { matchedPositions.add(match.index); const unit = raw.includes('INR') || raw.includes('₹') ? 'INR' : 'USD'; facts.push({ type: 'currency', value, unit, raw, clauseIndex }); } } // Pattern 4: Bare Indian-format numbers WITHOUT INR/₹ prefix // Matches the distinctive XX,XX,XXX comma grouping (e.g. "10,00,000", "1,50,000", "1,00,00,000") // The Indian notation uses groups of 2 after the thousands place, which is // structurally unique and won't false-positive on Western "1,000,000". // Guard: only activate if the broader CLAUSE context mentions INR/₹/Rupees/Lakh/Crore // Use clauseContext (full clause text) rather than just the sentence text const hasIndianContext = /(?:INR|₹|rupee|lakh|crore)/i.test(clauseContext || text); if (hasIndianContext) { // Allow up to crore-level (1,00,00,000): {1,3} initial digits, repeating 2-3 digit groups const pat4 = /\b([0-9]{1,3},(?:[0-9]{2,3},)*[0-9]{3})(?:\.[0-9]{1,2})?\b/g; while ((match = pat4.exec(text)) !== null) { // Skip if this position was already captured by Pattern 2 (INR prefix) // Check if any earlier pattern matched at a position that contains this match let alreadyCaptured = false; for (const pos of matchedPositions) { if (pos <= match.index && match.index < pos + 30) { alreadyCaptured = true; break; } } if (alreadyCaptured) continue; const raw = match[0]; const value = Number(match[1].replace(/,/g, '')); // Guard: must be at least 1,000 (i.e. "1,000" minimum) to avoid matching dates/codes if (!isNaN(value) && value >= 1000) { facts.push({ type: 'currency', value, unit: 'INR', raw, clauseIndex }); } } } return facts; } // ── Duration Extraction ───────────────────────────────────────────────────── function extractDurationFacts(text, clauseIndex) { const facts = []; // Numeric durations: "15 days", "72 hours", "3 years", "24 months" const numDurPattern = /(\d+)\s*(calendar\s+days?|business\s+days?|working\s+days?|days?|hours?|weeks?|months?|years?)\b/gi; let match; while ((match = numDurPattern.exec(text)) !== null) { const value = parseInt(match[1], 10); const rawUnit = match[2].toLowerCase().replace(/\s+/g, ' '); const unit = DURATION_UNITS[rawUnit] || DURATION_UNITS[rawUnit.replace(/s$/, '')] || 'days'; const days = durationToDays(value, unit); facts.push({ type: 'duration', value, unit, days, raw: match[0], clauseIndex, }); } // Word-number durations: "fifteen days", "seventy-two hours" const wordDurPattern = new RegExp( '(' + Object.keys(WORD_NUMBERS).join('|') + ')(?:[- ](?:' + Object.keys(WORD_NUMBERS).join('|') + '))*' + '\\s+(calendar\\s+days?|business\\s+days?|working\\s+days?|days?|hours?|weeks?|months?|years?)', 'gi' ); while ((match = wordDurPattern.exec(text)) !== null) { const unitPart = match[0].match(/(calendar\s+days?|business\s+days?|working\s+days?|days?|hours?|weeks?|months?|years?)$/i); if (!unitPart) continue; const numberPart = match[0].slice(0, match[0].length - unitPart[0].length).trim(); const value = parseWordNumber(numberPart); const rawUnit = unitPart[1].toLowerCase().replace(/\s+/g, ' '); const unit = DURATION_UNITS[rawUnit] || DURATION_UNITS[rawUnit.replace(/s$/, '')] || 'days'; const days = durationToDays(value, unit); if (value > 0) { facts.push({ type: 'duration', value, unit, days, raw: match[0], clauseIndex, }); } } return facts; } // ── Percentage Extraction ─────────────────────────────────────────────────── function extractPercentageFacts(text, clauseIndex) { const facts = []; const pctPattern = /(\d+(?:\.\d+)?)\s*%/g; let match; while ((match = pctPattern.exec(text)) !== null) { const value = parseFloat(match[1]); facts.push({ type: 'percentage', value, raw: match[0], clauseIndex }); } return facts; } // ── Date Extraction ───────────────────────────────────────────────────────── function extractDateFacts(text, clauseIndex) { const facts = []; // "March 31, 2025" / "31 March 2025" / "2025-03-31" / "03/31/2025" const datePatterns = [ /\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),?\s+(\d{4})\b/gi, /\b(\d{1,2})\s+(January|February|March|April|May|June|July|August|September|October|November|December),?\s+(\d{4})\b/gi, /\b(\d{4})-(\d{2})-(\d{2})\b/g, ]; for (const pat of datePatterns) { let match; while ((match = pat.exec(text)) !== null) { facts.push({ type: 'date', raw: match[0], clauseIndex }); } } return facts; } // ── Cross-Reference Extraction ────────────────────────────────────────────── function extractCrossRefFacts(text, clauseIndex) { const facts = []; // "Section 4.2", "Clause 11", "Article III", "Schedule A", "Exhibit B" const refPattern = /\b(Section|Clause|Article|Schedule|Exhibit|Appendix|Annex)\s+([A-Z0-9][A-Z0-9.()]*)\b/gi; let match; while ((match = refPattern.exec(text)) !== null) { facts.push({ type: 'cross_ref', refType: match[1].toLowerCase(), refTarget: match[2], raw: match[0], clauseIndex, }); } return facts; } // ── Jurisdiction Extraction ───────────────────────────────────────────────── const JURISDICTION_KEYWORDS = [ 'new york', 'california', 'delaware', 'texas', 'illinois', 'florida', 'massachusetts', 'washington', 'singapore', 'london', 'england', 'mumbai', 'delhi', 'bangalore', 'bengaluru', 'chennai', 'kolkata', 'hyderabad', 'port blair', 'india', 'united states', 'united kingdom', ]; function extractJurisdictionFacts(text, clauseIndex) { const lower = text.toLowerCase(); const facts = []; // Only extract if the clause discusses jurisdiction/governing law if (!/(?:jurisdiction|governed?\s+by|courts?\s+(?:of|located|sitting)|exclusive)/i.test(text)) { return facts; } for (const jur of JURISDICTION_KEYWORDS) { if (lower.includes(jur)) { facts.push({ type: 'jurisdiction', value: jur, raw: jur, clauseIndex, }); } } return facts; } // ── Semantic Fact Extraction (for ownership, precedence, termination) ─────── function extractSemanticFacts(text, clauseIndex) { const lower = text.toLowerCase(); const facts = []; // Ownership claims: "Client owns", "Vendor retains ownership", "assigns all rights" // Party lists include: Client/Customer/Buyer/Company/Employer/Licensor/Licensee const clientPartyPattern = /(?:client|customer|buyer|company|employer|licensee)/i; const vendorPartyPattern = /(?:vendor|provider|supplier|contractor|employee|licensor)/i; const ownershipPatterns = [ { pattern: new RegExp(clientPartyPattern.source + '\\s+(?:shall\\s+)?(?:own|retain|have)\\s+(?:all\\s+)?(?:right|title|ownership|interest)', 'i'), party: 'client', action: 'owns' }, { pattern: new RegExp(vendorPartyPattern.source + '\\s+(?:shall\\s+)?(?:own|retain|have)\\s+(?:all\\s+)?(?:right|title|ownership|interest)', 'i'), party: 'vendor', action: 'owns' }, { pattern: new RegExp('(?:assign|transfer|convey)\\s+(?:all\\s+)?(?:right|title|ownership|interest)\\s+(?:to|in)\\s+(?:the\\s+)?' + clientPartyPattern.source, 'i'), party: 'client', action: 'assigned_to' }, { pattern: new RegExp('(?:assign|transfer|convey)\\s+(?:all\\s+)?(?:right|title|ownership|interest)\\s+(?:to|in)\\s+(?:the\\s+)?' + vendorPartyPattern.source, 'i'), party: 'vendor', action: 'assigned_to' }, // Broader: "assigned to and owned exclusively by the Client" { pattern: new RegExp('(?:assigned|belong|vest)\\s+.*?(?:owned|exclusively)\\s+.*?' + clientPartyPattern.source, 'i'), party: 'client', action: 'assigned_to' }, { pattern: new RegExp('(?:assigned|belong|vest)\\s+.*?(?:owned|exclusively)\\s+.*?' + vendorPartyPattern.source, 'i'), party: 'vendor', action: 'assigned_to' }, // "Vendor retains..." { pattern: new RegExp(vendorPartyPattern.source + '\\s+retain', 'i'), party: 'vendor', action: 'owns' }, { pattern: new RegExp(clientPartyPattern.source + '\\s+retain', 'i'), party: 'client', action: 'owns' }, // "property of the Company/Client" { pattern: new RegExp('(?:property|owned)\\s+(?:of|by)\\s+(?:the\\s+)?' + clientPartyPattern.source, 'i'), party: 'client', action: 'owns' }, { pattern: new RegExp('(?:property|owned)\\s+(?:of|by)\\s+(?:the\\s+)?' + vendorPartyPattern.source, 'i'), party: 'vendor', action: 'owns' }, ]; for (const op of ownershipPatterns) { if (op.pattern.test(text)) { // Determine the subject — but guard against negated mentions. // "that are not Deliverables" should NOT classify subject as deliverables. const hasDeliverable = /deliverable/i.test(lower); const isNegatedDeliverable = /(?:not|excluding|other\s+than)\s+deliverables?/i.test(lower); const isScopedToPreExisting = /pre-existing|underlying|background\s+ip|prior\s+work|personal\s+time|personal\s+resources|outside\s+(?:of\s+)?(?:working|business)\s+hours/i.test(lower); let subject = 'general'; if (hasDeliverable && !isNegatedDeliverable && !isScopedToPreExisting) { subject = 'deliverables'; } else if (/intellectual\s+property/i.test(lower) && !isScopedToPreExisting) { subject = 'ip'; } else if (/(?:invention|work\s+product|customization|derivative\s+work)/i.test(lower) && !isScopedToPreExisting) { subject = 'deliverables'; // inventions/work product treated same as deliverables } facts.push({ type: 'ownership', party: op.party, action: op.action, subject, raw: text.substring(0, 120), clauseIndex, }); } } // Precedence claims: "Schedule A prevails", "Agreement shall prevail", "SOW prevails", "MSA prevails" const precedencePattern = /\b(schedule\s+[a-z]|(?:this\s+)?(?:agreement|contract)|agreement|contract|exhibit\s+[a-z]|appendix\s+[a-z]|(?:this\s+)?sow|(?:this\s+)?(?:statement\s+of\s+work)|(?:the\s+)?(?:master\s+service\s+agreement)|(?:the\s+)?msa)\s+(?:shall\s+)?(?:prevail|take\s+precedence|supersede|control|override)\b/gi; let match; while ((match = precedencePattern.exec(text)) !== null) { facts.push({ type: 'precedence', document: match[1].trim().toLowerCase(), raw: match[0], clauseIndex, }); } // Termination payment semantics if (/refund\s+(?:all|entire|full|100%)/i.test(text) && /(?:fee|payment|amount)/i.test(text)) { facts.push({ type: 'termination_payment', action: 'refund_all', raw: text.substring(0, 120), clauseIndex }); } if (/(?:pay|compensate|reimburse)\s+.*?(?:services?\s+(?:performed|rendered|completed)|work\s+(?:performed|completed|done))/i.test(text)) { facts.push({ type: 'termination_payment', action: 'pay_for_services', raw: text.substring(0, 120), clauseIndex }); } // Auto-renewal detection if (/auto(?:matically)?[- ]?renew/i.test(text)) { facts.push({ type: 'auto_renewal', value: true, raw: text.substring(0, 120), clauseIndex }); } // Fixed end date detection if (/(?:shall\s+)?(?:expire|terminate|end)\s+(?:on|in|after|during|at|within)\s+/i.test(text) || /(?:fixed|definite)\s+(?:end|expiry|termination)\s+date/i.test(text)) { facts.push({ type: 'fixed_end_date', value: true, raw: text.substring(0, 120), clauseIndex }); } return facts; } // ── Main Extraction Orchestrator ──────────────────────────────────────────── /** * Extract all quantitative and structural facts from an array of clauses. * * @param {{ segmentIndex: number, rawText: string }[]} clauses * @returns {{ facts: Array, categorizedFacts: Object }} */ function extractFactTable(clauses) { const allFacts = []; for (const clause of clauses) { const idx = clause.segmentIndex; const text = clause.rawText || ''; const clauseCategories = detectCategory(text, idx); // Split clause text into sentences to localize fact categorization using lookbehind to protect decimals const sentences = text.split(/(?<=[.!?])\s+/); for (const sentenceText of sentences) { const sentenceCategories = detectCategory(sentenceText, idx); const categoriesToUse = (sentenceCategories.length === 1 && sentenceCategories[0] === 'uncategorized') ? clauseCategories : sentenceCategories; const sentenceFacts = [ ...extractCurrencyFacts(sentenceText, idx, text), ...extractDurationFacts(sentenceText, idx), ...extractPercentageFacts(sentenceText, idx), ...extractDateFacts(sentenceText, idx), ...extractCrossRefFacts(sentenceText, idx), ...extractJurisdictionFacts(sentenceText, idx), ...extractSemanticFacts(sentenceText, idx), ]; // Tag each fact with its localized categories for (const fact of sentenceFacts) { fact.categories = categoriesToUse; } allFacts.push(...sentenceFacts); } } // Group by type for easier rule evaluation const categorizedFacts = {}; for (const fact of allFacts) { if (!categorizedFacts[fact.type]) categorizedFacts[fact.type] = []; categorizedFacts[fact.type].push(fact); } return { facts: allFacts, categorizedFacts }; } module.exports = { extractFactTable, durationToDays, parseWordNumber };