{
  "id": "CLMJfjbdtPnwTu6S",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "AI Lie Detector: Forensic Stylometry Engine",
  "tags": [],
  "nodes": [
    {
      "id": "40de2d94-0384-40b7-a572-c920d9dac3d1",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -528,
        -16
      ],
      "parameters": {
        "width": 496,
        "height": 768,
        "content": "## AI Lie Detector: Forensic Stylometry Engine\nPaste any text and three specialist agents debate whether it was written by a human, AI, or a mix of both.\n\n### How it works\n\nExtract: A code node computes stylometric metrics (burstiness, vocabulary density, repetition, sentence variance) from the raw text.\nScan: Agent 1 reads the text cold with no metrics and gives a gut reaction.\nAnalyze: Agent 2 gets the text, the metrics, and Agent 1's impression. Writes a forensic report grounded in data.\nChallenge: Agent 3 (Devil's Advocate) gets everything and argues the opposite case.\nJudge: A code node weighs the debate and metrics to produce the final verdict.\n\n### Setup\n\n- [ ] Add API credentials for the LLM providers you want to use\n- [ ] Activate the workflow and open the chat window\n- [ ] Paste any text and wait for the forensic analysis\n\n### Customization\nSwap any LLM for another. Adjust metric thresholds in the Extract Stylometric Metrics node. Modify agent personas in their system prompts."
      },
      "typeVersion": 1
    },
    {
      "id": "203720a1-8f08-42b0-afd5-c4aa326832f5",
      "name": "Format Chat Message",
      "type": "n8n-nodes-base.code",
      "position": [
        3536,
        336
      ],
      "parameters": {
        "jsCode": "// Formats the forensic debate into a readable chat message with verdict badge, metrics table, and the debate chain.\n\nconst reportData = $input.first().json;\nconst finalVerdict = reportData.finalVerdict;\nconst agentDebate = reportData.debate;\nconst textMetrics = reportData.metrics;\nconst metricSignals = reportData.metricSignals;\n\nlet verdictBadge = \"\";\nif (finalVerdict.classification === \"human\") verdictBadge = \"\ud83d\ude4e\ud83c\udffb **Verdict: Human-Written**\";\nelse if (finalVerdict.classification === \"ai\") verdictBadge = \"\ud83e\udd16 **Verdict: AI-Generated**\";\nelse verdictBadge = \"\ud83e\uddbe **Verdict: AI-Augmented (Hybrid)**\";\n\nconst confidencePercent = Math.round(finalVerdict.confidence * 100);\nconst filledBarBlocks = Math.floor(confidencePercent / 10);\nconst emptyBarBlocks = 10 - filledBarBlocks;\nconst confidenceBar = \"\u2588\".repeat(filledBarBlocks) + \"\u2591\".repeat(emptyBarBlocks);\n\nlet chatMessage = `${verdictBadge}\\n`;\nchatMessage += `**Confidence:** ${confidenceBar} ${confidencePercent}%\\n\\n`;\n\nchatMessage += `**\ud83d\udcca Stylometric Metrics:**\\n`;\nchatMessage += `Burstiness: ${textMetrics.burstiness} ${textMetrics.burstiness < 0.3 ? '\ud83d\udfe5 AI' : '\ud83d\udfe9 Human'}\\n`;\nchatMessage += `Vocabulary Diversity: ${textMetrics.typeTokenRatio} ${textMetrics.typeTokenRatio < 0.4 ? '\ud83d\udfe5 AI' : '\ud83d\udfe9 Human'}\\n`;\nchatMessage += `Hapax Rate: ${textMetrics.hapaxRate} ${textMetrics.hapaxRate < 0.4 ? '\ud83d\udfe5 AI' : '\ud83d\udfe9 Human'}\\n`;\nchatMessage += `Repetition: ${textMetrics.repetitionScore} ${textMetrics.repetitionScore > 0.15 ? '\ud83d\udfe5 AI' : '\ud83d\udfe9 Human'}\\n`;\nchatMessage += `Transition Density: ${textMetrics.transitionDensity} ${textMetrics.transitionDensity > 0.015 ? '\ud83d\udfe5 AI' : '\ud83d\udfe9 Human'}\\n\\n`;\n\nchatMessage += `---\\n\\n`;\n\nchatMessage += `**\ud83d\udd0e Agent 1 (Gut Check):** ${agentDebate.scanner.impression.toUpperCase()} (${Math.round(agentDebate.scanner.confidence * 100)}%)\\n`;\nconst scannerReasoningText = agentDebate.scanner.gut_reasoning || \"\";\n\nconst maxScannerLength = 200;\nchatMessage += `*\"${scannerReasoningText.length > maxScannerLength ? scannerReasoningText.substring(0, maxScannerLength) + '...' : scannerReasoningText}\"*\\n\\n`;\n\nchatMessage += `**\ud83d\udd2c Agent 2 (Data):** ${agentDebate.analyst.classification.toUpperCase()} (${Math.round(agentDebate.analyst.confidence * 100)}%)\\n`;\nconst analystReportText = agentDebate.analyst.forensic_report || \"\";\n\nconst maxAnalystLength = 200;\nchatMessage += `*\"${analystReportText.length > maxAnalystLength ? analystReportText.substring(0, maxAnalystLength) + '...' : analystReportText}\"*\\n\\n`;\n\nchatMessage += `**\ud83d\ude08 Agent 3 (Critic):** ${agentDebate.devilsAdvocate.counter_classification.toUpperCase()} (${Math.round(agentDebate.devilsAdvocate.confidence * 100)}%)\\n`;\nconst counterArgumentText = agentDebate.devilsAdvocate.counter_argument || \"\";\n\nconst maxDevilLength = 200;\nchatMessage += `*\"${counterArgumentText.length > maxDevilLength ? counterArgumentText.substring(0, maxDevilLength) + '...' : counterArgumentText}\"*\\n`;\n\nconst biggestFlawText = agentDebate.devilsAdvocate.strongest_weakness || \"\";\nif (biggestFlawText) {\n  chatMessage += `**Flaw Found:** ${biggestFlawText}\\n`;\n}\n\nreturn [{\n  json: {\n    output: chatMessage,\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "7100eabf-34dc-4e36-baf1-c6b909f39875",
      "name": "Final Verdict",
      "type": "n8n-nodes-base.code",
      "position": [
        3248,
        336
      ],
      "parameters": {
        "jsCode": "// Weighs the full debate chain and raw metrics to produce the final classification, with short-text adjustments and AI fingerprint boosting.\n\nconst analystPackageOutput = $('Package Analyst Output').first().json;\nconst devilsAdvocateRawOutput = $input.first().json.output || $input.first().json.text || \"\";\n\nlet parsedDevilsAdvocateVerdict = {};\n\ntry {\n  let cleanedDevilOutput = devilsAdvocateRawOutput.trim();\n  if (cleanedDevilOutput.startsWith(\"```json\")) cleanedDevilOutput = cleanedDevilOutput.slice(7);\n  if (cleanedDevilOutput.startsWith(\"```\")) cleanedDevilOutput = cleanedDevilOutput.slice(3);\n  if (cleanedDevilOutput.endsWith(\"```\")) cleanedDevilOutput = cleanedDevilOutput.slice(0, -3);\n  parsedDevilsAdvocateVerdict = JSON.parse(cleanedDevilOutput.trim());\n} catch (parseError) {\n  parsedDevilsAdvocateVerdict = {\n    counter_classification: \"unknown\",\n    confidence: 0,\n    counter_argument: \"Devil's Advocate failed: \" + parseError.message,\n    strongest_weakness: \"N/A\",\n  };\n}\n\nconst scannerVerdict = analystPackageOutput.scannerVerdict;\nconst analystVerdict = analystPackageOutput.analystVerdict;\nconst devilsAdvocateVerdict = parsedDevilsAdvocateVerdict;\nconst textMetrics = analystPackageOutput.metrics;\n\nlet aiSignalCount = 0;\nlet humanSignalCount = 0;\n\nif (textMetrics.burstiness < 0.3) aiSignalCount += 1;\nelse humanSignalCount += 1;\n\nif (textMetrics.typeTokenRatio < 0.4) aiSignalCount += 1;\nelse humanSignalCount += 1;\n\nif (textMetrics.hapaxRate < 0.4) aiSignalCount += 1;\nelse humanSignalCount += 1;\n\nif (textMetrics.repetitionScore > 0.15) aiSignalCount += 1;\nelse humanSignalCount += 1;\n\nif (textMetrics.transitionDensity > 0.015) aiSignalCount += 1;\nelse humanSignalCount += 1;\n\nif (textMetrics.aiFingerprintCount >= 8) aiSignalCount += 4;\nelse if (textMetrics.aiFingerprintCount >= 5) aiSignalCount += 2;\nelse if (textMetrics.aiFingerprintCount >= 3) aiSignalCount += 1;\n\nif (textMetrics.transitionDensity >= 0.05) aiSignalCount += 3;\nelse if (textMetrics.transitionDensity >= 0.03) aiSignalCount += 2;\n\nif (textMetrics.totalWords < 150) {\n  if (textMetrics.burstiness < 0.2) aiSignalCount += 2;\n  if (textMetrics.transitionDensity > 0.02) aiSignalCount += 2;\n  if (textMetrics.aiFingerprintCount >= 3) aiSignalCount += 1;\n}\n\nconst totalSignalCount = aiSignalCount + humanSignalCount;\n\nconst isScannerValid = scannerVerdict.impression && scannerVerdict.impression !== \"unknown\";\nconst isAnalystValid = analystVerdict.classification && analystVerdict.classification !== \"unknown\";\nconst isDevilValid = devilsAdvocateVerdict.counter_classification && devilsAdvocateVerdict.counter_classification !== \"unknown\";\n\nlet analystWeight = 0.35;\nlet scannerWeight = 0.15;\nlet devilsAdvocateWeight = 0.15;\nlet metricsWeight = 0.35;\n\nif (!isScannerValid) {\n  metricsWeight += scannerWeight;\n  scannerWeight = 0;\n}\n\nconst metricAiRatio = totalSignalCount > 0 ? aiSignalCount / totalSignalCount : 0.5;\n\nif (metricAiRatio >= 0.70 || metricAiRatio <= 0.30) {\n  metricsWeight += 0.10;\n  devilsAdvocateWeight -= 0.10;\n}\n\nfunction convertClassificationToScore(classification) {\n  if (classification === \"ai\") return 1.0;\n  if (classification === \"ai-augmented\") return 0.5;\n  if (classification === \"human\") return 0.0;\n  return 0.5;\n}\n\nconst scannerNumericScore = isScannerValid\n  ? convertClassificationToScore(scannerVerdict.impression) * scannerVerdict.confidence\n  : 0;\n\nconst analystNumericScore = isAnalystValid\n  ? convertClassificationToScore(analystVerdict.classification) * analystVerdict.confidence\n  : 0.5;\n\nconst devilsAdvocateNumericScore = isDevilValid\n  ? convertClassificationToScore(devilsAdvocateVerdict.counter_classification) * devilsAdvocateVerdict.confidence\n  : 0.5;\n\nconst metricsNumericScore = totalSignalCount > 0 ? aiSignalCount / totalSignalCount : 0.5;\n\nconst combinedWeightedScore =\n  (analystNumericScore * analystWeight) +\n  (scannerNumericScore * scannerWeight) +\n  (devilsAdvocateNumericScore * devilsAdvocateWeight) +\n  (metricsNumericScore * metricsWeight);\n\nlet finalClassification = \"ai-augmented\";\nlet finalConfidenceScore = 0.5;\n\nif (combinedWeightedScore >= 0.60) {\n  finalClassification = \"ai\";\n  finalConfidenceScore = Math.min(0.95, 0.5 + combinedWeightedScore);\n} else if (combinedWeightedScore <= 0.35) {\n  finalClassification = \"human\";\n  finalConfidenceScore = Math.min(0.95, 0.5 + (1 - combinedWeightedScore));\n} else {\n  finalClassification = \"ai-augmented\";\n  finalConfidenceScore = 0.5 + Math.abs(combinedWeightedScore - 0.5);\n}\n\nfinalConfidenceScore = Math.round(finalConfidenceScore * 100) / 100;\n\nconst failedAgentNames = [];\nif (!isScannerValid) failedAgentNames.push(\"Scanner\");\nif (!isAnalystValid) failedAgentNames.push(\"Forensic Analyst\");\nif (!isDevilValid) failedAgentNames.push(\"Devil's Advocate\");\n\nconst allAgentVotes = [];\nif (isScannerValid) allAgentVotes.push(scannerVerdict.impression);\nif (isAnalystValid) allAgentVotes.push(analystVerdict.classification);\nif (isDevilValid) allAgentVotes.push(devilsAdvocateVerdict.counter_classification);\n\nconst voteCountsByClassification = {};\nfor (const vote of allAgentVotes) {\n  voteCountsByClassification[vote] = (voteCountsByClassification[vote] || 0) + 1;\n}\n\nreturn [{\n  json: {\n    finalVerdict: {\n      classification: finalClassification,\n      confidence: finalConfidenceScore,\n      weightedScore: Math.round(combinedWeightedScore * 100) / 100,\n    },\n    debate: {\n      scanner: scannerVerdict,\n      analyst: analystVerdict,\n      devilsAdvocate: devilsAdvocateVerdict,\n    },\n    metrics: textMetrics,\n    metricSignals: { ai: aiSignalCount, human: humanSignalCount },\n    voteCounts: voteCountsByClassification,\n    originalText: analystPackageOutput.originalText,\n    failedAgents: failedAgentNames,\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "a552c123-5e25-4997-9bab-1627281b285a",
      "name": "Agent 3 - Devil's Advocate",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "onError": "continueRegularOutput",
      "position": [
        2736,
        336
      ],
      "parameters": {
        "text": "==You are the Devil's Advocate. Two agents have already analyzed this text. Your ONLY job is to argue the OPPOSITE of the Forensic Analyst's conclusion. If they said AI, you argue human. If they said human, you argue AI. You must find real weaknesses in their reasoning.\n\n===START OF TEXT TO ANALYZE===\n{{ $json.originalText }}\n===END OF TEXT TO ANALYZE===\n\nSCANNER'S IMPRESSION:\nClassification: {{ $json.scannerVerdict.impression }} ({{ $json.scannerVerdict.confidence }} confidence)\nReasoning: {{ $json.scannerVerdict.gut_reasoning }}\n\nFORENSIC ANALYST'S VERDICT:\nClassification: {{ $json.analystVerdict.classification }} ({{ $json.analystVerdict.confidence }} confidence)\nReport: {{ $json.analystVerdict.forensic_report }}\nAgrees with Scanner: {{ $json.analystVerdict.agrees_with_scanner }}\n\nMETRICS:\n- Burstiness: {{ $json.metrics.burstiness }}\n- Type-Token Ratio: {{ $json.metrics.typeTokenRatio }}\n- Hapax Rate: {{ $json.metrics.hapaxRate }}\n- Repetition Score: {{ $json.metrics.repetitionScore }}\n- Transition Density: {{ $json.metrics.transitionDensity }}\n\nReturn ONLY valid JSON on a single line with no line breaks inside values:\n{\"counter_classification\": \"human or ai or ai-augmented\", \"confidence\": 0.60, \"counter_argument\": \"Aggressively argue why the Analyst is wrong. Find holes in their logic. Point to metrics they misinterpreted or ignored.\", \"strongest_weakness\": \"What is the single biggest flaw in the Analysts reasoning?\"}",
        "options": {
          "systemMessage": "You are a hostile cross-examiner. Your job is to destroy the previous analysis. Do not agree with the Forensic Analyst under any circumstances. Find every possible flaw. Return valid JSON only."
        },
        "promptType": "define"
      },
      "typeVersion": 3.1
    },
    {
      "id": "4fda3540-33e8-4bde-a126-acdf525d0001",
      "name": "Package Analyst Output",
      "type": "n8n-nodes-base.code",
      "position": [
        2480,
        336
      ],
      "parameters": {
        "jsCode": "// Packages the Forensic Analyst's raw output together with everything accumulated so far so the Devil's Advocate has the full debate history.\n\nconst scannerPackageOutput = $('Package Scanner Output').first().json;\nconst analystRawOutput = $input.first().json.output || $input.first().json.text || \"\";\n\nlet parsedAnalystVerdict = {};\n\ntry {\n  let cleanedAnalystOutput = analystRawOutput.trim();\n  if (cleanedAnalystOutput.startsWith(\"```json\")) cleanedAnalystOutput = cleanedAnalystOutput.slice(7);\n  if (cleanedAnalystOutput.startsWith(\"```\")) cleanedAnalystOutput = cleanedAnalystOutput.slice(3);\n  if (cleanedAnalystOutput.endsWith(\"```\")) cleanedAnalystOutput = cleanedAnalystOutput.slice(0, -3);\n  parsedAnalystVerdict = JSON.parse(cleanedAnalystOutput.trim());\n} catch (parseError) {\n  parsedAnalystVerdict = {\n    classification: \"unknown\",\n    confidence: 0,\n    forensic_report: \"Analyst failed to produce valid output: \" + parseError.message,\n    agrees_with_scanner: false,\n  };\n}\n\nreturn [{\n  json: {\n    originalText: scannerPackageOutput.originalText,\n    metrics: scannerPackageOutput.metrics,\n    scannerVerdict: scannerPackageOutput.scannerVerdict,\n    analystVerdict: parsedAnalystVerdict,\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "10cd22d8-66b5-46e5-ac84-154c276b7f16",
      "name": "Agent 2 - Forensic Analyst",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "onError": "continueRegularOutput",
      "position": [
        2144,
        336
      ],
      "parameters": {
        "text": "=You are a forensic linguist. An initial scanner has already reviewed this text and given a gut impression. Your job is different: you have hard data. Use the stylometric metrics below to build a rigorous forensic case.\n\nTEXT:\n{{ $json.originalText }}\n\nSCANNER'S IMPRESSION:\nClassification: {{ $json.scannerVerdict.impression }}\nConfidence: {{ $json.scannerVerdict.confidence }}\nReasoning: {{ $json.scannerVerdict.gut_reasoning }}\n\nSTYLOMETRIC METRICS:\n- Burstiness: {{ $json.metrics.burstiness }} (sentence length variation, higher = more human-like)\n- Type-Token Ratio: {{ $json.metrics.typeTokenRatio }} (vocabulary diversity, lower = more AI-like)\n- Hapax Rate: {{ $json.metrics.hapaxRate }} (words used only once, lower = constrained vocabulary)\n- Repetition Score: {{ $json.metrics.repetitionScore }} (bigram repetition, higher = more repetitive)\n- Transition Density: {{ $json.metrics.transitionDensity }} (filler/transition words, higher = AI signal)\n- Avg Sentence Length: {{ $json.metrics.avgSentenceLength }}\n- Sentence Variance: {{ $json.metrics.sentenceLengthVariance }}\n- Paragraph Variance: {{ $json.metrics.paragraphVariance }}\n\nReturn ONLY valid JSON:\n{\n  \"classification\": \"human\" or \"ai\" or \"ai-augmented\",\n  \"confidence\": 0.85,\n  \"forensic_report\": \"Write a detailed forensic analysis. Reference specific metrics by number. Explain whether the data supports or contradicts the Scanner's impression. Identify the strongest signals.\",\n  \"agrees_with_scanner\": true or false\n}",
        "options": {
          "systemMessage": "You are a forensic linguist who relies on data over instinct. Your analysis must reference the specific metric values provided. Do not speculate without data. Return valid JSON only."
        },
        "promptType": "define"
      },
      "typeVersion": 3.1
    },
    {
      "id": "07cc2c22-851b-4525-9a41-d2364597b76b",
      "name": "Package Scanner Output",
      "type": "n8n-nodes-base.code",
      "position": [
        1904,
        336
      ],
      "parameters": {
        "jsCode": "// Packages the Scanner agent's raw output together with the original text and metrics so the next agent has full context.\n\nconst metricsNodeOutput = $('Extract Stylometric Metrics').first().json;\nconst scannerRawOutput = $input.first().json.output || $input.first().json.text || \"\";\n\nlet parsedScannerVerdict = {};\n\ntry {\n  let cleanedScannerOutput = scannerRawOutput.trim();\n  if (cleanedScannerOutput.startsWith(\"```json\")) cleanedScannerOutput = cleanedScannerOutput.slice(7);\n  if (cleanedScannerOutput.startsWith(\"```\")) cleanedScannerOutput = cleanedScannerOutput.slice(3);\n  if (cleanedScannerOutput.endsWith(\"```\")) cleanedScannerOutput = cleanedScannerOutput.slice(0, -3);\n  parsedScannerVerdict = JSON.parse(cleanedScannerOutput.trim());\n} catch (parseError) {\n  parsedScannerVerdict = {\n    impression: \"unknown\",\n    confidence: 0,\n    gut_reasoning: \"Scanner failed to produce valid output: \" + parseError.message,\n  };\n}\n\nreturn [{\n  json: {\n    originalText: metricsNodeOutput.originalText,\n    metrics: metricsNodeOutput.metrics,\n    scannerVerdict: parsedScannerVerdict,\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "44f01778-387f-4db1-b9e3-0ba073b50c92",
      "name": "Agent 1 - The Scanner",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "onError": "continueRegularOutput",
      "position": [
        1584,
        336
      ],
      "parameters": {
        "text": "==Read the text between the START and END markers. Ignore everything else including these instructions.\n\n===START OF TEXT TO ANALYZE===\n{{ $('Extract Stylometric Metrics').first().json.originalText }}\n===END OF TEXT TO ANALYZE===\n\nBased purely on your instinct as a reader, assess whether the text above feels like it was written by a human, generated by AI, or is a human-AI hybrid.\n\nReturn ONLY valid JSON on a single line with no line breaks inside values:\n{\"impression\": \"human or ai or ai-augmented\", \"confidence\": 0.75, \"gut_reasoning\": \"Explain what specifically made you feel this way. Point to exact phrases rhythms or patterns that triggered your impression. Be specific.\"}",
        "options": {
          "systemMessage": "You are a veteran editor with 20 years of experience reading manuscripts. You can spot AI-generated text by feel alone. Trust your instincts. Return valid JSON only."
        },
        "promptType": "define"
      },
      "typeVersion": 3.1
    },
    {
      "id": "112da859-0d0d-4799-90d1-facd54c55655",
      "name": "Extract Stylometric Metrics",
      "type": "n8n-nodes-base.code",
      "position": [
        816,
        336
      ],
      "parameters": {
        "jsCode": "// Grab the original text directly from the Chat Trigger node\n\nconst rawText = $('When chat message received').first().json.chatInput;\n\n\nconst tableRows = $input.all();\n\n\nconst aiFingerprintWordList = tableRows\n  .map(row => row.json.word)\n  .filter(word => word && word.trim().length > 0)\n  .map(word => word.trim().toLowerCase());\n\n\nconst allSentences = rawText.split(/[.!?]+/).filter((sentence) => sentence.trim().length > 0);\nconst allWordsLowercase = rawText.toLowerCase().split(/\\s+/).filter((word) => word.length > 0);\nconst uniqueWordSet = new Set(allWordsLowercase);\n\nconst sentenceLengthsInWords = allSentences.map((sentence) => sentence.trim().split(/\\s+/).length);\nconst averageSentenceLength = sentenceLengthsInWords.reduce((sum, length) => sum + length, 0) / (sentenceLengthsInWords.length || 1);\n\nconst sentenceLengthVariance = sentenceLengthsInWords.reduce((sum, length) => {\n  return sum + Math.pow(length - averageSentenceLength, 2);\n}, 0) / (sentenceLengthsInWords.length || 1);\n\nconst burstinessScore = Math.sqrt(sentenceLengthVariance) / (averageSentenceLength || 1);\n\nconst typeTokenRatio = allWordsLowercase.length > 0 ? uniqueWordSet.size / allWordsLowercase.length : 0;\n\nconst wordFrequencyMap = {};\nfor (const word of allWordsLowercase) {\n  wordFrequencyMap[word] = (wordFrequencyMap[word] || 0) + 1;\n}\nconst wordsAppearingOnce = Object.values(wordFrequencyMap).filter((count) => count === 1).length;\nconst hapaxLegomenaRate = uniqueWordSet.size > 0 ? wordsAppearingOnce / uniqueWordSet.size : 0;\n\nconst allBigrams = [];\nfor (let i = 0; i < allWordsLowercase.length - 1; i++) {\n  allBigrams.push(allWordsLowercase[i] + \" \" + allWordsLowercase[i + 1]);\n}\nconst uniqueBigramSet = new Set(allBigrams);\nconst bigramRepetitionScore = allBigrams.length > 0 ? 1 - (uniqueBigramSet.size / allBigrams.length) : 0;\n\nconst allParagraphs = rawText.split(/\\n\\n+/).filter((paragraph) => paragraph.trim().length > 0);\nconst paragraphLengthsInWords = allParagraphs.map((paragraph) => paragraph.trim().split(/\\s+/).length);\nconst averageParagraphLength = paragraphLengthsInWords.reduce((sum, length) => sum + length, 0) / (paragraphLengthsInWords.length || 1);\nconst paragraphLengthVariance = paragraphLengthsInWords.reduce((sum, length) => {\n  return sum + Math.pow(length - averageParagraphLength, 2);\n}, 0) / (paragraphLengthsInWords.length || 1);\n\nconst detectedFingerprintWords = [];\nconst fingerprintMatchCount = allWordsLowercase.filter((word) => {\n  if (aiFingerprintWordList.includes(word)) {\n    detectedFingerprintWords.push(word);\n    return true;\n  }\n  return false;\n}).length;\nconst fingerprintDensity = allWordsLowercase.length > 0 ? fingerprintMatchCount / allWordsLowercase.length : 0;\n\nconst extractedMetrics = {\n  totalWords: allWordsLowercase.length,\n  totalSentences: allSentences.length,\n  totalParagraphs: allParagraphs.length,\n  avgSentenceLength: Math.round(averageSentenceLength * 100) / 100,\n  sentenceLengthVariance: Math.round(sentenceLengthVariance * 100) / 100,\n  burstiness: Math.round(burstinessScore * 100) / 100,\n  typeTokenRatio: Math.round(typeTokenRatio * 100) / 100,\n  hapaxRate: Math.round(hapaxLegomenaRate * 100) / 100,\n  repetitionScore: Math.round(bigramRepetitionScore * 100) / 100,\n  avgParagraphLength: Math.round(averageParagraphLength * 100) / 100,\n  paragraphVariance: Math.round(paragraphLengthVariance * 100) / 100,\n  transitionDensity: Math.round(fingerprintDensity * 1000) / 1000,\n  aiFingerprintsFound: detectedFingerprintWords,\n  aiFingerprintCount: detectedFingerprintWords.length,\n};\n\nreturn [{\n  json: {\n    originalText: rawText,\n    metrics: extractedMetrics,\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "dab14166-da47-45d1-9d6a-ffc82387854d",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "disabled": true,
      "position": [
        3984,
        128
      ],
      "parameters": {
        "color": 7,
        "width": 448,
        "height": 384,
        "content": "## Chat Response\n\nFormats the forensic report and sends it to the user"
      },
      "typeVersion": 1
    },
    {
      "id": "ba54ff47-b150-4190-802b-ea7d78fa2d4d",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3120,
        128
      ],
      "parameters": {
        "color": 7,
        "width": 768,
        "height": 384,
        "content": "## Final Verdict\n\nWeighs the full debate chain and metrics to produce the classification"
      },
      "typeVersion": 1
    },
    {
      "id": "7a7b2ef3-c43e-410b-8ccc-52f995773656",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1200,
        64
      ],
      "parameters": {
        "color": 7,
        "width": 1840,
        "height": 640,
        "content": "## Sequential Forensic Debate\n\nThree specialists analyze the text in sequence, each building on the previous agent's output"
      },
      "typeVersion": 1
    },
    {
      "id": "2b6e3a76-b40c-4d72-aaa1-75870810ca35",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        624,
        160
      ],
      "parameters": {
        "color": 7,
        "width": 496,
        "height": 368,
        "content": "## Metrics Extraction\n\nComputes burstiness, vocabulary density, repetition, sentence variance from raw text"
      },
      "typeVersion": 1
    },
    {
      "id": "bf5b6ba1-8999-4725-8fc7-59a764cd5063",
      "name": "When chat message received",
      "type": "@n8n/n8n-nodes-langchain.chatTrigger",
      "position": [
        144,
        336
      ],
      "parameters": {
        "public": true,
        "options": {
          "responseMode": "responseNodes"
        },
        "initialMessages": "Hi there! \ud83d\udc4b\nPaste any text and I'll analyze whether it was written by a human, AI, or a mix of both."
      },
      "typeVersion": 1.4
    },
    {
      "id": "ce245602-ee8c-4330-811e-86d94982efb0",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        176,
        1104
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "months"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "61a36f67-e9b7-4d9a-a4b9-fea4afb63168",
      "name": "Format Existing List",
      "type": "n8n-nodes-base.code",
      "position": [
        624,
        1104
      ],
      "parameters": {
        "jsCode": "// Collects all existing fingerprint words from the data table into a single comma-separated string for the LLM to reference.\n\nconst words = $input.all().map(item => item.json.word).filter(w => w);\nreturn [{ json: { existingWords: words.join(', ') } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "57624427-95ba-4da5-8877-1c2b69c57339",
      "name": "Find New Words",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        848,
        1104
      ],
      "parameters": {
        "text": "=You are an expert forensic linguist tracking AI text generation trends. Identify 5 NEW vocabulary words that modern LLMs (like GPT-4 or Claude) currently overuse in their outputs (e.g., flowery, corporate, or repetitive filler words).\n\nCRITICAL: Do NOT include any of these words we already track:\n{{ $json.existingWords }}\n\nReturn ONLY a comma-separated list of the 5 new words in lowercase. No intro, no bullet points, no extra text.",
        "promptType": "define"
      },
      "typeVersion": 1.4
    },
    {
      "id": "266422c6-5a29-4651-8c5e-59da049372a3",
      "name": "Split into Rows",
      "type": "n8n-nodes-base.code",
      "position": [
        1184,
        1104
      ],
      "parameters": {
        "jsCode": "// Splits the LLM's comma-separated response into individual words and formats each as a separate row for saving to the data table.\n\nconst response = $input.first().json.text || $input.first().json.output || \"\";\nconst newWords = response.split(',').map(w => w.trim().toLowerCase()).filter(w => w.length > 0);\n\nreturn newWords.map(word => ({\n  json: { word: word }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "7343b1a2-ae04-4f91-b962-66854bf1e084",
      "name": "Agent Orchestrator",
      "type": "@n8n/n8n-nodes-langchain.chat",
      "position": [
        1328,
        336
      ],
      "parameters": {
        "message": "\ud83d\udd0d Analyzing your text... Three specialist agents are about to debate it. This takes 30-60 seconds.",
        "options": {}
      },
      "typeVersion": 1.3
    },
    {
      "id": "46a11b60-92a3-409d-916f-590f956e070b",
      "name": "Load Fingerprint List",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        368,
        336
      ],
      "parameters": {
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "1uOX45z3usViNlZs",
          "cachedResultUrl": "/projects/hDHhdcMr4jVn06kt/datatables/1uOX45z3usViNlZs",
          "cachedResultName": "AIFingerprints"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "7b6f2eab-fd4a-48e0-89f5-ce034a5d118b",
      "name": "Send Final Report",
      "type": "@n8n/n8n-nodes-langchain.chat",
      "position": [
        4080,
        336
      ],
      "parameters": {
        "message": "={{ $json.output }}",
        "options": {}
      },
      "typeVersion": 1.3
    },
    {
      "id": "9b2a6106-ea4b-4aaf-b8d5-f740f851664f",
      "name": "Check Existing Words",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        384,
        1104
      ],
      "parameters": {
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "1uOX45z3usViNlZs",
          "cachedResultUrl": "/projects/hDHhdcMr4jVn06kt/datatables/1uOX45z3usViNlZs",
          "cachedResultName": "AIFingerprints"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "54f505bd-79f5-4666-86f8-2116dbaca755",
      "name": "Save New Fingerprints",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        1392,
        1104
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "word",
              "type": "string",
              "display": true,
              "removed": false,
              "readOnly": false,
              "required": false,
              "displayName": "word",
              "defaultMatch": false
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [
            "word"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": "1uOX45z3usViNlZs",
          "cachedResultUrl": "/projects/hDHhdcMr4jVn06kt/datatables/1uOX45z3usViNlZs",
          "cachedResultName": "AIFingerprints"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "3774aae6-d1f8-4626-9184-c997d755299b",
      "name": "LLM - Generator",
      "type": "@n8n/n8n-nodes-langchain.lmChatGroq",
      "position": [
        784,
        1312
      ],
      "parameters": {
        "model": "openai/gpt-oss-safeguard-20b",
        "options": {}
      },
      "typeVersion": 1
    },
    {
      "id": "bf8a5267-56eb-486c-84fd-761f6964ca53",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -496,
        928
      ],
      "parameters": {
        "width": 464,
        "height": 528,
        "content": "##  Fingerprint Generator Section\n**Runs:** Monthly (1st at midnight) OR manual trigger\n\n**Purpose:** Keeps detection current by asking an LLM to generate fresh AI fingerprint words based on latest model patterns (GPT-4o, Claude 3.5+, Gemini 1.5+, Llama 3.3+).\n\n**Flow:**\n1. Schedule Trigger \u2192 Get current date\n2. Load existing fingerprints from data table\n3. Ask LLM: \"What new AI markers emerged this month?\"\n4. Compare with existing words (avoid duplicates)\n5. Insert new words into data table with categories + weights\n\n**Customization:**\n- Change schedule: Edit cron expression (default: `0 0 1 * *`)\n- Adjust prompt: Edit \"Find New Words\" node\n- Different LLM: Swap \"LLM - Generator\" node\n\n**First-Time Setup:**\nRun this section ONCE manually to populate initial data table with ~80-100 fingerprint words."
      },
      "typeVersion": 1
    },
    {
      "id": "da2079a1-1c31-4e42-ba48-dfcc3c90717a",
      "name": "Sticky Note11",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        80,
        928
      ],
      "parameters": {
        "color": 7,
        "width": 1584,
        "height": 528,
        "content": "## Generator (Monthly Auto-Update)\n\nRuns 1st of each month: Loads existing words \u2192 Asks LLM for new AI fingerprints \u2192 Saves to data table\n\n**First run:** Populates initial 80-100 fingerprint words"
      },
      "typeVersion": 1
    },
    {
      "id": "0fa04de9-feec-40da-8804-52804d5ee7b7",
      "name": "Sticky Note12",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        32,
        160
      ],
      "parameters": {
        "color": 7,
        "width": 560,
        "height": 368,
        "content": "## Input & Metrics Extraction\n\nChat trigger receives text \u2192 Loads fingerprints from data table \u2192 Extracts stylometric signals"
      },
      "typeVersion": 1
    },
    {
      "id": "87687ccf-8cbc-4377-8a40-1070d8697dc4",
      "name": "LLM - Devil's Advocate",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "onError": "continueRegularOutput",
      "position": [
        2800,
        560
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5-mini"
        },
        "options": {},
        "builtInTools": {}
      },
      "typeVersion": 1.3
    },
    {
      "id": "c65987f4-612d-4699-a982-99cde99d1158",
      "name": "LLM Scanner",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "position": [
        1456,
        544
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "claude-sonnet-4-5-20250929",
          "cachedResultName": "Claude Sonnet 4.5"
        },
        "options": {}
      },
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "92bec431-ff76-4ecb-a1a1-3c979e56f3cd",
      "name": "LLM - Analyst",
      "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
      "position": [
        2016,
        544
      ],
      "parameters": {
        "options": {}
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "callerPolicy": "workflowsFromSameOwner",
    "timeSavedMode": "fixed",
    "availableInMCP": false,
    "executionOrder": "v1",
    "executionTimeout": 180
  },
  "versionId": "db08ce0d-43c2-416a-91af-a48086b05199",
  "connections": {
    "LLM Scanner": {
      "ai_languageModel": [
        [
          {
            "node": "Agent 1 - The Scanner",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Final Verdict": {
      "main": [
        [
          {
            "node": "Format Chat Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM - Analyst": {
      "ai_languageModel": [
        [
          {
            "node": "Agent 2 - Forensic Analyst",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Find New Words": {
      "main": [
        [
          {
            "node": "Split into Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM - Generator": {
      "ai_languageModel": [
        [
          {
            "node": "Find New Words",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Split into Rows": {
      "main": [
        [
          {
            "node": "Save New Fingerprints",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Check Existing Words",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Agent Orchestrator": {
      "main": [
        [
          {
            "node": "Agent 1 - The Scanner",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Chat Message": {
      "main": [
        [
          {
            "node": "Send Final Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Existing Words": {
      "main": [
        [
          {
            "node": "Format Existing List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Existing List": {
      "main": [
        [
          {
            "node": "Find New Words",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Agent 1 - The Scanner": {
      "main": [
        [
          {
            "node": "Package Scanner Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Fingerprint List": {
      "main": [
        [
          {
            "node": "Extract Stylometric Metrics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM - Devil's Advocate": {
      "ai_languageModel": [
        [
          {
            "node": "Agent 3 - Devil's Advocate",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Package Analyst Output": {
      "main": [
        [
          {
            "node": "Agent 3 - Devil's Advocate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Package Scanner Output": {
      "main": [
        [
          {
            "node": "Agent 2 - Forensic Analyst",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Agent 2 - Forensic Analyst": {
      "main": [
        [
          {
            "node": "Package Analyst Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Agent 3 - Devil's Advocate": {
      "main": [
        [
          {
            "node": "Final Verdict",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When chat message received": {
      "main": [
        [
          {
            "node": "Load Fingerprint List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Stylometric Metrics": {
      "main": [
        [
          {
            "node": "Agent Orchestrator",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}