/** * Agent 10: Deterministic Auditor (Rebuilt — Phase 26) * * Three-layer architecture: * Layer 1: Deterministic Fact Extraction + Canonicalization (zero LLM) * Layer 2: Deterministic Contradiction Rules (zero LLM) * Layer 3: Semantic Contradiction Detection (zero LLM pattern matching) * Bonus: Optional LLM enhancement pass (if provider available) * * This agent produces results even when ALL LLM providers are rate-limited. */ const { callLLM } = require('./aiClient'); const Contract = require('../models/Contract'); const Clause = require('../models/Clause'); const { extractFactTable } = require('./deterministicFactExtractor'); const { detectContradictions } = require('./crossClauseContradictionEngine'); // ── Optional LLM DSL Prompt (Phase 4 — supplementary, not primary) ──────── const DSL_EXTRACTION_PROMPT = ` You are Agent 10 (The Deterministic Code Interpreter) for LexGuard. Your objective is to catch hard mathematical, financial, and chronological contradictions in the provided contract. Extract rules in a strict JSON Domain Specific Language (DSL). Output Schema: { "rules": [ { "type": "sum_equals" | "multiply_equals" | "less_than_or_equal" | "greater_than" | "timeline_conflict", "leftFact": { "label": "description", "value": }, "rightFact": { "label": "description", "value": }, "title": "Short title of the rule being checked", "severity": "critical" | "high" | "medium", "reason": "Explanation of the contradiction if this rule is violated." } ] } Return ONLY valid JSON. NO EXPRESSIONS — only literal Numbers or Arrays of Numbers. `; /** * Main entry point for Agent 10. * @param {string} contractId */ async function runAgent10DeterministicAudit(contractId) { console.log(`[Agent 10] Starting Three-Layer Deterministic Auditor for contract: ${contractId}`); const contract = await Contract.findById(contractId); if (!contract) throw new Error('Contract not found'); // Delete any existing virtual clauses from previous runs of Agent 10 await Clause.deleteMany({ contractId, segmentIndex: { $gte: 9999 } }); const clauses = await Clause.find({ contractId }).sort('segmentIndex'); if (clauses.length === 0) { console.warn('[Agent 10] No clauses found. Skipping.'); return; } let newRisksCount = 0; const pushRisk = async (severity, reason, title, clauseRefs = []) => { let updatedCount = 0; if (clauseRefs && clauseRefs.length > 0) { const targetClauses = await Clause.find({ contractId, segmentIndex: { $in: clauseRefs } }); if (targetClauses.length > 0) { const RISK_PRIORITY = { critical: 4, high: 3, medium: 2, low: 1, null: 0 }; const COMP_PRIORITY = { high: 3, medium: 2, low: 1, null: 0 }; for (const targetClause of targetClauses) { const currentLevel = targetClause.risk_level || 'low'; if (RISK_PRIORITY[severity] > RISK_PRIORITY[currentLevel]) { targetClause.risk_level = severity; } const newRiskScore = severity === 'critical' ? 10 : severity === 'high' ? 8 : 6; targetClause.risk_score = Math.max(targetClause.risk_score || 0, newRiskScore); if (!targetClause.risk_reasons) targetClause.risk_reasons = []; if (!targetClause.risk_reasons.includes(reason)) { targetClause.risk_reasons.push(reason); } if (!targetClause.reasons) targetClause.reasons = []; if (!targetClause.reasons.includes(reason)) { targetClause.reasons.push(reason); } targetClause.confidence_score = 10; const compSeverity = (severity === 'critical' || severity === 'high') ? 'high' : (severity === 'medium' ? 'medium' : 'low'); const curComp = targetClause.compliance_risk_level || 'low'; if (COMP_PRIORITY[compSeverity] > COMP_PRIORITY[curComp]) { targetClause.compliance_risk_level = compSeverity; } await targetClause.save(); updatedCount++; } } } if (updatedCount === 0 && clauses.length > 0) { const titleLower = (title || '').toLowerCase(); const reasonLower = (reason || '').toLowerCase(); // Intelligently map to target clause if text/type matches finding const targetClause = clauses.find(c => { const textLower = (c.rawText || '').toLowerCase(); const typeLower = (c.clauseType || '').toLowerCase(); if ((titleLower.includes('restricted') || reasonLower.includes('restricted')) && (typeLower.includes('non_compete') || textLower.includes('restricted period'))) return true; if ((titleLower.includes('product') || reasonLower.includes('work product')) && (typeLower.includes('ip') || textLower.includes('work product'))) return true; return false; }); if (targetClause) { targetClause.risk_reasons = targetClause.risk_reasons || []; if (!targetClause.risk_reasons.includes(`[Deterministic Finding] ${title}: ${reason}`)) { targetClause.risk_reasons.push(`[Deterministic Finding] ${title}: ${reason}`); } targetClause.risk_level = severity; targetClause.risk_score = Math.max(targetClause.risk_score || 0, severity === 'critical' ? 10 : 8); await targetClause.save(); } // Store in contract-level contradictions array without corrupting Clause #1 opening header await Contract.findByIdAndUpdate(contractId, { $push: { contradictions: { title: title, severity: severity, reason: reason, timestamp: new Date() } } }); console.log(`🚨 [Agent 10] Deterministic Trap Caught (Document-Level Finding): ${title} (${severity})`); } else { console.log(`🚨 [Agent 10] Deterministic Trap Caught: ${title} (${severity}) - Mapped to ${updatedCount} original clauses.`); } }; // ════════════════════════════════════════════════════════════════════════ // PHASE 1: Zero-LLM Deterministic Extraction + Contradiction Detection // ════════════════════════════════════════════════════════════════════════ console.log(`[Agent 10] Phase 1: Extracting facts from ${clauses.length} clauses...`); const clauseData = clauses.map(c => ({ segmentIndex: c.segmentIndex, rawText: c.rawText, })); const factTable = extractFactTable(clauseData); console.log(`[Agent 10] Phase 1: Extracted ${factTable.facts.length} facts (${Object.keys(factTable.categorizedFacts).length} categories).`); const { findings } = detectContradictions(factTable, clauseData); console.log(`[Agent 10] Phase 1: Contradiction engine found ${findings.length} issues.`); // Persist all deterministic findings for (const finding of findings) { await pushRisk(finding.severity, finding.reason, finding.title, finding.clauseRefs); } // ════════════════════════════════════════════════════════════════════════ // PHASE 2: Optional LLM Enhancement Pass // ════════════════════════════════════════════════════════════════════════ // Only runs if an LLM provider is available. This catches qualitative // contradictions that pure regex/pattern-matching may miss. try { console.log(`[Agent 10] Phase 2: Attempting LLM enhancement pass...`); const fullText = clauses.map(c => c.rawText).join('\n\n'); const llmResult = await callLLM({ systemPrompt: DSL_EXTRACTION_PROMPT, userContent: JSON.stringify({ contractText: fullText }), jsonMode: true, temperature: 0.1, maxTokens: 4000 }); // callLLM already returns the parsed object directly — no need for .parsed const data = llmResult; if (data?.rules && Array.isArray(data.rules)) { console.log(`[Agent 10] Phase 2: LLM returned ${data.rules.length} DSL rules.`); // Schema guard: Normalize hallucinated types const parseCleanNumber = (val) => { if (typeof val === 'number') return val; const strVal = String(val).toLowerCase(); const cleaned = strVal.replace(/[^0-9.-]+/g, ''); let num = Number(cleaned); if (strVal.includes('k') && num < 1000) num *= 1000; if (strVal.includes('m') && num < 1000) num *= 1000000; return isNaN(num) ? 0 : num; }; for (const rule of data.rules) { try { let rawType = String(rule.type || '').toLowerCase().trim(); let type = rawType; if (rawType.includes('sum') || rawType.includes('math') || rawType.includes('add')) type = 'sum_equals'; else if (rawType.includes('multiply') || rawType.includes('multiplier')) type = 'multiply_equals'; else if (rawType.includes('timeline') || rawType.includes('less') || rawType.includes('conflict')) type = 'less_than_or_equal'; else if (rawType.includes('greater')) type = 'greater_than'; let leftVal = rule.leftFact?.value; let rightVal = rule.rightFact?.value; let passed = true; if (type === 'sum_equals') { let arr = Array.isArray(leftVal) ? leftVal : String(leftVal).split(','); const sum = arr.reduce((a, b) => a + parseCleanNumber(b), 0); passed = Math.abs(sum - parseCleanNumber(rightVal)) < 0.01; } else if (type === 'multiply_equals') { let arr = Array.isArray(leftVal) ? leftVal : String(leftVal).split(','); const product = arr.reduce((a, b) => a * (parseCleanNumber(b) || 1), 1); passed = Math.abs(product - parseCleanNumber(rightVal)) < 0.01; } else if (type === 'less_than_or_equal') { passed = (parseCleanNumber(leftVal) <= parseCleanNumber(rightVal)); } else if (type === 'greater_than') { passed = (parseCleanNumber(leftVal) > parseCleanNumber(rightVal)); } if (!passed) { // Check if this finding duplicates a Phase 1 finding const titleLower = (rule.title || '').toLowerCase(); const isDuplicate = findings.some(f => f.title.toLowerCase().includes(titleLower.substring(0, 20)) || titleLower.includes(f.title.toLowerCase().substring(0, 20)) ); if (!isDuplicate) { await pushRisk( rule.severity || 'high', rule.reason || `Contradiction detected between ${rule.leftFact?.label} and ${rule.rightFact?.label}.`, rule.title || 'Mathematical/Temporal Contradiction' ); } else { console.log(`[Agent 10] Phase 2: Skipping duplicate LLM finding: ${rule.title}`); } } } catch (err) { console.error(`⚠️ [Agent 10] Error evaluating DSL rule "${rule.title}": ${err.message}`); } } } else { console.warn(`[Agent 10] Phase 2: LLM did not return a valid "rules" array. Response keys: ${Object.keys(data || {}).join(', ') || '(empty)'}`); } } catch (llmErr) { // LLM enhancement is optional — if it fails, Phase 1 results still stand console.warn(`[Agent 10] Phase 2: LLM pass skipped (${llmErr.message}). Phase 1 deterministic results are preserved.`); } console.log(`✅ [Agent 10] Three-Layer Audit completed. Found ${newRisksCount} total contradictions.`); } module.exports = { runAgent10DeterministicAudit };