lexguard-backend / src /services /crossClauseContradictionEngine.js
github-actions[bot]
Deploy to Hugging Face
b921752
Raw
History Blame Contribute Delete
27.2 kB
/**
* 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 };