Spaces:
Sleeping
Sleeping
| /** | |
| * Cross-Clause Contradiction Engine (Layer 2 + Layer 3) | |
| * | |
| * Layer 2: Deterministic rules for quantitative contradictions (zero LLM) | |
| * Layer 3: Semantic pattern matching for qualitative contradictions (zero LLM) | |
| * | |
| * Takes a FactTable from deterministicFactExtractor and runs contradiction | |
| * rules across all clause pairs. | |
| */ | |
| const { durationToDays } = require('./deterministicFactExtractor'); | |
| // ── Severity Defaults (per user-approved tiering) ────────────────────────── | |
| const SEVERITY = { | |
| FEE_SUM_MISMATCH: 'critical', | |
| PAYMENT_INCONSISTENCY: 'critical', | |
| LIABILITY_CONTRADICTION: 'critical', | |
| OWNERSHIP_CONTRADICTION: 'critical', | |
| JURISDICTION_CONFLICT: 'high', | |
| DATE_CONFLICT: 'high', | |
| ACCEPTANCE_WINDOW: 'high', | |
| NOTICE_CONFLICT: 'medium', | |
| RETENTION_CONFLICT: 'high', | |
| RECORDKEEPING_CONFLICT: 'high', | |
| DURATION_CONFLICT: 'high', | |
| AUTO_RENEWAL_VS_FIXED: 'high', | |
| PRECEDENCE_CONFLICT: 'high', | |
| TERMINATION_PAYMENT: 'critical', | |
| INTEREST_CONTRADICTION: 'high', | |
| }; | |
| /** | |
| * Run all contradiction rules against the extracted FactTable. | |
| * | |
| * @param {{ facts: Array, categorizedFacts: Object }} factTable | |
| * @param {{ segmentIndex: number, rawText: string }[]} clauses - Original clauses for context | |
| * @returns {{ findings: Array }} | |
| */ | |
| function detectContradictions(factTable, clauses) { | |
| const findings = []; | |
| const { facts, categorizedFacts } = factTable; | |
| // ════════════════════════════════════════════════════════════════════════════ | |
| // LAYER 2: DETERMINISTIC QUANTITATIVE RULES | |
| // ════════════════════════════════════════════════════════════════════════════ | |
| // ── Rule 1: FEE_SUM_MISMATCH ───────────────────────────────────────────── | |
| // If we find a "total fee" currency value and multiple "milestone" currency | |
| // values, check that milestones sum to the total. | |
| const totalFeeFacts = facts.filter(f => | |
| f.type === 'currency' && f.categories?.some(c => c === 'total_fee') | |
| ); | |
| const milestoneFacts = facts.filter(f => | |
| f.type === 'currency' && f.categories?.some(c => c === 'milestone_fee') | |
| ); | |
| if (totalFeeFacts.length > 0 && milestoneFacts.length >= 1) { | |
| const totalFee = totalFeeFacts[0].value; | |
| // Exclude the total fee itself from the milestone list (it may share the clause). | |
| // Guard: require ALL THREE conditions to match to avoid excluding legitimate | |
| // milestone amounts that coincidentally equal the total value but are in different | |
| // clauses or have different raw text (e.g. two separate INR 50,00,000 references). | |
| const actualMilestones = milestoneFacts.filter(f => | |
| !(f.clauseIndex === totalFeeFacts[0].clauseIndex && | |
| f.value === totalFee && | |
| f.raw === totalFeeFacts[0].raw) | |
| ); | |
| if (actualMilestones.length >= 1) { | |
| let milestoneSum = actualMilestones.reduce((sum, f) => sum + f.value, 0); | |
| if (Math.abs(milestoneSum - totalFee) > 0.01) { | |
| // Additional check: see if the milestones are periodic installments. | |
| // If so, multiply by the appropriate period (12 for monthly, 4 for quarterly). | |
| let adjustedSum = 0; | |
| for (const f of actualMilestones) { | |
| const clause = clauses.find(c => c.segmentIndex === f.clauseIndex); | |
| let multiplier = 1; | |
| if (clause) { | |
| const text = clause.rawText.toLowerCase(); | |
| if (/monthly/i.test(text)) { | |
| multiplier = 12; | |
| } else if (/quarterly/i.test(text)) { | |
| multiplier = 4; | |
| } else if (/bi-weekly/i.test(text)) { | |
| multiplier = 26; | |
| } | |
| } | |
| adjustedSum += f.value * multiplier; | |
| } | |
| if (Math.abs(adjustedSum - totalFee) > 0.01) { | |
| const currUnit = totalFeeFacts[0].unit || 'USD'; | |
| findings.push({ | |
| rule: 'FEE_SUM_MISMATCH', | |
| severity: SEVERITY.FEE_SUM_MISMATCH, | |
| title: 'Contract Value vs Milestone Payment Mismatch', | |
| reason: `The stated total contract value is ${formatCurrency(totalFee, currUnit)} (Clause ${totalFeeFacts[0].clauseIndex + 1}), but the milestone payments sum to ${formatCurrency(adjustedSum, currUnit)} (${actualMilestones.map(f => formatCurrency(f.value, currUnit)).join(' + ')}). This is a ${formatCurrency(Math.abs(adjustedSum - totalFee), currUnit)} discrepancy.`, | |
| clauseRefs: [...new Set([totalFeeFacts[0].clauseIndex, ...actualMilestones.map(f => f.clauseIndex)])], | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| // ── Rule 1b: Fee Sum Mismatch (Broad) ──────────────────────────────────── | |
| // Even without explicit category tagging, look for fee patterns in the text. | |
| // Also covers the gap where Rule 1 found categorized facts but actualMilestones | |
| // was empty after the exclusion filter (e.g. only 1 milestone that matched total). | |
| // Guard: only run if total and milestone are in DIFFERENT clauses. | |
| const rule1Fired = findings.some(f => f.rule === 'FEE_SUM_MISMATCH'); | |
| if (!rule1Fired && (totalFeeFacts.length === 0 || milestoneFacts.length === 0)) { | |
| const allCurrencies = categorizedFacts.currency || []; | |
| const potentialTotals = []; | |
| const potentialMilestones = []; | |
| for (const curr of allCurrencies) { | |
| const clause = clauses.find(c => c.segmentIndex === curr.clauseIndex); | |
| if (!clause) continue; | |
| const text = clause.rawText.toLowerCase(); | |
| if (/(?:total|aggregate|contract)\s+(?:value|fee|price|consideration|amount)/i.test(text)) { | |
| potentialTotals.push(curr); | |
| } | |
| if (/(?:milestone|phase|tranche|installment|stage)/i.test(text)) { | |
| potentialMilestones.push(curr); | |
| } | |
| } | |
| // Guard: require milestones from different clauses than the total | |
| const totalClause = potentialTotals.length > 0 ? potentialTotals[0].clauseIndex : -1; | |
| const crossClauseMilestones = potentialMilestones.filter(m => m.clauseIndex !== totalClause); | |
| if (potentialTotals.length > 0 && crossClauseMilestones.length >= 2) { | |
| const totalFee = potentialTotals[0].value; | |
| const milestoneSum = crossClauseMilestones.reduce((sum, f) => sum + f.value, 0); | |
| if (Math.abs(milestoneSum - totalFee) > 0.01) { | |
| const currUnit = potentialTotals[0].unit || 'USD'; | |
| findings.push({ | |
| rule: 'FEE_SUM_MISMATCH', | |
| severity: SEVERITY.FEE_SUM_MISMATCH, | |
| title: 'Contract Value vs Milestone Payment Mismatch', | |
| reason: `The stated total contract value is ${formatCurrency(totalFee, currUnit)} (Clause ${potentialTotals[0].clauseIndex + 1}), but the milestone payments sum to ${formatCurrency(milestoneSum, currUnit)}. This is a ${formatCurrency(Math.abs(milestoneSum - totalFee), currUnit)} discrepancy that must be reconciled.`, | |
| clauseRefs: [potentialTotals[0].clauseIndex, ...new Set(crossClauseMilestones.map(f => f.clauseIndex))], | |
| }); | |
| } | |
| } | |
| } | |
| // ── Rule 2: ACCEPTANCE_WINDOW ───────────────────────────────────────────── | |
| // If review/testing period > deemed acceptance period, it's a logical impossibility. | |
| const acceptanceFacts = facts.filter(f => | |
| f.type === 'duration' && f.categories?.some(c => c === 'acceptance_period') | |
| ); | |
| const deemedFacts = facts.filter(f => | |
| f.type === 'duration' && f.categories?.some(c => c === 'deemed_acceptance') | |
| ); | |
| if (acceptanceFacts.length > 0 && deemedFacts.length > 0) { | |
| for (const acc of acceptanceFacts) { | |
| for (const deemed of deemedFacts) { | |
| if (acc.days > deemed.days) { | |
| findings.push({ | |
| rule: 'ACCEPTANCE_WINDOW', | |
| severity: SEVERITY.ACCEPTANCE_WINDOW, | |
| title: 'Review Period vs Deemed Acceptance Conflict', | |
| reason: `The testing/review period is ${acc.value} ${acc.unit} (Clause ${acc.clauseIndex + 1}), but deliverables are deemed accepted after only ${deemed.value} ${deemed.unit} (Clause ${deemed.clauseIndex + 1}). The Client cannot complete testing before acceptance is forced.`, | |
| clauseRefs: [...new Set([acc.clauseIndex, deemed.clauseIndex])], | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| // ── Rule 3: NOTIFICATION_CONFLICT ───────────────────────────────────────── | |
| // Different notification deadlines for the same type of event | |
| const noticeFacts = facts.filter(f => | |
| f.type === 'duration' && f.categories?.some(c => c === 'security_notice' || c === 'notice_period') | |
| ); | |
| if (noticeFacts.length >= 2) { | |
| const securityNotices = noticeFacts.filter(f => f.categories?.includes('security_notice')); | |
| if (securityNotices.length >= 2) { | |
| const vals = securityNotices.map(f => f.days); | |
| if (Math.max(...vals) / Math.min(...vals) > 1.5) { | |
| findings.push({ | |
| rule: 'NOTICE_CONFLICT', | |
| severity: SEVERITY.NOTICE_CONFLICT, | |
| title: 'Conflicting Security Notification Deadlines', | |
| reason: `Multiple security notification deadlines found: ${securityNotices.map(f => `${f.value} ${f.unit} (Clause ${f.clauseIndex + 1})`).join(' vs ')}. These conflicting requirements create compliance ambiguity.`, | |
| clauseRefs: securityNotices.map(f => f.clauseIndex), | |
| }); | |
| } | |
| } | |
| } | |
| // ── Rule 4: RETENTION_CONFLICT ──────────────────────────────────────────── | |
| const retentionFacts = facts.filter(f => | |
| f.type === 'duration' && f.categories?.some(c => c === 'retention_period') | |
| ); | |
| if (retentionFacts.length >= 2) { | |
| // Helper to extract specific retention subjects | |
| const getRetentionSubject = (text) => { | |
| const lower = text.toLowerCase(); | |
| const isConfidential = /confidential/i.test(lower); | |
| const isFinancial = /(?:financial|tax|audit|accounting|invoice|billing|payment)/i.test(lower); | |
| const isEmail = /(?:email|correspondence|communication|message|logs?|chat)/i.test(lower); | |
| if (isConfidential) return 'confidentiality'; | |
| if (isFinancial) return 'financial'; | |
| if (isEmail) return 'email'; | |
| return 'general'; | |
| }; | |
| let conflictPair = null; | |
| for (let i = 0; i < retentionFacts.length; i++) { | |
| for (let j = i + 1; j < retentionFacts.length; j++) { | |
| const f1 = retentionFacts[i]; | |
| const f2 = retentionFacts[j]; | |
| const ratio = Math.max(f1.days, f2.days) / Math.min(f1.days, f2.days); | |
| if (ratio > 1.5) { | |
| const t1 = clauses[f1.clauseIndex]?.rawText || ''; | |
| const t2 = clauses[f2.clauseIndex]?.rawText || ''; | |
| const sub1 = getRetentionSubject(t1); | |
| const sub2 = getRetentionSubject(t2); | |
| if (sub1 === sub2 || sub1 === 'general' || sub2 === 'general') { | |
| conflictPair = [f1, f2]; | |
| break; | |
| } | |
| } | |
| } | |
| if (conflictPair) break; | |
| } | |
| if (conflictPair) { | |
| findings.push({ | |
| rule: 'RETENTION_CONFLICT', | |
| severity: SEVERITY.RETENTION_CONFLICT, | |
| title: 'Conflicting Record Retention Periods', | |
| reason: `Multiple record retention periods found: ${conflictPair.map(f => `${f.value} ${f.unit} (Clause ${f.clauseIndex + 1})`).join(' vs ')}. These conflicting requirements create compliance uncertainty.`, | |
| clauseRefs: conflictPair.map(f => f.clauseIndex), | |
| }); | |
| } | |
| } | |
| // ── Rule 5: JURISDICTION_CONFLICT ───────────────────────────────────────── | |
| const jurisdictionFacts = categorizedFacts.jurisdiction || []; | |
| if (jurisdictionFacts.length >= 2) { | |
| const uniqueJurisdictions = [...new Set(jurisdictionFacts.map(f => f.value))]; | |
| if (uniqueJurisdictions.length >= 2) { | |
| const clauseIndices = [...new Set(jurisdictionFacts.map(f => f.clauseIndex))]; | |
| findings.push({ | |
| rule: 'JURISDICTION_CONFLICT', | |
| severity: SEVERITY.JURISDICTION_CONFLICT, | |
| title: 'Multiple Exclusive Jurisdictions Claimed', | |
| reason: `The contract references multiple jurisdictions: ${uniqueJurisdictions.join(', ')} across Clauses ${clauseIndices.map(i => i + 1).join(', ')}. If both claim exclusivity, this is an irreconcilable drafting defect.`, | |
| clauseRefs: clauseIndices, | |
| }); | |
| } | |
| } | |
| // ── Rule 6: LIABILITY_CAP_CONFLICT ──────────────────────────────────────── | |
| const liabilityFacts = facts.filter(f => | |
| (f.type === 'currency' || f.type === 'percentage') && | |
| f.categories?.some(c => c === 'liability_cap') | |
| ); | |
| if (liabilityFacts.length >= 2) { | |
| const clauseIndices = [...new Set(liabilityFacts.map(f => f.clauseIndex))]; | |
| const uniqueValues = [...new Set(liabilityFacts.map(f => f.value))]; | |
| if (uniqueValues.length >= 2) { | |
| findings.push({ | |
| rule: 'LIABILITY_CONTRADICTION', | |
| severity: SEVERITY.LIABILITY_CONTRADICTION, | |
| title: 'Conflicting Liability Cap Provisions', | |
| reason: `Multiple liability caps found: ${liabilityFacts.map(f => f.type === 'currency' ? formatCurrency(f.value) : f.value + '%').join(', ')} across Clauses ${clauseIndices.map(i => i + 1).join(', ')}. Conflicting caps create material risk allocation ambiguity.`, | |
| clauseRefs: clauseIndices, | |
| }); | |
| } | |
| } | |
| // ── Rule 7: DATE_CONFLICT ──────────────────────────────────────────────── | |
| const completionDates = facts.filter(f => | |
| f.type === 'date' && f.categories?.some(c => c === 'completion_date') | |
| ); | |
| const deliveryDates = facts.filter(f => | |
| f.type === 'date' && f.categories?.some(c => c === 'delivery_date') | |
| ); | |
| if (completionDates.length > 0 && deliveryDates.length > 0) { | |
| if (completionDates[0].raw !== deliveryDates[0].raw) { | |
| findings.push({ | |
| rule: 'DATE_CONFLICT', | |
| severity: SEVERITY.DATE_CONFLICT, | |
| title: 'Project Completion Date vs Final Delivery Date Inconsistency', | |
| reason: `The project completion date (${completionDates[0].raw}, Clause ${completionDates[0].clauseIndex + 1}) differs from the final delivery date (${deliveryDates[0].raw}, Clause ${deliveryDates[0].clauseIndex + 1}). This creates timeline ambiguity.`, | |
| clauseRefs: [completionDates[0].clauseIndex, deliveryDates[0].clauseIndex], | |
| }); | |
| } | |
| } | |
| // ── Rule 8: AUTO_RENEWAL_VS_FIXED ───────────────────────────────────────── | |
| const autoRenewalFacts = facts.filter(f => f.type === 'auto_renewal'); | |
| const fixedEndFacts = facts.filter(f => f.type === 'fixed_end_date'); | |
| if (autoRenewalFacts.length > 0 && fixedEndFacts.length > 0) { | |
| findings.push({ | |
| rule: 'AUTO_RENEWAL_VS_FIXED', | |
| severity: SEVERITY.AUTO_RENEWAL_VS_FIXED, | |
| title: 'Auto-Renewal Provision Conflicts with Fixed End Date', | |
| reason: `The contract contains an auto-renewal provision (Clause ${autoRenewalFacts[0].clauseIndex + 1}) but also specifies a fixed termination/expiry date (Clause ${fixedEndFacts[0].clauseIndex + 1}). These provisions are logically inconsistent.`, | |
| clauseRefs: [autoRenewalFacts[0].clauseIndex, fixedEndFacts[0].clauseIndex], | |
| }); | |
| } | |
| // ── Rule 9: DURATION_CONFLICT (Same Category) ──────────────────────────── | |
| // Look for conflicting durations within the EXACT same semantic category. | |
| // Guard: only compare durations whose PRIMARY category matches, to avoid | |
| // cross-matching acceptance periods with notice periods. | |
| const durationCategories = ['contract_term', 'notice_period']; | |
| for (const cat of durationCategories) { | |
| const catDurations = facts.filter(f => { | |
| if (f.type !== 'duration') return false; | |
| // The fact must have this category but NOT also belong to a more specific | |
| // category (acceptance_period, deemed_acceptance, security_notice, retention_period) | |
| // that would cause cross-matching. | |
| const cats = f.categories || []; | |
| const isSpecific = cats.some(c => ['acceptance_period', 'deemed_acceptance', 'security_notice', 'retention_period'].includes(c)); | |
| return cats.includes(cat) && !isSpecific; | |
| }); | |
| if (catDurations.length >= 2) { | |
| const clauseIndices = [...new Set(catDurations.map(f => f.clauseIndex))]; | |
| if (clauseIndices.length >= 2) { | |
| const uniqueDays = [...new Set(catDurations.map(f => f.days))]; | |
| if (uniqueDays.length >= 2 && Math.max(...uniqueDays) / Math.min(...uniqueDays) > 1.5) { | |
| findings.push({ | |
| rule: 'DURATION_CONFLICT', | |
| severity: SEVERITY.DURATION_CONFLICT, | |
| title: `Conflicting ${cat.replace(/_/g, ' ')} Durations`, | |
| reason: `Multiple ${cat.replace(/_/g, ' ')} durations found: ${catDurations.map(f => `${f.value} ${f.unit} (Clause ${f.clauseIndex + 1})`).join(' vs ')}. This creates ambiguity about the actual contract term.`, | |
| clauseRefs: clauseIndices, | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| // ════════════════════════════════════════════════════════════════════════════ | |
| // LAYER 3: SEMANTIC CONTRADICTION PATTERNS | |
| // ════════════════════════════════════════════════════════════════════════════ | |
| // ── Rule 10: OWNERSHIP_CONTRADICTION ────────────────────────────────────── | |
| // Guard: Only flag when BOTH parties claim the SAME asset class. | |
| // "Client owns Deliverables" + "Vendor retains tools/frameworks" is COMPATIBLE. | |
| // "Client owns Deliverables" + "Vendor retains Deliverables" is a CONTRADICTION. | |
| const ownershipFacts = facts.filter(f => f.type === 'ownership'); | |
| const ownershipBySubject = {}; | |
| for (const f of ownershipFacts) { | |
| if (!ownershipBySubject[f.subject]) ownershipBySubject[f.subject] = []; | |
| ownershipBySubject[f.subject].push(f); | |
| } | |
| for (const [subject, owners] of Object.entries(ownershipBySubject)) { | |
| // Skip the generic bucket — it catches too many false positives | |
| if (subject === 'general') continue; | |
| const parties = [...new Set(owners.map(f => f.party))]; | |
| if (parties.length >= 2) { | |
| const clauseIndices = [...new Set(owners.map(f => f.clauseIndex))]; | |
| // Additional guard: check if clauses discuss different asset scopes. | |
| // "pre-existing", "underlying", "tools", "frameworks" indicate a scoped | |
| // retention that is compatible with the client owning deliverables. | |
| // BUT: only suppress if the scoped clause does NOT also claim the | |
| // contested subject. E.g., "Vendor retains Deliverables, tools, and | |
| // methodologies" DOES claim deliverables, so it IS a real contradiction. | |
| const scopedExclusionKeywords = /pre-existing|underlying|background\s+ip|prior\s+work/i; | |
| const subjectKeyword = subject === 'deliverables' ? /deliverable/i | |
| : subject === 'ip' ? /intellectual\s+property/i : null; | |
| const hasScoped = owners.some(o => { | |
| const clause = clauses.find(c => c.segmentIndex === o.clauseIndex); | |
| if (!clause) return false; | |
| const text = clause.rawText; | |
| const isScopedLanguage = scopedExclusionKeywords.test(text); | |
| // If the clause uses scoped language AND does NOT also claim the | |
| // contested subject, it's a compatible scoped retention. | |
| const alsoClaimsSubject = subjectKeyword ? subjectKeyword.test(text) : false; | |
| // Only treat as scoped (compatible) if it uses scoped language | |
| // without also claiming the same contested asset class. | |
| // "tools and frameworks" without "deliverables" = scoped | |
| // "Deliverables, tools, and methodologies" = NOT scoped (claims deliverables) | |
| return isScopedLanguage && !alsoClaimsSubject; | |
| }); | |
| if (!hasScoped) { | |
| const firstClause = clauseIndices[0] + 1; | |
| const secondClause = clauseIndices[1] !== undefined ? `Clause ${clauseIndices[1] + 1}` : `the same clause`; | |
| findings.push({ | |
| rule: 'OWNERSHIP_CONTRADICTION', | |
| severity: SEVERITY.OWNERSHIP_CONTRADICTION, | |
| title: `Conflicting Ownership of ${capitalize(subject)}`, | |
| reason: `Clause ${firstClause} assigns ${subject} ownership to ${parties[0]}, but ${secondClause} assigns it to ${parties[1]}. Both parties cannot own the same ${subject}.`, | |
| clauseRefs: clauseIndices, | |
| }); | |
| } | |
| } | |
| } | |
| // ── Rule 11: PRECEDENCE_CONFLICT ────────────────────────────────────────── | |
| // Guard: Do NOT flag when precedence claims are explicitly scoped. | |
| // "Schedule A prevails for pricing" + "Agreement prevails for all other" | |
| // is compatible because they govern different subject matters. | |
| const precedenceFacts = facts.filter(f => f.type === 'precedence'); | |
| if (precedenceFacts.length >= 2) { | |
| const documents = [...new Set(precedenceFacts.map(f => f.document))]; | |
| if (documents.length >= 2) { | |
| const clauseIndices = [...new Set(precedenceFacts.map(f => f.clauseIndex))]; | |
| if (clauseIndices.length >= 2) { | |
| // Check if the precedence claims are scoped to different topics | |
| const scopeKeywords = /(?:with\s+respect\s+to|regarding|for\s+(?:purposes?\s+of|all\s+(?:other\s+)?matters?|pricing|payment|delivery|scope))/i; | |
| const allScoped = precedenceFacts.every(f => { | |
| const clause = clauses.find(c => c.segmentIndex === f.clauseIndex); | |
| return clause && scopeKeywords.test(clause.rawText); | |
| }); | |
| if (!allScoped) { | |
| findings.push({ | |
| rule: 'PRECEDENCE_CONFLICT', | |
| severity: SEVERITY.PRECEDENCE_CONFLICT, | |
| title: 'Conflicting Document Precedence', | |
| reason: `Clause ${clauseIndices[0] + 1} states that "${documents[0]}" shall prevail, but Clause ${clauseIndices[1] + 1} states that "${documents[1]}" shall prevail. These are mutually exclusive precedence claims.`, | |
| clauseRefs: clauseIndices, | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| // ── Rule 12: TERMINATION_PAYMENT Contradiction ──────────────────────────── | |
| // Guard: "refund fees for services NOT YET performed" is COMPATIBLE with | |
| // "pay for services performed" — they describe the same economic principle | |
| // from different angles. Only flag when it's an unqualified "refund ALL fees". | |
| const termPaymentFacts = facts.filter(f => f.type === 'termination_payment'); | |
| if (termPaymentFacts.length >= 2) { | |
| const actions = [...new Set(termPaymentFacts.map(f => f.action))]; | |
| if (actions.includes('refund_all') && actions.includes('pay_for_services')) { | |
| // Check if the refund clause is qualified ("not yet performed", "unused") | |
| const refundFact = termPaymentFacts.find(f => f.action === 'refund_all'); | |
| const refundClause = clauses.find(c => c.segmentIndex === refundFact?.clauseIndex); | |
| const isQualifiedRefund = refundClause && | |
| /(?:not\s+yet\s+performed|unused|unearned|pro[- ]?rat(?:a|ed)|remaining)/i.test(refundClause.rawText); | |
| if (!isQualifiedRefund) { | |
| const clauseIndices = [...new Set(termPaymentFacts.map(f => f.clauseIndex))]; | |
| const firstClause = clauseIndices[0] + 1; | |
| const secondClause = clauseIndices[1] !== undefined ? `Clause ${clauseIndices[1] + 1}` : `the same clause`; | |
| findings.push({ | |
| rule: 'TERMINATION_PAYMENT', | |
| severity: SEVERITY.TERMINATION_PAYMENT, | |
| title: 'Contradictory Termination Payment Provisions', | |
| reason: `Clause ${firstClause} requires a full refund of all fees upon termination, but ${secondClause} requires payment for services already performed. These provisions directly contradict each other.`, | |
| clauseRefs: clauseIndices, | |
| }); | |
| } | |
| } | |
| } | |
| // ── Rule 13: INTEREST_CONTRADICTION ─────────────────────────────────────── | |
| // Conflicting interest rates for default or late payments | |
| const interestFacts = facts.filter(f => | |
| f.type === 'percentage' && f.categories?.includes('interest_rate') | |
| ); | |
| if (interestFacts.length >= 2) { | |
| const uniqueRates = [...new Set(interestFacts.map(f => f.value))]; | |
| if (uniqueRates.length >= 2) { | |
| const clauseIndices = [...new Set(interestFacts.map(f => f.clauseIndex))]; | |
| findings.push({ | |
| rule: 'INTEREST_CONTRADICTION', | |
| severity: SEVERITY.INTEREST_CONTRADICTION, | |
| title: 'Conflicting Interest Rates for Late Payments', | |
| reason: `Multiple conflicting interest rates found: ${interestFacts.map(f => `${f.value}% (Clause ${f.clauseIndex + 1})`).join(' vs ')}. These conflicting rates create commercial and compliance ambiguity.`, | |
| clauseRefs: clauseIndices, | |
| }); | |
| } | |
| } | |
| // ── Deduplicate findings ────────────────────────────────────────────────── | |
| const seen = new Set(); | |
| const dedupedFindings = findings.filter(f => { | |
| const key = f.rule + ':' + f.clauseRefs.sort().join(','); | |
| if (seen.has(key)) return false; | |
| seen.add(key); | |
| return true; | |
| }); | |
| console.log(`[Contradiction Engine] Detected ${dedupedFindings.length} contradictions across ${clauses.length} clauses.`); | |
| return { findings: dedupedFindings }; | |
| } | |
| // ── Helpers ───────────────────────────────────────────────────────────────── | |
| function formatCurrency(value, unit) { | |
| if (unit === 'INR') { | |
| return '₹' + value.toLocaleString('en-IN', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); | |
| } | |
| return '$' + value.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); | |
| } | |
| function capitalize(str) { | |
| return str.charAt(0).toUpperCase() + str.slice(1); | |
| } | |
| module.exports = { detectContradictions, SEVERITY }; | |