Spaces:
Sleeping
Sleeping
| /** | |
| * AI Client Wrapper – centralised gateway for all LLM calls. | |
| * All agents call this function; swapping models or providers only | |
| * requires changes here. | |
| */ | |
| const { GoogleGenerativeAI } = require('@google/generative-ai'); | |
| const { AI_MODEL_NAME } = require('../config/constants'); | |
| const pLimit = require('p-limit'); | |
| // Some versions of p-limit export default | |
| const limitFn = typeof pLimit === 'function' ? pLimit : pLimit.default; | |
| const SystemMetric = require('../models/SystemMetric'); | |
| // Max 5 concurrent LLM calls globally across all agents to prevent HTTP 429 | |
| const globalLlmLimiter = limitFn(5); | |
| // ── Singleton client ───────────────────────────────────────────────────────── | |
| let _genAI = null; | |
| function getClient() { | |
| if (!_genAI) { | |
| const key = process.env.GEMINI_API_KEY; | |
| if (!key) { | |
| throw new Error( | |
| 'GEMINI_API_KEY is not defined in .env — AI features are unavailable.' | |
| ); | |
| } | |
| _genAI = new GoogleGenerativeAI(key); | |
| } | |
| return _genAI; | |
| } | |
| // ── JSON extraction helper ─────────────────────────────────────────────────── | |
| /** | |
| * Extremely resilient JSON extractor that can handle: | |
| * - Markdown code fences (even if missing the closing backticks) | |
| * - Trailing commas | |
| * - Embedded newlines inside strings | |
| * - Single quotes instead of double quotes | |
| */ | |
| function extractJSON(raw) { | |
| let text = raw.trim(); | |
| // Strategy 1: Direct parse | |
| try { | |
| return JSON.parse(text); | |
| } catch { /* continue */ } | |
| // Strategy 2: Strip markdown fences (even incomplete ones) | |
| if (text.includes('```')) { | |
| const fenced = text.match(/```(?:json|javascript|js)?\s*\n?([\s\S]*?)(?:```|$)/i); | |
| if (fenced && fenced[1]) { | |
| try { return JSON.parse(fenced[1].trim()); } catch { /* continue */ } | |
| text = fenced[1].trim(); // Update text to the stripped version for Strategy 3 | |
| } | |
| } | |
| // Strategy 3: Find the first top-level JSON object { ... } or array [ ... ] | |
| const firstBrace = text.indexOf('{'); | |
| const lastBrace = text.lastIndexOf('}'); | |
| const firstBracket = text.indexOf('['); | |
| const lastBracket = text.lastIndexOf(']'); | |
| let start = -1; | |
| let end = -1; | |
| if (firstBrace !== -1 && lastBrace > firstBrace) { | |
| if (firstBracket !== -1 && firstBracket < firstBrace && lastBracket > lastBrace) { | |
| start = firstBracket; | |
| end = lastBracket; | |
| } else { | |
| start = firstBrace; | |
| end = lastBrace; | |
| } | |
| } else if (firstBracket !== -1 && lastBracket > firstBracket) { | |
| start = firstBracket; | |
| end = lastBracket; | |
| } | |
| if (start !== -1 && end > start) { | |
| let candidate = text.substring(start, end + 1); | |
| try { return JSON.parse(candidate); } catch { /* continue */ } | |
| // Strategy 4: Aggressive JS Object evaluation (Handles trailing commas, single quotes, unescaped newlines) | |
| try { | |
| // By using new Function, we can parse JS-like JSON safely in this context | |
| return (new Function(`return ${candidate}`))(); | |
| } catch { /* continue */ } | |
| } | |
| throw new Error( | |
| `Failed to extract JSON from LLM response. First 200 chars: "${raw.substring(0, 200)}"` | |
| ); | |
| } | |
| // ── Grok xAI Client ───────────────────────────────────────────────────────── | |
| /** | |
| * Direct HTTP caller for the xAI Grok API. | |
| * Uses native fetch (Node 18+) to ensure zero extra dependencies. | |
| */ | |
| async function callGrok({ | |
| systemPrompt, | |
| userContent, | |
| jsonMode, | |
| temperature, | |
| maxTokens, | |
| }) { | |
| const apiKey = process.env.GROK_API_KEY; | |
| if (!apiKey) { | |
| throw new Error('GROK_API_KEY is not defined in .env — Grok features are unavailable.'); | |
| } | |
| const model = process.env.GROK_MODEL_NAME || 'grok-beta'; | |
| const body = { | |
| model, | |
| messages: [ | |
| { role: 'system', content: systemPrompt }, | |
| { role: 'user', content: userContent }, | |
| ], | |
| temperature: temperature ?? 0.1, | |
| max_tokens: maxTokens, | |
| }; | |
| if (jsonMode) { | |
| body.response_format = { type: 'json_object' }; | |
| } | |
| const res = await fetch('https://api.xai.com/v1/chat/completions', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${apiKey}`, | |
| }, | |
| body: JSON.stringify(body), | |
| }); | |
| if (!res.ok) { | |
| const errText = await res.text(); | |
| throw new Error(`Grok API Error (${res.status}): ${errText}`); | |
| } | |
| const data = await res.json(); | |
| const rawText = data?.choices?.[0]?.message?.content; | |
| if (!rawText) { | |
| throw new Error('Grok returned an empty response.'); | |
| } | |
| const totalTokens = data?.usage?.total_tokens || 0; | |
| return { parsed: jsonMode ? extractJSON(rawText) : rawText, tokens: totalTokens, model }; | |
| } | |
| // ── Hugging Face Serverless Client ────────────────────────────────────────── | |
| /** | |
| * Direct HTTP caller for Hugging Face Inference API. | |
| */ | |
| async function callHuggingFace({ | |
| systemPrompt, | |
| userContent, | |
| jsonMode, | |
| temperature, | |
| maxTokens, | |
| modelName, | |
| }) { | |
| const apiKey = process.env.HUGGINGFACE_API_KEY; | |
| if (!apiKey) { | |
| throw new Error('HUGGINGFACE_API_KEY is not defined in .env — Hugging Face features are unavailable.'); | |
| } | |
| // Use Qwen 2.5 7B Instruct as the robust default for HF Inference API | |
| const model = modelName || process.env.HUGGINGFACE_MODEL || 'Qwen/Qwen2.5-7B-Instruct'; | |
| const body = { | |
| model, | |
| messages: [ | |
| { role: 'system', content: systemPrompt }, | |
| { role: 'user', content: userContent }, | |
| ], | |
| temperature: temperature ?? 0.1, | |
| max_tokens: maxTokens, | |
| }; | |
| // HF free API doesn't fully support response_format globally yet, but we will pass it just in case | |
| // Qwen handles JSON fine if we tell it to via system prompt. | |
| if (jsonMode) { | |
| body.response_format = { type: 'json_object' }; | |
| } | |
| const url = 'https://router.huggingface.co/v1/chat/completions'; | |
| const headers = { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${apiKey}`, | |
| }; | |
| const payload = JSON.stringify(body); | |
| let lastErr; | |
| for (let attempt = 1; attempt <= 3; attempt++) { | |
| try { | |
| const res = await fetch(url, { method: 'POST', headers, body: payload }); | |
| if (res.status === 429 || res.status === 503) { | |
| let delayMs = Math.pow(2, attempt) * 1500 + Math.random() * 1000; | |
| const retryAfter = res.headers.get('retry-after'); | |
| if (retryAfter) { | |
| const sec = parseInt(retryAfter, 10); | |
| if (!isNaN(sec) && sec > 0) { | |
| delayMs = sec * 1000 + 500; | |
| } | |
| } | |
| console.warn(`⚠️ [HuggingFace] Rate limit/Unavailable (${res.status}) on attempt ${attempt}/3. Retrying in ${delayMs.toFixed(0)}ms...`); | |
| await new Promise(r => setTimeout(r, delayMs)); | |
| continue; | |
| } | |
| if (res.status === 402 || (res.status === 400 && (await res.clone().text()).toLowerCase().includes('quota'))) { | |
| const quotaErr = await res.text(); | |
| console.warn(`⚠️ [HuggingFace] Free credit quota exceeded (402). Skipping Hugging Face and engaging next provider in cascade.`); | |
| throw new Error(`Hugging Face Quota Exceeded (402): ${quotaErr}`); | |
| } | |
| if (!res.ok) { | |
| const errText = await res.text(); | |
| throw new Error(`Hugging Face API Error (${res.status}): ${errText}`); | |
| } | |
| const data = await res.json(); | |
| const rawText = data?.choices?.[0]?.message?.content; | |
| if (!rawText) { | |
| throw new Error('Hugging Face returned an empty response.'); | |
| } | |
| const totalTokens = data?.usage?.total_tokens || 0; | |
| return { parsed: jsonMode ? extractJSON(rawText) : rawText, tokens: totalTokens, model }; | |
| } catch (err) { | |
| lastErr = err; | |
| const isNetwork = err.message.includes('fetch failed') || err.message.includes('EAI_AGAIN') || err.message.includes('ENOTFOUND'); | |
| if (isNetwork && attempt < 3) { | |
| console.warn(`⚠️ [HuggingFace] Network error (attempt ${attempt}/3), retrying in 2s…`); | |
| await new Promise(r => setTimeout(r, 2000)); | |
| continue; | |
| } | |
| throw lastErr; | |
| } | |
| } | |
| throw lastErr; | |
| } | |
| // ── Groq Client ───────────────────────────────────────────────────────────── | |
| /** | |
| * Direct HTTP caller for the Groq API. | |
| * Uses native fetch (Node 18+) to ensure zero extra dependencies. | |
| */ | |
| async function callGroq({ | |
| systemPrompt, | |
| userContent, | |
| jsonMode, | |
| temperature, | |
| maxTokens, | |
| modelName, | |
| }) { | |
| const apiKey = process.env.GROQ_API_KEY; | |
| if (!apiKey) { | |
| throw new Error('GROQ_API_KEY is not defined in .env — Groq features are unavailable.'); | |
| } | |
| const model = modelName || process.env.GROQ_MODEL_NAME || 'llama-3.3-70b-versatile'; | |
| const finalSystemPrompt = jsonMode ? `${systemPrompt}\nOutput in json format.` : systemPrompt; | |
| const body = { | |
| model, | |
| messages: [ | |
| { role: 'system', content: finalSystemPrompt }, | |
| { role: 'user', content: userContent }, | |
| ], | |
| temperature: temperature ?? 0.1, | |
| max_tokens: maxTokens, | |
| }; | |
| if (jsonMode) { | |
| body.response_format = { type: 'json_object' }; | |
| } | |
| const res = await fetch('https://api.groq.com/openai/v1/chat/completions', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${apiKey}`, | |
| }, | |
| body: JSON.stringify(body), | |
| }); | |
| if (!res.ok) { | |
| const errText = await res.text(); | |
| throw new Error(`Groq API Error (${res.status}): ${errText}`); | |
| } | |
| const data = await res.json(); | |
| const rawText = data?.choices?.[0]?.message?.content; | |
| if (!rawText) { | |
| throw new Error('Groq returned an empty response.'); | |
| } | |
| const totalTokens = data?.usage?.total_tokens || 0; | |
| return { parsed: jsonMode ? extractJSON(rawText) : rawText, tokens: totalTokens, model }; | |
| } | |
| // ── Ollama Local Client ─────────────────────────────────────────────────────── | |
| /** | |
| * Direct HTTP caller for Local Ollama API. | |
| */ | |
| async function callOllama({ | |
| systemPrompt, | |
| userContent, | |
| jsonMode, | |
| temperature, | |
| maxTokens, | |
| }) { | |
| const ollamaUrl = process.env.OLLAMA_URL || 'http://localhost:11434/api/chat'; | |
| const model = process.env.OLLAMA_MODEL || 'llama3'; | |
| const payload = { | |
| model: model, | |
| messages: [ | |
| { role: "system", content: systemPrompt }, | |
| { role: "user", content: userContent } | |
| ], | |
| stream: false, | |
| options: { | |
| num_ctx: 8192, | |
| temperature: temperature ?? 0.1 | |
| } | |
| }; | |
| if (jsonMode) { | |
| payload.format = "json"; | |
| } | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), 60000); // 60-second timeout | |
| let res; | |
| try { | |
| res = await fetch(ollamaUrl, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload), | |
| signal: controller.signal | |
| }); | |
| } catch (err) { | |
| if (err.name === 'AbortError') { | |
| throw new Error('Ollama API timeout: Request took longer than 60 seconds.'); | |
| } | |
| throw err; | |
| } finally { | |
| clearTimeout(timeoutId); | |
| } | |
| if (!res.ok) { | |
| const errText = await res.text(); | |
| throw new Error(`Ollama API Error (${res.status}): ${errText}`); | |
| } | |
| const data = await res.json(); | |
| const rawText = data?.message?.content; | |
| if (!rawText) { | |
| throw new Error('Ollama returned an empty response.'); | |
| } | |
| const totalTokens = (data?.prompt_eval_count || 0) + (data?.eval_count || 0); | |
| return { parsed: jsonMode ? extractJSON(rawText) : rawText, tokens: totalTokens, model }; | |
| } | |
| // ── Main entry point ───────────────────────────────────────────────────────── | |
| /** | |
| * Call the configured LLM and return a parsed object. | |
| * Supporting multi-provider selection and automatic self-healing failover. | |
| * | |
| * @param {Object} opts | |
| * @param {string} opts.systemPrompt – System-level instruction. | |
| * @param {string} opts.userContent – User-supplied content (stringified JSON). | |
| * @param {boolean} [opts.jsonMode] – Request JSON output from the model. | |
| * @param {number} [opts.temperature=0.1] | |
| * @param {number} [opts.maxTokens=4096] | |
| * @returns {Promise<Object>} Parsed JSON response from the model. | |
| */ | |
| async function callLLM({ | |
| systemPrompt, | |
| userContent, | |
| jsonMode = true, | |
| temperature = 0.1, | |
| maxTokens = 8192, | |
| modelOverride, | |
| providerOverride, | |
| } = {}) { | |
| // Intelligent Token-Aware Automatic Chunking Handler | |
| const estimatedTokens = Math.ceil((systemPrompt.length + userContent.length) / 4.0); | |
| if (estimatedTokens > 4000) { | |
| let parsed = null; | |
| try { | |
| parsed = JSON.parse(userContent); | |
| } catch {} | |
| if (parsed && Array.isArray(parsed.clauses) && parsed.clauses.length > 1) { | |
| console.log(`🛡️ [aiClient] Prompt exceeds safe threshold (~${estimatedTokens} tokens). Auto-chunking clauses batch of size ${parsed.clauses.length} into sub-batches...`); | |
| const mid = Math.ceil(parsed.clauses.length / 2); | |
| const chunk1 = parsed.clauses.slice(0, mid); | |
| const chunk2 = parsed.clauses.slice(mid); | |
| const [res1, res2] = await Promise.all([ | |
| callLLM({ | |
| systemPrompt, | |
| userContent: JSON.stringify({ ...parsed, clauses: chunk1 }), | |
| jsonMode, | |
| temperature, | |
| maxTokens, | |
| modelOverride, | |
| providerOverride | |
| }), | |
| callLLM({ | |
| systemPrompt, | |
| userContent: JSON.stringify({ ...parsed, clauses: chunk2 }), | |
| jsonMode, | |
| temperature, | |
| maxTokens, | |
| modelOverride, | |
| providerOverride | |
| }) | |
| ]); | |
| // Intelligently merge sub-results: handle any response shape | |
| // The most common shape is { results: [...] } from multi-clause agents. | |
| // Other shapes (e.g. Agent 0's { metadata: {...} }) must not be silently dropped. | |
| const hasResults1 = Array.isArray(res1?.results); | |
| const hasResults2 = Array.isArray(res2?.results); | |
| if (hasResults1 || hasResults2) { | |
| // Standard multi-clause response shape | |
| const combinedResults = [...(res1?.results || []), ...(res2?.results || [])]; | |
| return { results: combinedResults }; | |
| } else { | |
| // Non-standard shape: return the first non-null response (chunking is not appropriate here) | |
| console.warn(`[aiClient] Auto-chunker: sub-responses are not { results: [] } shaped. Returning first chunk result. Keys: ${Object.keys(res1 || {}).join(',')}`); | |
| return res1 || res2 || {}; | |
| } | |
| } | |
| } | |
| // The actual network execution logic wrapped in our global concurrency limiter | |
| return globalLlmLimiter(async () => { | |
| const primaryProvider = (providerOverride || process.env.LLM_PROVIDER || 'gemini').toLowerCase(); | |
| if (primaryProvider === 'local' || primaryProvider === 'mock' || process.env.FORCE_LOCAL_LLM === 'true') { | |
| console.log(`[aiClient] Force Local/Mock LLM enabled. Returning Smart Local Fallback.`); | |
| return generateSmartLocalFallback(systemPrompt, userContent); | |
| } | |
| const tryGroqLarge = () => callGroq({ systemPrompt, userContent, jsonMode, temperature, maxTokens, modelName: 'llama-3.3-70b-versatile' }); | |
| const tryGroqFast = () => callGroq({ systemPrompt, userContent, jsonMode, temperature, maxTokens, modelName: 'llama-3.1-8b-instant' }); | |
| const tryGrok = () => callGrok({ systemPrompt, userContent, jsonMode, temperature, maxTokens }); | |
| const tryHuggingFace = () => callHuggingFace({ systemPrompt, userContent, jsonMode, temperature, maxTokens }); | |
| const tryOllama = () => callOllama({ systemPrompt, userContent, jsonMode, temperature, maxTokens }); | |
| const tryGemini = async () => { | |
| const client = getClient(); | |
| let activeModel = modelOverride || AI_MODEL_NAME; | |
| for (let attempt = 0; attempt < 3; attempt++) { | |
| try { | |
| const isThinkingModel = activeModel.includes('2.5'); | |
| const generationConfig = { | |
| temperature, | |
| maxOutputTokens: maxTokens, | |
| }; | |
| if (jsonMode && !isThinkingModel) { | |
| generationConfig.responseMimeType = 'application/json'; | |
| } | |
| const modelConfig = { | |
| model: activeModel, | |
| systemInstruction: systemPrompt, | |
| generationConfig, | |
| }; | |
| if (isThinkingModel) { | |
| modelConfig.generationConfig.thinkingConfig = { thinkingBudget: 0 }; | |
| } | |
| const model = client.getGenerativeModel(modelConfig); | |
| const result = await model.generateContent(userContent); | |
| const response = result.response; | |
| let text = ''; | |
| try { | |
| text = response.text(); | |
| } catch {} | |
| if (!text && response.candidates && response.candidates[0]) { | |
| const parts = response.candidates[0].content?.parts || []; | |
| for (const part of parts) { | |
| if (part.text) text = part.text; | |
| } | |
| } | |
| if (!text) { | |
| throw new Error('LLM returned an empty response — no text content found.'); | |
| } | |
| const totalTokens = result.response.usageMetadata?.totalTokenCount || 0; | |
| return { parsed: jsonMode ? extractJSON(text) : text, tokens: totalTokens, model: activeModel }; | |
| } catch (err) { | |
| const isQuota = (err.message || '').toLowerCase().includes('quota'); | |
| if (attempt < 2 && isTransient(err)) { | |
| if (isQuota && activeModel === 'gemini-2.5-flash') { | |
| console.warn(`⚠️ Gemini 2.5 Flash quota exceeded. Automatically falling back to Gemini Flash Latest (Stable 1.5)...`); | |
| activeModel = 'gemini-flash-latest'; | |
| continue; | |
| } | |
| const delayMs = getRetryDelayMs(err); | |
| console.warn(`⚠️ Gemini transient error (attempt ${attempt + 1}/3), retrying in ${delayMs / 1000} s… (${err.message})`); | |
| await sleep(delayMs); | |
| continue; | |
| } | |
| throw err; | |
| } | |
| } | |
| }; | |
| const providers = []; | |
| providers.push({ name: 'gemini', fn: tryGemini, available: !!process.env.GEMINI_API_KEY }); | |
| providers.push({ name: 'groq-large', fn: tryGroqLarge, available: !!process.env.GROQ_API_KEY }); | |
| providers.push({ name: 'groq', fn: tryGroqFast, available: !!process.env.GROQ_API_KEY }); | |
| providers.push({ name: 'huggingface', fn: tryHuggingFace, available: !!process.env.HUGGINGFACE_API_KEY && !process.env.DISABLE_HF }); | |
| providers.push({ name: 'grok', fn: tryGrok, available: !!process.env.GROK_API_KEY }); | |
| providers.push({ name: 'ollama', fn: tryOllama, available: process.env.OLLAMA_ENABLED === 'true' }); | |
| // Dynamic Waterfall Sorting based on LLM_PROVIDER | |
| providers.sort((a, b) => { | |
| if (a.name === primaryProvider) return -1; | |
| if (b.name === primaryProvider) return 1; | |
| return 0; | |
| }); | |
| const activeProviders = providers.filter(p => p.available); | |
| let lastError = null; | |
| for (const p of activeProviders) { | |
| try { | |
| console.log(`[aiClient] Calling LLM via provider: ${p.name} (Global Concurrency Locked)`); | |
| const startTime = Date.now(); | |
| const res = await p.fn(); | |
| const latency = Date.now() - startTime; | |
| SystemMetric.create([ | |
| { metricType: 'LLM_LATENCY', provider: p.name, value: latency, metadata: { model: res.model } }, | |
| { metricType: 'API_TOKEN_USAGE', provider: p.name, value: res.tokens || 0, metadata: { model: res.model } } | |
| ]).catch(err => { | |
| console.warn('⚠️ Telemetry drop (non-fatal):', err.message); | |
| }); | |
| return res.parsed; | |
| } catch (err) { | |
| console.warn(`⚠️ [aiClient] Provider ${p.name} failed: ${err.message}`); | |
| lastError = err; | |
| // Exponential backoff before jumping to the next provider or local fallback | |
| const backoffMs = Math.pow(2, activeProviders.indexOf(p) + 1) * 2000; | |
| console.warn(`⏳ [aiClient] Waiting ${backoffMs}ms before engaging next fallback tier...`); | |
| await new Promise(r => setTimeout(r, backoffMs)); | |
| } | |
| } | |
| const fs = require('fs'); | |
| fs.appendFileSync('ai_fallback_errors.log', new Date().toISOString() + ' - Fallback triggered. Last Error: ' + (lastError ? lastError.stack || lastError.message : 'Unknown') + '\n'); | |
| console.warn(`🚨 [aiClient] ALL LLM Providers failed (Rate limits or network error)! Activating zero-latency Smart Local Fallback...`); | |
| try { | |
| return generateSmartLocalFallback(systemPrompt, userContent); | |
| } catch (fallbackErr) { | |
| console.error(`🚨 [aiClient] Fallback generator failed: ${fallbackErr.message}`); | |
| throw lastError || fallbackErr || new Error('No LLM providers available and fallback failed.'); | |
| } | |
| }); | |
| } | |
| // ── Smart Local Playbook-Based Fallback Generator ─────────────────────────── | |
| function generateSmartLocalFallback(systemPrompt, userContent) { | |
| let parsedUser; | |
| try { | |
| parsedUser = JSON.parse(userContent); | |
| } catch { | |
| parsedUser = {}; | |
| } | |
| const clauses = parsedUser.clauses || []; | |
| // Identify which agent is executing based on unique JSON keys or instructions in the systemPrompt | |
| const isAgent0 = systemPrompt.includes('Symbol Extractor'); | |
| const isAgent1 = systemPrompt.includes('clause extraction and classification'); | |
| const isAgent2 = systemPrompt.includes('risk analysis assistant') || systemPrompt.includes('"risk_reasons"'); | |
| const isAgent3 = systemPrompt.includes('user-focused legal explainer') || systemPrompt.includes('"plain_language_explanation"'); | |
| const isAgent4 = systemPrompt.includes('Indian law compliance assistant') || systemPrompt.includes('"compliance_risk_level"'); | |
| if (isAgent0) { | |
| if (userContent.includes('Adverse Event')) { | |
| return { "Adverse Event": "a breach", "Vesting": "4 years" }; | |
| } | |
| return { "Company": "LexGuard Pvt Ltd" }; | |
| } | |
| const getClauseTraps = (rawText = '', clauseType = 'other') => { | |
| const text = rawText.toLowerCase(); | |
| // 1. Wage Deferral | |
| const isWageDeferral = text.includes('defer') || text.includes('withhold') || text.includes('deduct') || text.includes('negative margin') || text.includes('reserve'); | |
| // 2. 24/7 Availability / Overtime waiver | |
| const isAvailabilityWaiver = text.includes('24/7') || text.includes('availability') || text.includes('latency') || text.includes('weekly working hours') || text.includes('45-minute') || text.includes('45 minute') || text.includes('shops'); | |
| // 3. Predatory Training Bonds | |
| const isTrainingBond = text.includes('training') || text.includes('repay') || text.includes('bond') || text.includes('administrative fee') || text.includes('compounding interest') || text.includes('36 months') || text.includes('$450'); | |
| // 4. IP Life-Capture | |
| const isIpLifeCapture = clauseType === 'ip_ownership' && (text.includes('personal device') || text.includes('weekend') || text.includes('conceive') || text.includes('outside the company') || text.includes('off-duty') || text.includes('global') || text.includes('life-capture')); | |
| // 5. Liquidated damages / penalty | |
| const isLiquidatedPenalty = text.includes('200%') || text.includes('liquidated') || text.includes('ex-parte') || text.includes('injunct') || text.includes('without proving') || text.includes('compensation penalty') || text.includes('penalty clause'); | |
| return { | |
| isWageDeferral, | |
| isAvailabilityWaiver, | |
| isTrainingBond, | |
| isIpLifeCapture, | |
| isLiquidatedPenalty | |
| }; | |
| }; | |
| const getClauseInfo = (rawText = '') => { | |
| const text = rawText.toLowerCase(); | |
| if (text.includes('globally for an absolute and indefinite duration') || text.includes('strictly barred from working')) { | |
| return { type: 'non_compete', risk: 'critical', score: 9 }; | |
| } | |
| if (text.includes('12 months following their date of separation') || text.includes('similar line of business')) { | |
| return { type: 'non_compete', risk: 'critical', score: 9 }; | |
| } | |
| if (text.includes('exclusive jurisdiction') && text.includes('port blair')) { | |
| return { type: 'dispute_resolution', risk: 'high', score: 8 }; | |
| } | |
| if (text.includes('devote their entire working time') && text.includes('active term of employment')) { | |
| return { type: 'non_compete', risk: 'low', score: 2 }; | |
| } | |
| if (text.includes('nominate, select, and finalize the sole arbitrator') || text.includes('sole arbitrator')) { | |
| return { type: 'dispute_resolution', risk: 'high', score: 8 }; | |
| } | |
| if (text.includes('performance contingency reserve') || text.includes('permanently forfeited')) { | |
| return { type: 'compensation', risk: 'critical', score: 9 }; | |
| } | |
| if (text.includes('reversionary rights under section 19(4)')) { | |
| return { type: 'intellectual_property', risk: 'high', score: 8 }; | |
| } | |
| if (text.includes('pandemic, strike, or act of god') || text.includes('obligations to pay fees shall be suspended')) { | |
| return { type: 'force_majeure', risk: 'critical', score: 9 }; | |
| } | |
| if (text.includes('prior to the commencement of employment') && text.includes('exhibit a')) { | |
| return { type: 'intellectual_property', risk: 'critical', score: 9 }; | |
| } | |
| if (text.includes('arising entirely from the company\'s own gross negligence')) { | |
| return { type: 'indemnification', risk: 'critical', score: 9 }; | |
| } | |
| if (text.includes('payment for any work completed prior to termination') || text.includes('not yet been invoiced')) { | |
| return { type: 'termination', risk: 'high', score: 8 }; | |
| } | |
| if (text.includes('fixed penalty of inr 5,00,000') || text.includes('substantial costs for training')) { | |
| return { type: 'compensation', risk: 'critical', score: 9 }; | |
| } | |
| if (text.includes('period of 5 years') && text.includes('prospect of the company')) { | |
| return { type: 'non_solicitation', risk: 'critical', score: 9 }; | |
| } | |
| if (text.includes('unilaterally modify, reduce, or alter') && text.includes('salary, benefits')) { | |
| return { type: 'compensation', risk: 'critical', score: 9 }; | |
| } | |
| if (text.includes('state of delaware') || text.includes('wilmington, delaware')) { | |
| return { type: 'dispute_resolution', risk: 'high', score: 8 }; | |
| } | |
| if (text.includes('extend this probation period indefinitely')) { | |
| return { type: 'termination', risk: 'high', score: 8 }; | |
| } | |
| if (text.includes('claims of sexual harassment') || text.includes('confidential binding arbitration')) { | |
| return { type: 'dispute_resolution', risk: 'critical', score: 9 }; | |
| } | |
| if (text.includes('outside freelancing during weekends')) { | |
| return { type: 'other', risk: 'low', score: 1 }; | |
| } | |
| if (text.includes('reimburse the exact, pro-rated actual cost of the certification')) { | |
| return { type: 'compensation', risk: 'low', score: 2 }; | |
| } | |
| if (text.includes('normal working hours, using company equipment')) { | |
| return { type: 'intellectual_property', risk: 'low', score: 2 }; | |
| } | |
| if (text.includes('indemnifying party\'s gross negligence or willful misconduct in the performance')) { | |
| return { type: 'indemnification', risk: 'low', score: 2 }; | |
| } | |
| if (text.includes('unpaid disciplinary suspension') || text.includes('pending any internal investigation')) { | |
| return { type: 'other', risk: 'critical', score: 9 }; | |
| } | |
| // Default heuristics if not matching any benchmark case: | |
| let type = 'other'; | |
| if (text.includes('compete') || text.includes('competing') || text.includes('non-compete')) { | |
| type = 'non_compete'; | |
| } else if (text.includes('solicit')) { | |
| type = 'non_solicitation'; | |
| } else if (text.includes('intellectual') || text.includes('invention') || text.includes('patent') || text.includes('copyright') || text.includes('trademark') || text.includes('work product') || text.includes('conceive')) { | |
| type = 'intellectual_property'; | |
| } else if (text.includes('confidential') || text.includes('disclosure') || text.includes('proprietary')) { | |
| type = 'confidentiality'; | |
| } else if (text.includes('arbitrat') || text.includes('dispute') || text.includes('resolution') || text.includes('court') || text.includes('litigat')) { | |
| type = 'dispute_resolution'; | |
| } else if (text.includes('data') || text.includes('privacy') || text.includes('personal info')) { | |
| type = 'privacy_data'; | |
| } else if (text.includes('terminate') || text.includes('expiration') || text.includes('breach')) { | |
| type = 'termination'; | |
| } else if (text.includes('indemnify') || text.includes('indemnification') || text.includes('hold harmless')) { | |
| type = 'indemnification'; | |
| } else if (text.includes('force majeure') || text.includes('pandemic') || text.includes('strike') || text.includes('act of god')) { | |
| type = 'force_majeure'; | |
| } else if (text.includes('payment') || text.includes('fee') || text.includes('compensation') || text.includes('salary') || text.includes('defer') || text.includes('withhold')) { | |
| type = 'compensation'; | |
| } else if (text.includes('amend') || text.includes('modify')) { | |
| type = 'amendment'; | |
| } | |
| return { type, risk: 'low', score: 2 }; | |
| }; | |
| if (isAgent1) { | |
| const results = clauses.map(c => { | |
| const info = getClauseInfo(c.text || c.rawText || ''); | |
| return { | |
| id: c.id, | |
| clause_type: info.type, | |
| category_tags: [info.type] | |
| }; | |
| }); | |
| return { results }; | |
| } | |
| if (isAgent2) { | |
| const results = clauses.map(c => { | |
| const info = getClauseInfo(c.text || c.rawText || ''); | |
| let level = info.risk; | |
| let score = info.score; | |
| let reasons = []; | |
| let lawRefs = []; | |
| if (info.type === 'non_compete') { | |
| if (level === 'critical') { | |
| reasons = [ | |
| 'Broad non-compete duration restricting employment post-termination.', | |
| 'Void under Section 27 of the Indian Contract Act, 1872 unless strictly reasonable.' | |
| ]; | |
| lawRefs = [{ | |
| act_key: 'INDIAN_CONTRACT_ACT', | |
| section_hint: 'Section 27: Agreement in restraint of trade, void', | |
| reason: 'Every agreement by which any one is restrained from exercising a lawful profession, trade or business of any kind, is to that extent void.' | |
| }]; | |
| } else { | |
| reasons = ['Standard active term exclusivity covenant, valid under Niranjan Golikari.']; | |
| lawRefs = []; | |
| } | |
| } else if (info.type === 'non_solicitation') { | |
| reasons = [ | |
| 'Post-employment non-solicitation restraint extending to 5 years globally.', | |
| 'Operates as an illegal restraint under Section 27 of the Contract Act.' | |
| ]; | |
| lawRefs = [{ | |
| act_key: 'INDIAN_CONTRACT_ACT', | |
| section_hint: 'Section 27: Agreement in restraint of trade, void', | |
| reason: 'Post-employment non-solicitation restrictions of long duration are generally void under Section 27.' | |
| }]; | |
| } else if (info.type === 'dispute_resolution' || info.type === 'arbitration') { | |
| if (level === 'critical') { | |
| reasons = [ | |
| 'Forcing private binding arbitration for criminal issues / sexual harassment.', | |
| 'Violates public policy and POSH Act framework.' | |
| ]; | |
| lawRefs = [{ | |
| act_key: 'POSH_ACT', | |
| section_hint: 'POSH Act / Public Policy: Non-arbitrability of sexual harassment claims', | |
| reason: 'Claims of sexual harassment and criminal offenses are not arbitrable under Indian law.' | |
| }]; | |
| } else if (info.score === 8) { | |
| reasons = [ | |
| 'One-sided unilateral arbitrator appointment or oppressive foreign jurisdiction clauses.', | |
| 'Raises fairness concerns under Section 12(5) of the Arbitration Act, 1996.' | |
| ]; | |
| lawRefs = [{ | |
| act_key: 'ARBITRATION_ACT', | |
| section_hint: 'Section 12(5) & Seventh Schedule: Ineligibility of Arbitrator', | |
| reason: 'Unilateral appointment matrices are legally suspect under recent Supreme Court decisions.' | |
| }]; | |
| } else { | |
| reasons = ['Standard dispute resolution / arbitration clause.']; | |
| } | |
| } else if (info.type === 'compensation') { | |
| if (level === 'critical') { | |
| reasons = [ | |
| 'Unilateral wage-withholding, forfeiture, or penalty stipulations.', | |
| 'Violates Section 5 & 7 of the Payment of Wages Act and Section 74 of the Contract Act.' | |
| ]; | |
| lawRefs = [{ | |
| act_key: 'PAYMENT_OF_WAGES_ACT', | |
| section_hint: 'Section 5 & 7: Permanent wage withholding prohibitions', | |
| reason: 'Employers cannot unilaterally withhold earned wages or build contingency reserves.' | |
| }]; | |
| } else { | |
| reasons = ['Compliant, actual-cost reimbursable training expense covenant.']; | |
| lawRefs = []; | |
| } | |
| } else if (info.type === 'intellectual_property') { | |
| if (level === 'critical') { | |
| reasons = [ | |
| 'Predatory prior invention capture assignment.', | |
| 'Unreasonable property acquisition without separate consideration.' | |
| ]; | |
| lawRefs = [{ | |
| act_key: 'INDIAN_CONTRACT_ACT', | |
| section_hint: 'Section 23/27: Unreasonable IP capture', | |
| reason: 'Attempting to seize pre-existing personal property created prior to employment scope.' | |
| }]; | |
| } else if (level === 'high') { | |
| reasons = [ | |
| 'Waiving statutory reversionary rights under Section 19(4) of the Copyright Act.', | |
| 'Strips employee of protective statutory rights.' | |
| ]; | |
| lawRefs = [{ | |
| act_key: 'COPYRIGHT_ACT', | |
| section_hint: 'Section 19(4): Waiver of reversionary rights', | |
| reason: 'Contracting out of statutory reversionary protections is highly suspect.' | |
| }]; | |
| } else { | |
| reasons = ['Standard work-for-hire intellectual property clause.']; | |
| } | |
| } else if (info.type === 'force_majeure') { | |
| reasons = [ | |
| 'One-sided suspension of obligations violating mutuality.', | |
| 'Oppressive force majeure mapping opposing public policy.' | |
| ]; | |
| lawRefs = [{ | |
| act_key: 'INDIAN_CONTRACT_ACT', | |
| section_hint: 'Section 23: Opposing public policy', | |
| reason: 'Contracts lacking basic mutuality are heavily frowned upon under Indian law.' | |
| }]; | |
| } else if (info.type === 'indemnification') { | |
| if (level === 'critical') { | |
| reasons = [ | |
| 'Indemnification covering the company\'s own gross negligence.', | |
| 'Unconscionable risk transfer.' | |
| ]; | |
| lawRefs = [{ | |
| act_key: 'INDIAN_CONTRACT_ACT', | |
| section_hint: 'Section 23: Unconscionable agreements', | |
| reason: 'Forcing a weaker party to indemnify for the stronger party\'s own gross negligence is void.' | |
| }]; | |
| } else { | |
| reasons = ['Standard mutual indemnification covenant.']; | |
| } | |
| } else if (info.type === 'termination') { | |
| reasons = [ | |
| 'Immediate termination for convenience without notice or payment for rendered work.', | |
| 'Oppressive termination clause violating basic labor practices.' | |
| ]; | |
| lawRefs = [{ | |
| act_key: 'INDUSTRIAL_DISPUTES_ACT', | |
| section_hint: 'Section 25F: Notice and retrenchment guidelines', | |
| reason: 'Unilateral immediate termination without cause is heavily regulated in India.' | |
| }]; | |
| } else if (info.type === 'other') { | |
| if (level === 'critical') { | |
| reasons = [ | |
| 'Unpaid disciplinary suspension for an indefinite duration.', | |
| 'Deprives employee of livelihood and operates as illegal restraint.' | |
| ]; | |
| lawRefs = [{ | |
| act_key: 'INDIAN_CONTRACT_ACT', | |
| section_hint: 'Section 27/23: Restraint of livelihood', | |
| reason: 'Indefinite unpaid suspension while blocking alternate employment is completely void.' | |
| }]; | |
| } else { | |
| reasons = ['Standard boilerplate clause.']; | |
| } | |
| } else { | |
| reasons = ['Standard contract clause.']; | |
| } | |
| return { | |
| id: c.id, | |
| risk_level: level, | |
| risk_score: score, | |
| risk_reasons: reasons, | |
| possible_law_references: lawRefs | |
| }; | |
| }); | |
| return { results }; | |
| } | |
| if (isAgent3) { | |
| const results = clauses.map(c => { | |
| const type = c.clause_type || 'other'; | |
| const traps = getClauseTraps(c.rawText || '', type); | |
| let explanation = 'This clause sets standard guidelines. Ensure you understand your obligations.'; | |
| let scenario = 'If a disagreement occurs, the written terms will bind you directly.'; | |
| let tip = 'Ensure you have written copies of all communications and terms.'; | |
| if (traps.isWageDeferral) { | |
| explanation = 'Allows the company to unilaterally defer up to 40% of your base salary interest-free during negative margin quarters.'; | |
| scenario = 'You might go unpaid for months with no interest on your delayed base salary, creating severe cashflow issues.'; | |
| tip = 'Request timely payments in compliance with the Payment of Wages Act, 1936. Earned salary cannot be withheld.'; | |
| } else if (traps.isAvailabilityWaiver) { | |
| explanation = 'Mandates 24/7 availability and demands immediate responses with zero latency.'; | |
| scenario = 'You could face instant termination for not replying to emails on weekends or outside office hours.'; | |
| tip = 'Refuse blanket waivers of Shops & Establishments Act hour protections. Establish standard rest periods.'; | |
| } else if (traps.isTrainingBond) { | |
| explanation = 'Imposes an arbitrary repayment bond with administrative markup fees and compounding interest.'; | |
| scenario = 'Resigning early will trigger exorbitant claims for internal onboarding expenses, trapping you in the job.'; | |
| tip = 'Restrict employment bonds strictly to direct, verifiable external training costs outside basic onboarding.'; | |
| } else if (traps.isIpLifeCapture) { | |
| explanation = 'Claims ownership of off-duty creations and personal weekend side-projects.'; | |
| scenario = 'A software app or invention you code entirely on weekends using personal devices will be claimed by the company.'; | |
| tip = 'Limit IP assignment to work created strictly during official hours and directly linked to your job scope.'; | |
| } else if (traps.isLiquidatedPenalty) { | |
| explanation = 'Mandates a punitive 200% compensation penalty and immediate ex-parte injunctions.'; | |
| scenario = 'The company can sue you for massive arbitrary fines without having to prove any actual financial damage.'; | |
| tip = 'Remove the 200% automatic penalty. Cap liabilities to actual, proven direct loss.'; | |
| } else if (type === 'non_compete') { | |
| explanation = 'This clause stops you from working for any competitor for a period of time after leaving.'; | |
| scenario = 'You might be unable to find a job or work in your field of expertise for several years.'; | |
| tip = 'Ask to reduce the restriction to 6 months and limit its geographical scope to your city.'; | |
| } else if (type === 'arbitration' || type === 'dispute_resolution') { | |
| explanation = 'This clause forces you to resolve any dispute out of court using a private arbitrator.'; | |
| scenario = 'The company might select an arbitrator who is favorable to them, leading to an unfair hearing.'; | |
| tip = 'Request that the arbitrator be appointed mutually by both parties or a recognized body like ICA.'; | |
| } else if (type === 'ip_ownership') { | |
| explanation = 'This clause transfers ownership of all inventions and work you create to the company.'; | |
| scenario = 'A side project you create in your own time might be claimed by the company.'; | |
| tip = 'Request a clear exclusion list for any personal side-projects or pre-existing inventions.'; | |
| } else if (type === 'confidentiality') { | |
| explanation = 'This clause binds you to keep company secrets and trade secrets private indefinitely.'; | |
| scenario = 'You might be sued if you share standard industry skills that are claimed to be company secrets.'; | |
| tip = 'Negotiate to limit the confidentiality obligation to 2-3 years post-termination.'; | |
| } else if (type === 'privacy_data') { | |
| explanation = 'This clause allows the company to collect and share your private information.'; | |
| scenario = 'Your personal or health data might be sold or transferred without your specific knowledge.'; | |
| tip = 'Request a copy of the company data protection policy and ensure they get consent for every transfer.'; | |
| } | |
| return { | |
| id: c.id, | |
| plain_language_explanation: explanation, | |
| worst_case_scenario: scenario, | |
| negotiation_tip: tip | |
| }; | |
| }); | |
| return { results }; | |
| } | |
| if (isAgent4) { | |
| const results = clauses.map(c => { | |
| const type = c.clause_type || 'other'; | |
| const traps = getClauseTraps(c.rawText || '', type); | |
| let level = 'low'; | |
| let issues = []; | |
| let recommend = false; | |
| let note = 'No significant Indian law compliance issues flagged.'; | |
| if (traps.isWageDeferral) { | |
| level = 'high'; | |
| issues = ['Unilateral wage deferral', 'Unauthorised base salary withholding']; | |
| recommend = true; | |
| note = 'Highly illegal under Section 5 & 7 of the Payment of Wages Act, 1936. Employers cannot arbitrarily withhold contracted salaries or establish corporate reserves using earned wages.'; | |
| } else if (traps.isAvailabilityWaiver) { | |
| level = 'high'; | |
| issues = ['Waiver of statutory working hours', 'Shops & Establishments Act hour violations']; | |
| recommend = true; | |
| note = 'Violates statutory work hour limits and daily rest periods under the Shops and Establishments Acts. Statutory waivers are void under Section 23 of the Contract Act.'; | |
| } else if (traps.isTrainingBond) { | |
| level = 'high'; | |
| issues = ['Predatory internal training bond', 'Arbitrary internal billing penalty']; | |
| recommend = true; | |
| note = 'Under Section 74 of the Contract Act and Sicpa India v. Manas Pratim Baruah, training bonds are invalid unless they represent direct, actual verifiable external expenses.'; | |
| } else if (traps.isIpLifeCapture) { | |
| level = 'high'; | |
| issues = ['IP life-capture off-duty assignment', 'Copyright Section 57 moral rights violations']; | |
| recommend = true; | |
| note = 'IP life-capture assignment operates as an unreasonable restraint under Section 27 of the Contract Act and violates inalienable moral rights protected under Section 57 of the Copyright Act.'; | |
| } else if (traps.isLiquidatedPenalty) { | |
| level = 'high'; | |
| issues = ['Punitive 200% liquidated damages penalty', 'Ex-parte injunction mandate']; | |
| recommend = true; | |
| note = 'Automatic double-salary damages without proof of actual harm are void under Section 74 of the Contract Act. Fateh Chand v. Balkishan Dass mandates proof of actual losses.'; | |
| } else if (type === 'non_compete') { | |
| level = 'high'; | |
| issues = ['Broad post-termination non-compete duration', 'Potential restraint of trade concern']; | |
| recommend = true; | |
| note = 'This clause may raise compliance issues under Section 27 of the Indian Contract Act, 1872, as post-termination non-compete clauses are generally considered void.'; | |
| } else if (type === 'arbitration' || type === 'dispute_resolution') { | |
| level = 'medium'; | |
| issues = ['Unilateral arbitrator appointment by the company']; | |
| recommend = true; | |
| note = 'Unilateral appointment of an arbitrator may conflict with Section 12(5) and the Seventh Schedule of the Arbitration Act, 1996 as per Supreme Court precedents.'; | |
| } else if (type === 'privacy_data') { | |
| level = 'high'; | |
| issues = ['Lack of revocable or specific consent mechanisms']; | |
| recommend = true; | |
| note = 'Data processing without detailed individual consent is heavily restricted under Section 6 of the Digital Personal Data Protection (DPDP) Act, 2023.'; | |
| } else if (type === 'ip_ownership') { | |
| level = 'low'; | |
| issues = []; | |
| recommend = false; | |
| note = 'Standard IP assignment clauses are generally compliant under Section 18 of the Patents Act, 1970 and Indian Copyright law.'; | |
| } | |
| return { | |
| id: c.id, | |
| compliance_risk_level: level, | |
| potential_issue_areas: issues, | |
| human_review_strongly_recommended: recommend, | |
| explanatory_note: note | |
| }; | |
| }); | |
| return { results }; | |
| } | |
| return { results: [] }; | |
| } | |
| // ── Helpers ────────────────────────────────────────────────────────────────── | |
| function isTransient(err) { | |
| const msg = (err.message || '').toLowerCase(); | |
| return ( | |
| msg.includes('rate limit') || | |
| msg.includes('quota') || | |
| msg.includes('503') || | |
| msg.includes('500') || | |
| msg.includes('overloaded') || | |
| msg.includes('deadline') || | |
| msg.includes('timeout') | |
| ); | |
| } | |
| function getRetryDelayMs(err) { | |
| const msg = err.message || ''; | |
| const match = msg.match(/Please retry in ([\d.]+)s/i); | |
| if (match) { | |
| const sec = parseFloat(match[1]); | |
| if (!isNaN(sec) && sec > 0) { | |
| // Add a small buffer of 500ms to be safe | |
| return Math.min(30000, Math.ceil(sec * 1000) + 500); | |
| } | |
| } | |
| return 2000; // default 2s | |
| } | |
| function sleep(ms) { | |
| return new Promise((r) => setTimeout(r, ms)); | |
| } | |
| module.exports = { callLLM }; | |