{
  "id": "OyQ7KKD3schOSm3I",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Legal Contract Risk Analyser \u2014 Hybrid RAG + AI Risk Scoring",
  "tags": [],
  "nodes": [
    {
      "id": "5be1fe40-fab4-47cf-b4c0-c9a14c9e7eba",
      "name": "Extract from File",
      "type": "n8n-nodes-base.extractFromFile",
      "position": [
        1152,
        160
      ],
      "parameters": {
        "options": {},
        "operation": "pdf",
        "binaryPropertyName": "file"
      },
      "typeVersion": 1.1
    },
    {
      "id": "ce33d8d0-0827-49d4-9c2f-847740f723cc",
      "name": "Classify Clause Type",
      "type": "@n8n/n8n-nodes-langchain.googleGemini",
      "position": [
        1520,
        192
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "models/gemini-2.5-pro",
          "cachedResultName": "models/gemini-2.5-pro"
        },
        "options": {},
        "messages": {
          "values": [
            {
              "content": "=You are an expert legal contract classifier. Your job is to identify the clause type and assess risk.\n\nCLAUSE TYPE DEFINITIONS:\n\n1. **INDEMNIFICATION**: One party agrees to pay for losses, damages, or legal costs if the other party gets sued or incurs liability. Keywords: indemnify, hold harmless, defend, claims, damages, third-party. RISK: Usually HIGH \u2014 can expose party to unlimited liability.\n\n2. **LIMITATION OF LIABILITY**: Caps the amount one party can be sued for, or excludes certain types of damages (indirect, consequential, punitive). Keywords: liability cap, not liable for, excluding, except for, indirect damages, lost profits. RISK: Varies \u2014 favorable if you're the party protected, dangerous if you're the one paying.\n\n3. **TERMINATION**: Specifies how and when the contract can end, notice periods, and post-termination obligations. Keywords: terminate, cancellation, end date, term, notice period, renewal, survival. RISK: MEDIUM if either party can terminate without cause; HIGH if one party can terminate unilaterally.\n\n4. **INTELLECTUAL PROPERTY**: Defines who owns inventions, patents, trademarks, copyrights, or work product created during the contract. Keywords: ownership, IP, patent, trademark, copyright, work for hire, assignment, license, derivative works. RISK: HIGH \u2014 can lose valuable IP rights permanently.\n\n5. **PAYMENT / FEES / COMPENSATION**: Specifies pricing, payment terms, invoicing, late payment penalties, and payment methods. Keywords: price, fee, cost, payment, invoice, net 30, due date, interest, deposit. RISK: LOW typically, unless payment is contingent on subjective satisfaction.\n\n6. **CONFIDENTIALITY / NDA**: Requires keeping sensitive information secret, specifies how long the obligation lasts, and permitted disclosures. Keywords: confidential, NDA, non-disclosure, keep secret, proprietary, don't disclose, return documents. RISK: MEDIUM \u2014 breaches can result in litigation; indefinite obligations are concerning.\n\n7. **GOVERNING LAW / JURISDICTION**: Specifies which state/country's laws apply to the contract and which courts have jurisdiction. Keywords: governed by, jurisdiction, courts, New York law, Delaware law, California, venue, arbitration. RISK: MEDIUM \u2014 unfavorable jurisdiction can make disputes costly.\n\n8. **FORCE MAJEURE**: Excuses non-performance when unforeseeable events (natural disasters, war, pandemics) prevent one party from performing. Keywords: force majeure, act of God, unforeseen circumstances, pandemic, war, natural disaster, excuse performance. RISK: LOW typically, unless narrowly defined.\n\n9. **WARRANTY / REPRESENTATIONS**: One party makes factual claims about quality, functionality, non-infringement, or legal status. Includes both express warranties and implied warranties disclaimed. Keywords: warranty, represent, guarantee, warrants, fitness for purpose, merchantability, as-is, no warranty, disclaim. RISK: HIGH if warranties are disclaimed with \"as-is\" language; LOW if strong warranties are offered.\n\n10. **OTHER**: Clauses that don't fit the above categories (severability, entire agreement, amendments, assignment, notices, definitions, boilerplate). Keywords: entire agreement, severability, notices, amendments, further assurances, counterparts.\n\nEXAMPLES:\n\nExample 1 - INDEMNIFICATION (HIGH risk):\n\"The Client shall indemnify, defend and hold harmless the Vendor from any and all claims, damages, or attorney fees arising out of Client's use of the Services.\"\n\u2192 clause_type: \"indemnification\"\n\u2192 key_terms: [\"indemnify\", \"hold harmless\", \"any and all claims\", \"damages\"]\n\u2192 risk_preliminary: \"HIGH\"\n\nExample 2 - PAYMENT (LOW risk):\n\"Vendor shall invoice Client monthly. Payment is due net 30 days from invoice date. Late payments accrue interest at 1.5% per month.\"\n\u2192 clause_type: \"payment\"\n\u2192 key_terms: [\"invoice\", \"net 30\", \"payment\", \"interest\"]\n\u2192 risk_preliminary: \"LOW\"\n\nExample 3 - WARRANTY (HIGH risk):\n\"Vendor provides no warranty. All services provided as-is without any representations or guarantees of fitness for purpose.\"\n\u2192 clause_type: \"warranty\"\n\u2192 key_terms: [\"no warranty\", \"as-is\", \"no representations\", \"fitness for purpose disclaimed\"]\n\u2192 risk_preliminary: \"HIGH\"\n\nExample 4 - INTELLECTUAL PROPERTY (HIGH risk):\n\"All work product created by Contractor shall be irrevocably assigned to Company on a perpetual, worldwide, royalty-free basis. Contractor waives all moral rights.\"\n\u2192 clause_type: \"intellectual_property\"\n\u2192 key_terms: [\"irrevocably assigned\", \"perpetual\", \"worldwide\", \"waives moral rights\"]\n\u2192 risk_preliminary: \"HIGH\"\n\nINSTRUCTIONS:\n1. Read the clause carefully\n2. Identify which category it BEST fits (choose ONE)\n3. Extract 3-5 key legal terms that appear in the clause\n4. Assess preliminary risk:\n   - HIGH: Unlimited liability, irrevocable rights waived, unilateral termination, IP assignment without limitation, \"as-is\" warranties\n   - MEDIUM: Capped liability, mutual termination, time-limited confidentiality, standard payment terms\n   - LOW: Boilerplate, definitions, standard force majeure, balanced provisions\n\nRespond ONLY in valid JSON \u2014 absolutely NO markdown, NO backticks, NO preamble:\n\n{\n  \"clause_type\": \"one of: indemnification, limitation_of_liability, termination, intellectual_property, payment, confidentiality, governing_law, force_majeure, warranty, other\",\n  \"key_terms\": [\"term1\", \"term2\", \"term3\"],\n  \"risk_preliminary\": \"HIGH|MEDIUM|LOW\"\n}\n\nNow classify the user clause:\n{{ $json.text }}"
            }
          ]
        },
        "jsonOutput": true,
        "builtInTools": {}
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1.1
    },
    {
      "id": "cd3a2bca-acd0-4ef5-8891-6cbe35067406",
      "name": "extract JSON from Gemini response",
      "type": "n8n-nodes-base.code",
      "position": [
        1824,
        192
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Get Gemini classification result\nconst raw = $json.content.parts[0].text;\nconst cleaned = raw.replace(/```json|```/g, '').trim();\nconst parsed = JSON.parse(cleaned);\n\n// Reach back to Clause Splitter node for original clause data\nconst clauseData = $('Clause Splitter').item.json;\n\nreturn {\n  json: {\n    job_id: clauseData.job_id,\n    clause_id:        clauseData.clause_id,\n    text:             clauseData.text,\n    source_doc:       clauseData.source_doc,\n    char_count:       clauseData.char_count,\n    clause_type:      parsed.clause_type,\n    key_terms:        parsed.key_terms,\n    risk_preliminary: parsed.risk_preliminary\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "d3871e3c-1956-42ac-bbcf-eba3325bd4f6",
      "name": "Generate Embedding",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2256,
        176
      ],
      "parameters": {
        "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent?key=YOUR_TOKEN_HERE",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"model\": \"models/gemini-embedding-001\",\n  \"content\": {\n    \"parts\": [{ \n      \"text\": {{ JSON.stringify($json.text) }}\n    }]\n  },\n  \"outputDimensionality\": 3072\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "b09e890a-43ee-40b0-b585-2d8c961b87ed",
      "name": "Prepare Embedding Request",
      "type": "n8n-nodes-base.code",
      "position": [
        2016,
        176
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Mode: Run for Each Item\n// Get text safely with a fallback\nconst rawText = $json.text || $json.clause_id || '';\n\nconst cleanText = rawText\n  .replace(/[\\n\\r\\t]/g, ' ')\n  .replace(/\\s+/g, ' ')\n  .trim();\n\nreturn {\n  json: {\n    job_id: $json.job_id,\n    clause_id:        $json.clause_id,\n    text:             rawText,\n    source_doc:       $json.source_doc,\n    char_count:       $json.char_count,\n    clause_type:      $json.clause_type,\n    key_terms:        $json.key_terms,\n    risk_preliminary: $json.risk_preliminary,\n    embedding_body:   JSON.stringify({\n      model: \"models/gemini-embedding-001\",\n      content: {\n        parts: [{ text: cleanText }]\n      },\n      outputDimensionality: 3072\n    })\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "cf02e61f-0650-489a-b17d-51d9ce49a0eb",
      "name": "Extract Embedding",
      "type": "n8n-nodes-base.code",
      "position": [
        2512,
        192
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Get embedding from current node (Generate Embedding output)\nconst embedding = $json.embedding.values;\n\n// Get all other fields from Prepare Embedding Request node\nconst prev = $('Prepare Embedding Request').item.json;\n\nreturn {\n  json: {\n    job_id:           prev.job_id,\n    clause_id:        prev.clause_id,\n    text:             prev.text,\n    source_doc:       prev.source_doc,\n    char_count:       prev.char_count,\n    clause_type:      prev.clause_type,\n    key_terms:        prev.key_terms,\n    risk_preliminary: prev.risk_preliminary,\n    embedding:        embedding\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "9a103bb7-bbdd-49a6-9bad-3cae22548011",
      "name": "Insert to Supabase",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2992,
        80
      ],
      "parameters": {
        "url": "https://kwfhbdrqlfnzlixvbpsl.supabase.co/rest/v1/legal_clauses",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ JSON.stringify({\n  id:          $json.clause_id,\n  text:        $json.text,\n  clause_type: $json.clause_type,\n  risk_level:  $json.risk_preliminary,\n  key_terms:   $json.key_terms,\n  source_doc:  $json.source_doc,\n  embedding:   $json.embedding\n}) }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "apikey",
              "value": "={{ $credentials.supabaseApiKey }}"
            },
            {
              "name": "Authorization",
              "value": "Bearer {{ $credentials.supabaseApiKey }}"
            },
            {
              "name": "Prefer",
              "value": "resolution=merge-duplicates"
            }
          ]
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.4
    },
    {
      "id": "8633db9f-6730-401e-8a48-43b7a37d47ad",
      "name": "Vector Search",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2912,
        336
      ],
      "parameters": {
        "url": "https://kwfhbdrqlfnzlixvbpsl.supabase.co/rest/v1/rpc/match_clauses",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ JSON.stringify({\n  query_embedding: $json.embedding,\n  match_threshold: 0.70,\n  match_count: 5\n}) }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "apikey",
              "value": "={{ $credentials.supabaseApiKey }}"
            },
            {
              "name": "Authorization",
              "value": "Bearer {{ $credentials.supabaseApiKey }}"
            }
          ]
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "69cf0843-362e-4ac6-b927-14a593f6183b",
      "name": "Wait",
      "type": "n8n-nodes-base.wait",
      "position": [
        2752,
        80
      ],
      "parameters": {},
      "typeVersion": 1.1
    },
    {
      "id": "d8477444-7f7a-4f88-90dc-2fe266155fdf",
      "name": "Keyword Search",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2912,
        576
      ],
      "parameters": {
        "url": "https://kwfhbdrqlfnzlixvbpsl.supabase.co/rest/v1/rpc/keyword_search_clauses",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ JSON.stringify({search_query: $json.text, match_count: 5}) }}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "apikey",
              "value": "={{ $credentials.supabaseApiKey }}"
            },
            {
              "name": "Authorization",
              "value": "Bearer {{ $credentials.supabaseApiKey }}"
            }
          ]
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "fd2166a0-51da-4a94-82cd-555823a4eaee",
      "name": "Merge",
      "type": "n8n-nodes-base.merge",
      "position": [
        3376,
        352
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "77db0e0e-825a-4163-9b2d-9dbeafa090c1",
      "name": "Clause Splitter",
      "type": "n8n-nodes-base.code",
      "position": [
        1344,
        448
      ],
      "parameters": {
        "jsCode": "// Mode: Run Once for All Items\n\nconst allInputs = $input.all();\n\n// Get text from Extract from File node output\nconst fullText = $input.first().json.text;\n\n// With Webhook node, filename is in binary metadata\nlet docName = 'unknown_doc';\n\nfor (let item of allInputs) {\n  // Webhook sends file as binary \u2014 filename is here\n  if (item.binary?.file?.fileName) {\n    docName = item.binary.file.fileName;\n    break;\n  }\n  // Fallback: check mimeType metadata\n  if (item.binary?.file?.fileExtension) {\n    docName = `contract.${item.binary.file.fileExtension}`;\n    break;\n  }\n}\n\nconst pattern = /(?=\\n\\s*(?:\\d+\\.|\\d+\\.\\d+|Article\\s+\\w+|Clause\\s+\\d+|Section\\s+\\d+)\\s)/gi;\nconst rawClauses = fullText.split(pattern);\nconst clauses = rawClauses.filter(c => c.trim().length > 80);\n\nreturn clauses.map((clause, i) => ({\n  json: {\n    ...$input.all().find(i => i.json.body?.job_id)?.json?.body,   // \u2705 THIS LINE FIXES EVERYTHING\n\n    clause_id: `${docName.replace(/\\.pdf$/i,'').replace(/\\s+/g,'_')}_clause_${i+1}`,\n    text: clause.replace(/[\\n\\r\\t]/g,' ').replace(/\\s+/g,' ').replace(/\"/g,\"'\").trim(),\n    source_doc: docName,\n    char_count: clause.length\n  }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "71dde4b7-ebd0-4b23-af50-61692e4f9677",
      "name": "Merge1",
      "type": "n8n-nodes-base.merge",
      "position": [
        1152,
        448
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "ccdb1d69-9aee-4b1d-802c-e1f72b2b0b95",
      "name": "Vector",
      "type": "n8n-nodes-base.set",
      "position": [
        3120,
        336
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "13e1eb42-a915-48ce-8114-be1da890d049",
              "name": "source",
              "type": "string",
              "value": "vector"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "735ec30f-c747-4003-b451-3485101788ef",
      "name": "BM25",
      "type": "n8n-nodes-base.set",
      "position": [
        3120,
        576
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "13e1eb42-a915-48ce-8114-be1da890d049",
              "name": "source",
              "type": "string",
              "value": "bm25"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "20c3be78-92e7-46e8-87a4-bf1c7464c323",
      "name": "RRF Reranker",
      "type": "n8n-nodes-base.code",
      "position": [
        3632,
        352
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Mode: Run for Each Item\n\nconst typeBoost = {\n  indemnification: 1.8,\n  limitation_of_liability: 1.7,\n  termination: 1.4,\n  intellectual_property: 1.3,\n  governing_law: 1.2,\n  payment: 1.0,\n  other: 0.8\n};\n\nconst boost = typeBoost[$json.clause_type] || 1.0;\nconst similarity = $json.similarity || 0;\nconst k = 60;\nconst rrf_score = (1 / (k + similarity)) * boost;\n\nreturn {\n  json: {\n    // Explicitly map ALL fields from Supabase/Vector Search\n    job_id:       $json.job_id,\n    id:           $json.id,\n    text:         $json.text,\n    clause_type:  $json.clause_type,\n    risk_level:   $json.risk_level,\n    key_terms:    $json.key_terms,\n    source_doc:   $json.source_doc,\n    similarity:   $json.similarity || 0,\n    source:       $json.source || 'vector',\n    // Add RRF score\n    rrf_score:    rrf_score\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "b5d79d2f-1599-4bee-8c0f-6241d9b9d9d5",
      "name": "Risk Scorer",
      "type": "n8n-nodes-base.code",
      "position": [
        3872,
        352
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Mode: Run for Each Item\n\nconst HIGH = [\n  /unlimited\\s+liability/i,\n  /indemnify.*all.*claims/i,\n  /irrevocable/i,\n  /sole\\s+discretion/i,\n  /waive.*right/i,\n  /without.*limitation/i,\n  /any\\s+and\\s+all\\s+claims/i,\n  /perpetual.*license/i,\n  /unilateral.*termination/i,\n  /no\\s+liability.*whatsoever/i\n];\n\nconst MEDIUM = [\n  /reasonable\\s+efforts/i,\n  /no\\s+warranty/i,\n  /as\\s+is/i,\n  /may\\s+terminate/i,\n  /limitation\\s+of\\s+liability/i,\n  /binding\\s+arbitration/i,\n  /force\\s+majeure/i,\n  /payment\\s+default/i,\n  /confidential\\s+information/i\n];\n\nlet risk = 'LOW';\nlet triggered = [];\n\nfor (const pattern of HIGH) {\n  if (pattern.test($json.text)) {\n    risk = 'HIGH';\n    triggered.push(pattern.source);\n  }\n}\n\nif (risk !== 'HIGH') {\n  for (const pattern of MEDIUM) {\n    if (pattern.test($json.text)) {\n      risk = 'MEDIUM';\n      triggered.push(pattern.source);\n    }\n  }\n}\n\nreturn {\n  json: {\n    ...$json,        // \u2190 THIS spreads ALL fields from previous nodes\n    regex_risk: risk,\n    triggered_patterns: triggered,\n    pattern_count: triggered.length\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "99a4ff61-0989-48dd-bbdf-9db3582a97ea",
      "name": "AI Agent",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        4432,
        336
      ],
      "parameters": {
        "text": "=Analyze this contract clause and provide risk assessment.\n\nClause ID: {{ $json.id }}\nClause Type: {{ $json.clause_type }}\nRegex Risk Level: {{ $json.regex_risk }}\n\nClause Text:\n{{ $json.text }}\n\nRespond with ONLY the JSON object, no other text.",
        "options": {
          "systemMessage": "You are a legal contract risk analysis expert. Your task is to analyze contract clauses and provide structured risk assessments.\n\nFor each clause provided, respond ONLY with a valid JSON object (no markdown, no backticks, no preamble, no explanation before or after) containing:\n\n{\n  \"risk_level\": \"HIGH\" or \"MEDIUM\" or \"LOW\",\n  \"confidence\": 0.0 to 1.0 (how certain you are),\n  \"plain_english\": \"2-3 sentence explanation of what this clause means in simple terms\",\n  \"risk_reason\": \"Specific reasons why this is risky (only if HIGH or MEDIUM, leave empty if LOW)\",\n  \"safer_alternative\": \"Suggested safer wording or modifications (only if HIGH or MEDIUM, leave empty if LOW)\",\n  \"key_obligations\": [\"list\", \"of\", \"key\", \"obligations\", \"from\", \"this\", \"clause\"],\n  \"legal_area\": \"Category like Indemnification, Liability, IP Rights, Payment, Termination, Warranty, Confidentiality, etc.\"\n}\n\nImportant rules:\n- Respond ONLY with the JSON object, nothing else\n- Do not include markdown formatting\n- Do not include backticks (```)\n- Do not include any explanations before or after the JSON\n- Be concise and professional\n- Focus on actual legal risk to the client, not general concerns\n- Only flag items that pose real business risk\n- Provide actionable suggestions that reduce risk\n- Use 0.8+ confidence only if very certain\n- Use 0.5-0.7 confidence if somewhat uncertain\n- Do not be alarmist; only flag genuine risks"
        },
        "promptType": "define"
      },
      "retryOnFail": true,
      "typeVersion": 3.1
    },
    {
      "id": "4229accc-3586-4b58-aee7-9360366bb145",
      "name": "Prepare LLM data",
      "type": "n8n-nodes-base.set",
      "position": [
        4128,
        352
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "13e1eb42-a915-48ce-8114-be1da890d049",
              "name": "id",
              "type": "string",
              "value": "={{ $json.id }}"
            },
            {
              "id": "45dd3285-c1ff-48db-a567-cc23e001ac83",
              "name": "text",
              "type": "string",
              "value": "={{ $json.text }}"
            },
            {
              "id": "413a2b69-2347-479d-99ed-7535e7d65271",
              "name": "clause_type",
              "type": "string",
              "value": "={{ $json.clause_type }}"
            },
            {
              "id": "f8bcf8cc-3249-48b0-a7f1-9c5453255446",
              "name": "source_doc",
              "type": "string",
              "value": "={{ $json.source_doc }}"
            },
            {
              "id": "fb12d70f-2f90-40e6-a4df-fa5f254a179a",
              "name": "similarity ",
              "type": "number",
              "value": "={{ $json.similarity }}"
            },
            {
              "id": "1c8dfe60-a570-442f-b397-1171ff104b2c",
              "name": "rrf_score",
              "type": "number",
              "value": "={{ $json.rrf_score }}"
            },
            {
              "id": "48df4e98-82a0-4040-8129-34407dabe9d1",
              "name": "regex_risk",
              "type": "string",
              "value": "={{ $json.regex_risk }}"
            },
            {
              "id": "c96f669e-5635-49b7-8d76-049297fd3583",
              "name": "key_terms",
              "type": "array",
              "value": "={{ $json.key_terms }}"
            },
            {
              "id": "b0b7aa28-d931-42c0-a2e9-0eb61ec5de1b",
              "name": "job_id",
              "type": "string",
              "value": "={{ $json.job_id }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "3384ab55-78b3-45e7-b706-b991546a93cf",
      "name": "Extract LLM Response",
      "type": "n8n-nodes-base.code",
      "position": [
        4768,
        336
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Mode: Run for Each Item\n\ntry {\n  // Get response from AI Agent\n  let raw = '';\n  \n  if ($json.output) {\n    raw = $json.output;\n  } else if ($json.text) {\n    raw = $json.text;\n  } else if ($json.response) {\n    raw = $json.response;\n  } else if ($json.message) {\n    raw = $json.message;\n  }\n\n  const cleaned = raw.replace(/```json|```/g, '').trim();\n  const parsed = JSON.parse(cleaned);\n\n  // Get data from Prepare LLM data Set node\n  const prev = $('Prepare LLM data').item.json;\n\n  return {\n    json: {\n      job_id:             prev.job_id,\n      clause_id:          prev.id,\n      text:               prev.text,\n      clause_type:        prev.clause_type,\n      source_doc:         prev.source_doc || 'unknown',\n      similarity:         prev.similarity || 0,\n      rrf_score:          prev.rrf_score || 0,\n      regex_risk:         prev.regex_risk,\n      risk_level:         parsed.risk_level || 'LOW',\n      confidence:         parsed.confidence || 0.5,\n      plain_english:      parsed.plain_english || '',\n      risk_reason:        parsed.risk_reason || '',\n      safer_alternative:  parsed.safer_alternative || '',\n      key_obligations:    Array.isArray(parsed.key_obligations) ? parsed.key_obligations : [],\n      legal_area:         parsed.legal_area || 'Other'\n    }\n  };\n} catch (error) {\n  const prev = $('Prepare LLM data').item.json;\n  return {\n    json: {\n      job_id:             prev.job_id,\n      clause_id:          prev.id,\n      text:               prev.text,\n      clause_type:        prev.clause_type,\n      source_doc:         prev.source_doc || 'unknown',\n      similarity:         prev.similarity || 0,\n      rrf_score:          prev.rrf_score || 0,\n      regex_risk:         prev.regex_risk,\n      risk_level:         prev.regex_risk,\n      confidence:         0.6,\n      plain_english:      'Unable to generate detailed analysis',\n      risk_reason:        'LLM analysis failed',\n      safer_alternative:  'Consult with legal counsel',\n      key_obligations:    [],\n      legal_area:         prev.clause_type || 'Other'\n    }\n  };\n}"
      },
      "typeVersion": 2
    },
    {
      "id": "b8b3e2ea-c5b7-44ae-bf56-d929927b8212",
      "name": "Aggregate Node",
      "type": "n8n-nodes-base.code",
      "position": [
        4976,
        336
      ],
      "parameters": {
        "jsCode": "// Mode: Run Once for All Items\n\nconst clauses = $input.all();\nconst job_id = clauses[0].json.job_id;\n\nconst riskCount = { HIGH: 0, MEDIUM: 0, LOW: 0 };\nconst areaCount = {};\nconst highRiskClauses = [];\n\nclauses.forEach(item => {\n  const json = item.json;\n  riskCount[json.risk_level || 'LOW']++;\n  areaCount[json.legal_area] = (areaCount[json.legal_area] || 0) + 1;\n  \n  if (json.risk_level === 'HIGH') {\n    highRiskClauses.push({\n      clause_id: json.clause_id,\n      type: json.clause_type,\n      legal_area: json.legal_area,\n      reason: json.risk_reason,\n      suggestion: json.safer_alternative\n    });\n  }\n});\n\nconst highPercent = (riskCount.HIGH / clauses.length) * 100;\nconst mediumPercent = (riskCount.MEDIUM / clauses.length) * 100;\nconst overallScore = Math.round((highPercent * 5) + (mediumPercent * 2));\n\nreturn [{\n  json: {\n    job_id: job_id,   // \u2705 THIS FIXES EVERYTHING\n    total_clauses_analyzed: clauses.length,\n    overall_risk_score: Math.min(overallScore, 100),\n    risk_distribution: riskCount,\n    risk_percentage: {\n      HIGH: Math.round((riskCount.HIGH / clauses.length) * 100),\n      MEDIUM: Math.round((riskCount.MEDIUM / clauses.length) * 100),\n      LOW: Math.round((riskCount.LOW / clauses.length) * 100)\n    },\n    legal_areas: areaCount,\n    high_risk_clauses: highRiskClauses,\n    clauses: clauses.map(item => ({\n      clause_id: item.json.clause_id,\n      type: item.json.clause_type,\n      risk_level: item.json.risk_level,\n      confidence: item.json.confidence,\n      area: item.json.legal_area,\n      summary: item.json.plain_english,\n      reason: item.json.risk_reason,\n      suggestion: item.json.safer_alternative\n    }))\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "9c538da2-10bb-4c3f-b9b2-ecd02d30ec03",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        720,
        256
      ],
      "parameters": {
        "path": "analyse-contract",
        "options": {
          "allowedOrigins": "*",
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              },
              {
                "name": "Access-Control-Allow-Methods",
                "value": "POST, GET, OPTIONS"
              },
              {
                "name": "Access-Control-Allow-Headers",
                "value": "*"
              }
            ]
          }
        },
        "httpMethod": "POST",
        "responseData": "allEntries",
        "responseMode": "lastNode"
      },
      "typeVersion": 2.1
    },
    {
      "id": "663103e8-df9e-4431-82a6-3e60a56132e9",
      "name": "Google Gemini Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
      "position": [
        4304,
        544
      ],
      "parameters": {
        "options": {}
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "cb9acecc-f983-4585-b0ad-b6a190d554eb",
      "name": "get job id",
      "type": "n8n-nodes-base.set",
      "position": [
        928,
        48
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "13e1eb42-a915-48ce-8114-be1da890d049",
              "name": "job_id",
              "type": "string",
              "value": "={{$json.body.job_id}}"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "2759e66e-3ab4-4d2a-80ab-8a2c77cf9bab",
      "name": "Merge2",
      "type": "n8n-nodes-base.merge",
      "position": [
        3376,
        752
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "combineBy": "combineByPosition"
      },
      "typeVersion": 3.2
    },
    {
      "id": "05545a81-03b7-4086-8ccb-e928ee37e4f6",
      "name": "Create a row",
      "type": "n8n-nodes-base.supabase",
      "position": [
        5184,
        336
      ],
      "parameters": {
        "tableId": "reports",
        "fieldsUi": {
          "fieldValues": [
            {
              "fieldId": "job_id",
              "fieldValue": "={{ $json.job_id }}"
            },
            {
              "fieldId": "result",
              "fieldValue": "={{$json}}"
            }
          ]
        }
      },
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "013964e6-4d59-4f32-8287-0a727279495d",
      "name": "\ud83d\udccb Workflow Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -224,
        -224
      ],
      "parameters": {
        "color": 7,
        "width": 820,
        "height": 1200,
        "content": "## \u2696\ufe0f Legal Contract Risk Analyser \u2014 Hybrid RAG + AI Risk Scoring\n\n### \ud83d\ude80 What This Workflow Does\nThis workflow transforms any PDF legal contract into a detailed AI-powered risk report \u2014 in under 5 minutes. Upload a contract, and the system automatically splits it into clauses, analyses each one using Hybrid RAG (semantic + keyword search), scores risk as HIGH / MEDIUM / LOW, and delivers plain-English explanations with safer alternative wording.\n\n---\n\n### \ud83d\udd25 Why Hybrid RAG?\nMost dangerous clauses don't use obvious legal keywords.\n**\"The Client accepts full responsibility for all third-party claims\"** is an indemnification clause \u2014 but keyword search misses it.\nHybrid RAG combines:\n- **Vector Search (pgvector)** \u2014 finds semantically similar risky patterns\n- **BM25 Keyword Search** \u2014 catches explicit legal red flags\n- **RRF Reranking** \u2014 merges both results with clause-type boosting\n\n---\n\n### \ud83d\udd0d What It Does\n- Accepts a PDF contract via webhook (with async job_id tracking)\n- Splits contract into individual numbered clauses\n- Classifies each clause type using Google Gemini (indemnification, IP, termination, etc.)\n- Generates vector embeddings and searches a Supabase knowledge base\n- Scores each clause HIGH / MEDIUM / LOW using regex + AI\n- AI Agent (Gemini Flash) explains risk in plain language + suggests safer wording\n- Aggregates all results into a single JSON report\n- Saves report to Supabase (frontend polls for result asynchronously)\n\n---\n\n### \u2699\ufe0f Architecture (Two Pipelines)\n**Pipeline 1 \u2014 Ingestion:** Builds the knowledge base of risky clause patterns in Supabase\n**Pipeline 2 \u2014 Query:** Analyses new contracts against the knowledge base\n\nBoth pipelines run in the same workflow \u2014 the branch splits at Extract Embedding.\n\n---\n\n### \ud83e\udde0 Key Technical Decisions\n- **Async architecture** \u2014 Frontend fires request + polls Supabase. No timeout issues.\n- **job_id tracking** \u2014 Preserved across all nodes via ...$json spread\n- **RRF Reranking** \u2014 Combines vector + BM25 scores with type-based boost multipliers\n- **Regex Risk Scorer** \u2014 First-pass risk classification before expensive LLM call\n- **Gemini Flash** \u2014 Fast, cost-efficient LLM for per-clause annotation\n\n---\n\n### \ud83d\udce6 Requirements\n- **Google Gemini API key** \u2014 for clause classification + embeddings + AI Agent\n- **Supabase project** \u2014 with pgvector extension enabled\n- **Supabase tables:** `legal_clauses` (knowledge base) + `reports` (results)\n- **Supabase functions:** `match_clauses()` + `keyword_search_clauses()`\n- **Frontend (optional):** HTML/CSS/JS web app hosted on Netlify\n\n---\n\n### \ud83d\udca1 Example Use Cases\n- Freelancers reviewing client contracts before signing\n- Startups evaluating vendor or investor agreements\n- Legal ops teams standardising contract review at scale\n- Business owners catching risky clauses without legal fees\n\n---\n\n### \ud83c\udfaf Output\n- Per-clause: risk_level, plain-English explanation, risk_reason, safer_alternative, key_obligations, legal_area\n- Summary: overall_risk_score, risk_distribution, legal_areas map, high_risk_clauses list\n- Stored as JSON in Supabase `reports` table, keyed by job_id"
      },
      "typeVersion": 1
    },
    {
      "id": "5b78f116-3cc4-4618-9dc6-e201c65b0226",
      "name": "Step 1: Webhook",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        768,
        -368
      ],
      "parameters": {
        "color": 4,
        "width": 340,
        "height": 376,
        "content": "### \ud83c\udf10 Step 1: Webhook Entry Point\n**Node:** Webhook\n\nReceives the PDF contract from the frontend web app via HTTP POST (multipart/form-data).\n\n**Inputs:**\n- `file` \u2014 the PDF binary\n- `job_id` \u2014 unique ID generated by frontend for async polling\n\n**Settings:**\n- Method: POST\n- Respond: When Last Node Finishes\n- CORS: Allowed Origins = *\n\n\u26a0\ufe0f **Update** the webhook path to match your n8n instance."
      },
      "typeVersion": 1
    },
    {
      "id": "fdea7bb6-ba55-4b5d-a2b3-f8c68936e903",
      "name": "Step 2: Extract & Split",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        864,
        624
      ],
      "parameters": {
        "color": 4,
        "width": 400,
        "height": 424,
        "content": "### \ud83d\udcc4 Step 2: PDF Extraction & Clause Splitting\n**Nodes:** get job id \u2192 Extract from File \u2192 Merge1 \u2192 Clause Splitter\n\n1. **get job id** \u2014 extracts the job_id from webhook body\n2. **Extract from File** \u2014 converts PDF binary to raw text\n3. **Merge1** \u2014 combines file text + webhook data (append mode)\n4. **Clause Splitter** \u2014 splits full text into individual numbered clauses\n\n**Clause Splitter logic:**\n- Splits on numbered patterns: `1.`, `1.1`, `Article X`, `Section X`\n- Filters out clauses shorter than 80 characters\n- Adds: clause_id, source_doc (from binary filename), job_id\n\n\u26a0\ufe0f The job_id is preserved here and passed through ALL subsequent nodes."
      },
      "typeVersion": 1
    },
    {
      "id": "06e4ba4d-00a1-4059-8a12-4d67984b8feb",
      "name": "Step 3: Classify",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1440,
        -240
      ],
      "parameters": {
        "color": 5,
        "width": 360,
        "height": 388,
        "content": "### \ud83c\udff7\ufe0f Step 3: Clause Classification\n**Nodes:** Classify Clause Type \u2192 extract JSON from Gemini response\n\n**Classify Clause Type** uses Google Gemini to label each clause:\n- indemnification, limitation_of_liability, termination\n- intellectual_property, payment, confidentiality\n- governing_law, force_majeure, warranty, other\n\nAlso extracts key_terms and preliminary risk level.\n\n**extract JSON from Gemini response** parses the raw Gemini output and merges it with clause data from Clause Splitter.\n\n\ud83d\udca1 Gemini is prompted to return ONLY valid JSON \u2014 no markdown."
      },
      "typeVersion": 1
    },
    {
      "id": "1f0885d1-31d5-4e06-ac5c-33138ec30935",
      "name": "Step 4: Embedding",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2048,
        -304
      ],
      "parameters": {
        "color": 5,
        "width": 380,
        "height": 392,
        "content": "### \ud83e\uddee Step 4: Vector Embedding Generation\n**Nodes:** Prepare Embedding Request \u2192 Generate Embedding \u2192 Extract Embedding\n\n1. **Prepare Embedding Request** \u2014 formats clause text for the Google Embedding API (gemini-embedding-001, 3072 dimensions)\n2. **Generate Embedding** \u2014 calls Google's embedding API via HTTP\n3. **Extract Embedding** \u2014 pulls the embedding values array and merges with all clause metadata\n\n\u26a0\ufe0f Replace the Google API key in the Generate Embedding HTTP node URL with your own key.\n\n\ud83d\udca1 After Extract Embedding, the pipeline **splits into two branches:**\n- \u2192 INSERT branch (build knowledge base)\n- \u2192 QUERY branch (analyse contract)"
      },
      "typeVersion": 1
    },
    {
      "id": "e9a95ac1-3590-425f-a214-7a23a855cdd8",
      "name": "Step 5A: Ingestion",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2608,
        -480
      ],
      "parameters": {
        "color": 6,
        "width": 380,
        "height": 512,
        "content": "### \ud83d\uddc4\ufe0f Step 5A: Ingestion Pipeline\n**Nodes:** Wait \u2192 Insert to Supabase\n\nThis branch **builds the knowledge base** of risky clause patterns.\n\n1. **Wait** \u2014 3-second delay to avoid Supabase rate limits\n2. **Insert to Supabase** \u2014 upserts clause into `legal_clauses` table with:\n   - clause_id, text, clause_type, risk_level\n   - key_terms, source_doc, embedding (vector)\n\n\u26a0\ufe0f **Setup required:**\n- Create `legal_clauses` table in Supabase with vector(3072) column\n- Enable pgvector extension\n- Create `match_clauses()` and `keyword_search_clauses()` functions\n- Add UNIQUE constraint on clause_id for upsert to work\n\n\ud83d\udca1 Run the ingestion pipeline first with sample contracts to populate the knowledge base before analysing new ones."
      },
      "typeVersion": 1
    },
    {
      "id": "7a76b847-4923-403d-baa9-656d274f6607",
      "name": "Step 5B: Hybrid RAG",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2864,
        784
      ],
      "parameters": {
        "color": 6,
        "width": 400,
        "height": 516,
        "content": "### \ud83d\udd0d Step 5B: Hybrid RAG Query Pipeline\n**Nodes:** Vector Search + Keyword Search \u2192 Vector/BM25 Set \u2192 Merge \u2192 Merge2\n\nThis is the core of the Hybrid RAG system:\n\n**Vector Search** \u2014 semantic similarity search using pgvector\n- Uses `match_clauses()` Supabase function\n- threshold: 0.70, returns top 5 matches per clause\n\n**Keyword Search (BM25)** \u2014 full-text keyword matching\n- Uses `keyword_search_clauses()` Supabase function\n- Returns top 5 BM25 keyword matches\n\n**Vector Set / BM25 Set** \u2014 adds `source` tag ('vector' or 'bm25') to each result\n\n**Merge (Append)** \u2014 combines both result sets (up to 44 items total)\n\n**Merge2** \u2014 combines search results with original embedding data\n\n\ud83d\udca1 Hybrid RAG catches risky clauses that neither search method alone would find."
      },
      "typeVersion": 1
    },
    {
      "id": "4941f487-f8e5-4784-9be1-68a10e6d2db2",
      "name": "Step 6: Reranking",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3600,
        -192
      ],
      "parameters": {
        "color": 3,
        "width": 380,
        "height": 480,
        "content": "### \ud83d\udcca Step 6: RRF Reranking + Risk Scoring\n**Nodes:** RRF Reranker \u2192 Risk Scorer\n\n**RRF Reranker** \u2014 Reciprocal Rank Fusion scoring\n- Formula: `(1 / (k + similarity)) \u00d7 typeBoost`\n- Type boost multipliers:\n  - Indemnification: 1.8x\n  - Limitation of Liability: 1.7x\n  - Termination: 1.4x\n  - IP: 1.3x, Governing Law: 1.2x\n  - Payment: 1.0x, Other: 0.8x\n- Preserves ALL fields via explicit field mapping\n\n**Risk Scorer** \u2014 Regex pattern matching\n- HIGH patterns: unlimited liability, irrevocable, sole discretion, waive rights, any and all claims\n- MEDIUM patterns: no warranty, as-is, may terminate, binding arbitration, force majeure\n- LOW: everything else\n- Adds: regex_risk, triggered_patterns, pattern_count"
      },
      "typeVersion": 1
    },
    {
      "id": "c7db4101-411a-4a82-9d70-79e7d358bcac",
      "name": "Step 7: AI Annotation",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4288,
        -192
      ],
      "parameters": {
        "color": 3,
        "width": 380,
        "height": 496,
        "content": "### \ud83e\udd16 Step 7: AI Annotation\n**Nodes:** Prepare LLM data \u2192 AI Agent (Gemini Flash) \u2192 Extract LLM Response\n\n**Prepare LLM data** \u2014 Set node that explicitly maps all fields for the AI Agent (AI Agent nodes don't auto-pass $json)\n\n**AI Agent** \u2014 Google Gemini Flash analyses each clause and returns:\n- `risk_level` \u2014 HIGH / MEDIUM / LOW\n- `confidence` \u2014 0.0 to 1.0\n- `plain_english` \u2014 what it means without legal jargon\n- `risk_reason` \u2014 why it's risky\n- `safer_alternative` \u2014 rewritten safer version\n- `key_obligations` \u2014 list of obligations\n- `legal_area` \u2014 Indemnification, IP, Termination etc.\n\n**Extract LLM Response** \u2014 parses AI output JSON and merges with clause metadata\n\n\u26a0\ufe0f AI Agent requires Google Gemini Chat Model sub-node connected."
      },
      "typeVersion": 1
    },
    {
      "id": "c9e1734c-031e-4f93-a627-fb42d7b92224",
      "name": "Step 8: Aggregate & Save",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4864,
        -400
      ],
      "parameters": {
        "color": 2,
        "width": 380,
        "height": 644,
        "content": "### \ud83d\udce6 Step 8: Aggregate & Save\n**Nodes:** Aggregate Node \u2192 Create a row\n\n**Aggregate Node** \u2014 combines all analysed clauses into a single report:\n- `total_clauses_analyzed`\n- `overall_risk_score` (0-100)\n- `risk_distribution` \u2014 HIGH/MEDIUM/LOW counts\n- `risk_percentage` \u2014 percentage breakdown\n- `legal_areas` \u2014 map of legal area to count\n- `high_risk_clauses` \u2014 array of HIGH risk summaries\n- `clauses` \u2014 full array of all annotated clauses\n\n**Create a row** \u2014 saves the complete report to Supabase `reports` table\n- Keyed by `job_id`\n- Frontend polls this table every 5 seconds until result appears\n\n\u26a0\ufe0f Create the `reports` table in Supabase:\n```sql\nCREATE TABLE reports (\n  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n  job_id text,\n  result jsonb,\n  created_at timestamp DEFAULT now()\n);\n```"
      },
      "typeVersion": 1
    },
    {
      "id": "e439c63d-1946-4d52-9784-5e99c979062d",
      "name": "\u26a0\ufe0f Setup Guide",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -224,
        1008
      ],
      "parameters": {
        "width": 400,
        "height": 640,
        "content": "### \u26a0\ufe0f Setup Guide \u2014 Read Before Running\n\n**1. Supabase Setup**\n- Enable pgvector extension in Supabase\n- Create `legal_clauses` table with `embedding vector(3072)` column\n- Create `reports` table (job_id, result jsonb)\n- Create `match_clauses()` RPC function for vector similarity search\n- Create `keyword_search_clauses()` RPC function for BM25 search\n- Add UNIQUE constraint on clause_id in legal_clauses\n- Disable RLS on reports table for frontend polling\n\n**2. Google Gemini**\n- Add your Google Gemini API key to the credential\n- Replace API key in Generate Embedding HTTP node URL\n\n**3. Build Knowledge Base First**\n- Upload 5-10 sample contracts to populate `legal_clauses` table\n- Without a knowledge base, vector + keyword search returns no results\n\n**4. Frontend (Optional)**\n- Deploy the HTML/CSS/JS web app to Netlify\n- Set webhook URL in the frontend HTML file\n- Frontend polls Supabase `reports` table using the job_id"
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "9ad2b25d-b58f-483c-8562-5bf634a01ba4",
  "connections": {
    "BM25": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Wait": {
      "main": [
        [
          {
            "node": "Insert to Supabase",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge": {
      "main": [
        [
          {
            "node": "Merge2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge1": {
      "main": [
        [
          {
            "node": "Clause Splitter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge2": {
      "main": [
        [
          {
            "node": "RRF Reranker",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Vector": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook": {
      "main": [
        [
          {
            "node": "Merge1",
            "type": "main",
            "index": 1
          },
          {
            "node": "get job id",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Agent": {
      "main": [
        [
          {
            "node": "Extract LLM Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "get job id": {
      "main": [
        [
          {
            "node": "Extract from File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Risk Scorer": {
      "main": [
        [
          {
            "node": "Prepare LLM data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "RRF Reranker": {
      "main": [
        [
          {
            "node": "Risk Scorer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Vector Search": {
      "main": [
        [
          {
            "node": "Vector",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate Node": {
      "main": [
        [
          {
            "node": "Create a row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keyword Search": {
      "main": [
        [
          {
            "node": "BM25",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clause Splitter": {
      "main": [
        [
          {
            "node": "Classify Clause Type",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare LLM data": {
      "main": [
        [
          {
            "node": "AI Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Embedding": {
      "main": [
        [
          {
            "node": "Wait",
            "type": "main",
            "index": 0
          },
          {
            "node": "Vector Search",
            "type": "main",
            "index": 0
          },
          {
            "node": "Keyword Search",
            "type": "main",
            "index": 0
          },
          {
            "node": "Merge2",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Extract from File": {
      "main": [
        [
          {
            "node": "Merge1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Embedding": {
      "main": [
        [
          {
            "node": "Extract Embedding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify Clause Type": {
      "main": [
        [
          {
            "node": "extract JSON from Gemini response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract LLM Response": {
      "main": [
        [
          {
            "node": "Aggregate Node",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Gemini Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Embedding Request": {
      "main": [
        [
          {
            "node": "Generate Embedding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "extract JSON from Gemini response": {
      "main": [
        [
          {
            "node": "Prepare Embedding Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}