lexguard-backend / src /services /agent10DeterministicAuditor.js
github-actions[bot]
Deploy to Hugging Face
b921752
Raw
History Blame Contribute Delete
13.5 kB
/**
* 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": <Array of Numbers or single Number> },
"rightFact": { "label": "description", "value": <Number> },
"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 };