{
  "name": "DocuQuery 2 - Query",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "ask",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "id": "07550150-8e25-4317-a903-db92479289d1",
      "name": "Webhook: /ask",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        190
      ],
      "webhookId": "docuquery-ask",
      "notes": "rawBody is required: Slack signs the raw request body, and re-encoding parsed form fields will not reproduce it byte for byte."
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// --- inlined from src/lib/config.js ---------------------------\n/**\n * Environment reading + validation shared by every workflow.\n *\n * Workflows fail fast with one readable message naming the missing variable,\n * rather than throwing \"undefined is not a string\" six nodes downstream.\n */\n\n/**\n * @param {object} env    Usually n8n's `$env`.\n * @param {object[]} spec [{ key, name, required, default, type }]\n */\nfunction readConfig(env, spec) {\n  const source = env ?? {};\n  const out = {};\n  const missing = [];\n  const invalid = [];\n\n  for (const entry of spec) {\n    const raw = source[entry.key];\n    let value = raw === undefined || raw === null || raw === '' ? entry.default : raw;\n\n    if (value === undefined || value === null || value === '') {\n      if (entry.required) missing.push(entry.key);\n      out[entry.name] = entry.default ?? null;\n      continue;\n    }\n\n    switch (entry.type) {\n      case 'number': {\n        const n = Number(value);\n        if (!Number.isFinite(n)) invalid.push(`${entry.key} must be a number (got \"${value}\")`);\n        value = n;\n        break;\n      }\n      case 'boolean':\n        value = value === true || String(value).trim().toLowerCase() === 'true' || String(value) === '1';\n        break;\n      case 'json':\n        if (typeof value === 'string') {\n          try {\n            value = JSON.parse(value);\n          } catch {\n            invalid.push(`${entry.key} must be valid JSON (got \"${String(value).slice(0, 40)}\")`);\n            value = entry.default;\n          }\n        }\n        break;\n      case 'list':\n        value = String(value)\n          .split(',')\n          .map((v) => v.trim())\n          .filter(Boolean);\n        break;\n      case 'url':\n        value = String(value).trim().replace(/\\/+$/, '');\n        if (!/^https?:\\/\\//i.test(value)) {\n          invalid.push(`${entry.key} must start with https:// (got \"${value}\")`);\n        }\n        break;\n      default:\n        value = String(value).trim();\n    }\n\n    out[entry.name] = value;\n  }\n\n  const problems = [];\n  if (missing.length) problems.push(`missing: ${missing.join(', ')}`);\n  if (invalid.length) problems.push(invalid.join('; '));\n  if (problems.length) {\n    throw new Error(\n      `DocuQuery configuration problem - ${problems.join(' | ')}. ` +\n        `Set these in your n8n environment (see .env.example) and restart n8n.`,\n    );\n  }\n\n  return out;\n}\n\n/**\n * n8n hides `$env` when N8N_BLOCK_ENV_ACCESS_IN_NODE is on. Detect it here so\n * the failure names the setting instead of surfacing as \"$env is not defined\".\n */\nfunction requireEnv(env) {\n  if (!env || typeof env !== 'object') {\n    throw new Error(\n      'This node cannot read environment variables. Set ' +\n        'N8N_BLOCK_ENV_ACCESS_IN_NODE=false on the n8n container and restart it.',\n    );\n  }\n  return env;\n}\n\nconst INGESTION_CONFIG_SPEC = [\n  { key: 'DRIVE_FOLDER_ID', name: 'driveFolderId', required: true },\n  { key: 'PINECONE_INDEX_HOST', name: 'pineconeHost', required: true, type: 'url' },\n  { key: 'PINECONE_NAMESPACE', name: 'pineconeNamespace', default: 'docuquery' },\n  { key: 'PINECONE_DIMENSION', name: 'pineconeDimension', type: 'number', default: 1536 },\n  { key: 'EMBEDDING_MODEL', name: 'embeddingModel', default: 'text-embedding-3-small' },\n  { key: 'EMBED_BATCH_SIZE', name: 'embedBatchSize', type: 'number', default: 96 },\n  { key: 'CHUNK_TOKENS', name: 'chunkTokens', type: 'number', default: 500 },\n  { key: 'CHUNK_OVERLAP_TOKENS', name: 'overlapTokens', type: 'number', default: 50 },\n  { key: 'AIRTABLE_BASE_ID', name: 'airtableBaseId', required: true },\n  { key: 'AIRTABLE_DOCUMENTS_TABLE', name: 'documentsTable', default: 'Documents' },\n  { key: 'MAX_INGEST_RETRIES', name: 'maxRetries', type: 'number', default: 3 },\n  { key: 'DEPARTMENTS', name: 'departments', type: 'list', default: 'General' },\n  { key: 'DEFAULT_DEPARTMENT', name: 'defaultDepartment', default: 'General' },\n  { key: 'DEPARTMENT_FOLDER_MAP', name: 'departmentFolderMap', type: 'json', default: {} },\n];\n\nconst QUERY_CONFIG_SPEC = [\n  { key: 'PINECONE_INDEX_HOST', name: 'pineconeHost', required: true, type: 'url' },\n  { key: 'PINECONE_NAMESPACE', name: 'pineconeNamespace', default: 'docuquery' },\n  { key: 'EMBEDDING_MODEL', name: 'embeddingModel', default: 'text-embedding-3-small' },\n  { key: 'LLM_PROVIDER', name: 'llmProvider', default: 'openai' },\n  { key: 'LLM_MODEL', name: 'llmModel', default: 'gpt-4o' },\n  { key: 'LLM_MAX_TOKENS', name: 'llmMaxTokens', type: 'number', default: 900 },\n  { key: 'LLM_TEMPERATURE', name: 'llmTemperature', type: 'number', default: 0 },\n  { key: 'LLM_BASE_URL', name: 'llmBaseUrl', default: '' },\n  { key: 'RETRIEVAL_TOP_K', name: 'topK', type: 'number', default: 8 },\n  { key: 'RETRIEVAL_MIN_SCORE', name: 'minScore', type: 'number', default: 0.32 },\n  { key: 'RETRIEVAL_MAX_PER_DOC', name: 'maxPerDoc', type: 'number', default: 4 },\n  { key: 'RETRIEVAL_MAX_CONTEXT_TOKENS', name: 'maxContextTokens', type: 'number', default: 6000 },\n  { key: 'DEPARTMENTS', name: 'departments', type: 'list', default: 'General' },\n  { key: 'SLACK_SIGNING_SECRET', name: 'slackSigningSecret', default: '' },\n  { key: 'SLACK_TEAM_ID', name: 'slackTeamId', default: '' },\n  { key: 'SLACK_ALLOW_UNVERIFIED', name: 'slackAllowUnverified', type: 'boolean', default: false },\n  { key: 'QUERY_API_TOKEN', name: 'queryApiToken', default: '' },\n];\n// --- end src/lib/config.js\n\n/**\n * Node: \"Validate Config\" (Query)\n *\n * Same fail-fast contract as the ingestion workflow. Runs before anything is\n * spent on an embedding call.\n */\n\nconst env = requireEnv(typeof $env === 'undefined' ? null : $env);\nreadConfig(env, QUERY_CONFIG_SPEC);\n\nreturn $input.all();\n"
      },
      "id": "a75b41e8-fa4c-49f2-a635-e4ea2e8efa75",
      "name": "Validate Config",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        300,
        190
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// --- inlined from src/lib/config.js ---------------------------\n/**\n * Environment reading + validation shared by every workflow.\n *\n * Workflows fail fast with one readable message naming the missing variable,\n * rather than throwing \"undefined is not a string\" six nodes downstream.\n */\n\n/**\n * @param {object} env    Usually n8n's `$env`.\n * @param {object[]} spec [{ key, name, required, default, type }]\n */\nfunction readConfig(env, spec) {\n  const source = env ?? {};\n  const out = {};\n  const missing = [];\n  const invalid = [];\n\n  for (const entry of spec) {\n    const raw = source[entry.key];\n    let value = raw === undefined || raw === null || raw === '' ? entry.default : raw;\n\n    if (value === undefined || value === null || value === '') {\n      if (entry.required) missing.push(entry.key);\n      out[entry.name] = entry.default ?? null;\n      continue;\n    }\n\n    switch (entry.type) {\n      case 'number': {\n        const n = Number(value);\n        if (!Number.isFinite(n)) invalid.push(`${entry.key} must be a number (got \"${value}\")`);\n        value = n;\n        break;\n      }\n      case 'boolean':\n        value = value === true || String(value).trim().toLowerCase() === 'true' || String(value) === '1';\n        break;\n      case 'json':\n        if (typeof value === 'string') {\n          try {\n            value = JSON.parse(value);\n          } catch {\n            invalid.push(`${entry.key} must be valid JSON (got \"${String(value).slice(0, 40)}\")`);\n            value = entry.default;\n          }\n        }\n        break;\n      case 'list':\n        value = String(value)\n          .split(',')\n          .map((v) => v.trim())\n          .filter(Boolean);\n        break;\n      case 'url':\n        value = String(value).trim().replace(/\\/+$/, '');\n        if (!/^https?:\\/\\//i.test(value)) {\n          invalid.push(`${entry.key} must start with https:// (got \"${value}\")`);\n        }\n        break;\n      default:\n        value = String(value).trim();\n    }\n\n    out[entry.name] = value;\n  }\n\n  const problems = [];\n  if (missing.length) problems.push(`missing: ${missing.join(', ')}`);\n  if (invalid.length) problems.push(invalid.join('; '));\n  if (problems.length) {\n    throw new Error(\n      `DocuQuery configuration problem - ${problems.join(' | ')}. ` +\n        `Set these in your n8n environment (see .env.example) and restart n8n.`,\n    );\n  }\n\n  return out;\n}\n\n/**\n * n8n hides `$env` when N8N_BLOCK_ENV_ACCESS_IN_NODE is on. Detect it here so\n * the failure names the setting instead of surfacing as \"$env is not defined\".\n */\nfunction requireEnv(env) {\n  if (!env || typeof env !== 'object') {\n    throw new Error(\n      'This node cannot read environment variables. Set ' +\n        'N8N_BLOCK_ENV_ACCESS_IN_NODE=false on the n8n container and restart it.',\n    );\n  }\n  return env;\n}\n\nconst INGESTION_CONFIG_SPEC = [\n  { key: 'DRIVE_FOLDER_ID', name: 'driveFolderId', required: true },\n  { key: 'PINECONE_INDEX_HOST', name: 'pineconeHost', required: true, type: 'url' },\n  { key: 'PINECONE_NAMESPACE', name: 'pineconeNamespace', default: 'docuquery' },\n  { key: 'PINECONE_DIMENSION', name: 'pineconeDimension', type: 'number', default: 1536 },\n  { key: 'EMBEDDING_MODEL', name: 'embeddingModel', default: 'text-embedding-3-small' },\n  { key: 'EMBED_BATCH_SIZE', name: 'embedBatchSize', type: 'number', default: 96 },\n  { key: 'CHUNK_TOKENS', name: 'chunkTokens', type: 'number', default: 500 },\n  { key: 'CHUNK_OVERLAP_TOKENS', name: 'overlapTokens', type: 'number', default: 50 },\n  { key: 'AIRTABLE_BASE_ID', name: 'airtableBaseId', required: true },\n  { key: 'AIRTABLE_DOCUMENTS_TABLE', name: 'documentsTable', default: 'Documents' },\n  { key: 'MAX_INGEST_RETRIES', name: 'maxRetries', type: 'number', default: 3 },\n  { key: 'DEPARTMENTS', name: 'departments', type: 'list', default: 'General' },\n  { key: 'DEFAULT_DEPARTMENT', name: 'defaultDepartment', default: 'General' },\n  { key: 'DEPARTMENT_FOLDER_MAP', name: 'departmentFolderMap', type: 'json', default: {} },\n];\n\nconst QUERY_CONFIG_SPEC = [\n  { key: 'PINECONE_INDEX_HOST', name: 'pineconeHost', required: true, type: 'url' },\n  { key: 'PINECONE_NAMESPACE', name: 'pineconeNamespace', default: 'docuquery' },\n  { key: 'EMBEDDING_MODEL', name: 'embeddingModel', default: 'text-embedding-3-small' },\n  { key: 'LLM_PROVIDER', name: 'llmProvider', default: 'openai' },\n  { key: 'LLM_MODEL', name: 'llmModel', default: 'gpt-4o' },\n  { key: 'LLM_MAX_TOKENS', name: 'llmMaxTokens', type: 'number', default: 900 },\n  { key: 'LLM_TEMPERATURE', name: 'llmTemperature', type: 'number', default: 0 },\n  { key: 'LLM_BASE_URL', name: 'llmBaseUrl', default: '' },\n  { key: 'RETRIEVAL_TOP_K', name: 'topK', type: 'number', default: 8 },\n  { key: 'RETRIEVAL_MIN_SCORE', name: 'minScore', type: 'number', default: 0.32 },\n  { key: 'RETRIEVAL_MAX_PER_DOC', name: 'maxPerDoc', type: 'number', default: 4 },\n  { key: 'RETRIEVAL_MAX_CONTEXT_TOKENS', name: 'maxContextTokens', type: 'number', default: 6000 },\n  { key: 'DEPARTMENTS', name: 'departments', type: 'list', default: 'General' },\n  { key: 'SLACK_SIGNING_SECRET', name: 'slackSigningSecret', default: '' },\n  { key: 'SLACK_TEAM_ID', name: 'slackTeamId', default: '' },\n  { key: 'SLACK_ALLOW_UNVERIFIED', name: 'slackAllowUnverified', type: 'boolean', default: false },\n  { key: 'QUERY_API_TOKEN', name: 'queryApiToken', default: '' },\n];\n// --- end src/lib/config.js\n\n// --- inlined from src/lib/slack-verify.js ---------------------\n/**\n * Slack request authentication for the /ask endpoint.\n *\n * Slack signs `v0:{timestamp}:{raw body}` with the app signing secret. That\n * means we need the *raw* body, not n8n's parsed version - re-encoding parsed\n * form fields will not reproduce Slack's byte order. The query workflow sets\n * `rawBody: true` on the Webhook node for exactly this reason.\n *\n * If the raw body genuinely cannot be recovered, the caller can fall back to a\n * workspace-id check, but only when SLACK_ALLOW_UNVERIFIED is explicitly on.\n * The default is to reject, because an unauthenticated /ask endpoint is an\n * anonymous read interface over the whole document index.\n */\n\nconst SLACK_MAX_SKEW_SECONDS = 300;\n\nfunction getCrypto(cryptoImpl) {\n  if (cryptoImpl) return cryptoImpl;\n  try {\n    // eslint-disable-next-line no-undef\n    return require('crypto');\n  } catch {\n    return null;\n  }\n}\n\nfunction verifySlackSignature({\n  rawBody,\n  timestamp,\n  signature,\n  signingSecret,\n  now,\n  maxSkewSeconds = SLACK_MAX_SKEW_SECONDS,\n  cryptoImpl,\n} = {}) {\n  if (!signingSecret) {\n    return { ok: false, reason: 'SLACK_SIGNING_SECRET is not set on the n8n container' };\n  }\n  if (!timestamp || !signature) {\n    return { ok: false, reason: 'request is missing X-Slack-Signature / X-Slack-Request-Timestamp' };\n  }\n  if (typeof rawBody !== 'string') {\n    return { ok: false, reason: 'raw request body was not available to verify' };\n  }\n\n  const age = Math.abs(Number(now) - Number(timestamp));\n  if (!Number.isFinite(age)) {\n    return { ok: false, reason: 'unreadable request timestamp' };\n  }\n  if (age > maxSkewSeconds) {\n    // Replay protection: Slack will not resend a command minutes later.\n    return { ok: false, reason: `stale request (${Math.round(age)}s old, limit ${maxSkewSeconds}s)` };\n  }\n\n  const lib = getCrypto(cryptoImpl);\n  if (!lib) {\n    return {\n      ok: false,\n      reason:\n        'node:crypto is unavailable in Code nodes. Set NODE_FUNCTION_ALLOW_BUILTIN=crypto ' +\n        'on the n8n container and restart it.',\n    };\n  }\n\n  const expected = `v0=${lib\n    .createHmac('sha256', signingSecret)\n    .update(`v0:${timestamp}:${rawBody}`, 'utf8')\n    .digest('hex')}`;\n\n  const a = Buffer.from(expected, 'utf8');\n  const b = Buffer.from(String(signature), 'utf8');\n  const ok = a.length === b.length && lib.timingSafeEqual(a, b);\n\n  return { ok, reason: ok ? 'slack signature verified' : 'slack signature mismatch' };\n}\n\n/** Header lookup that does not care about casing or proxy normalisation. */\nfunction header(headers, name) {\n  if (!headers) return undefined;\n  const target = name.toLowerCase();\n  for (const key of Object.keys(headers)) {\n    if (key.toLowerCase() === target) return headers[key];\n  }\n  return undefined;\n}\n\nfunction parseFormBody(raw) {\n  const out = {};\n  for (const [key, value] of new URLSearchParams(String(raw ?? ''))) out[key] = value;\n  return out;\n}\n\n/**\n * Authenticate either a Slack slash command or a plain webhook caller.\n *\n * @returns {{ok, source, reason, body, verified}} `verified` distinguishes a\n *   real signature check from the degraded team-id fallback, so it can be\n *   logged rather than quietly accepted.\n */\nfunction authenticateRequest({\n  headers = {},\n  rawBody,\n  parsedBody = {},\n  config = {},\n  now,\n  cryptoImpl,\n} = {}) {\n  const slackSignature = header(headers, 'x-slack-signature');\n  const slackTimestamp = header(headers, 'x-slack-request-timestamp');\n  const isSlack = Boolean(slackSignature || parsedBody.command || parsedBody.response_url);\n\n  if (!isSlack) {\n    const auth = String(header(headers, 'authorization') ?? '');\n    const token = auth.replace(/^Bearer\\s+/i, '').trim();\n    if (!config.queryApiToken) {\n      return {\n        ok: false,\n        source: 'webhook',\n        verified: false,\n        reason: 'QUERY_API_TOKEN is not set, so non-Slack callers cannot be authenticated',\n        body: parsedBody,\n      };\n    }\n    const ok = Boolean(token) && token === config.queryApiToken;\n    return {\n      ok,\n      source: 'webhook',\n      verified: ok,\n      reason: ok ? 'bearer token accepted' : 'invalid or missing bearer token',\n      body: parsedBody,\n    };\n  }\n\n  const result = verifySlackSignature({\n    rawBody,\n    timestamp: slackTimestamp,\n    signature: slackSignature,\n    signingSecret: config.slackSigningSecret,\n    now,\n    cryptoImpl,\n  });\n\n  const body = typeof rawBody === 'string' && rawBody ? parseFormBody(rawBody) : parsedBody;\n\n  if (result.ok) {\n    if (config.slackTeamId && body.team_id && body.team_id !== config.slackTeamId) {\n      return {\n        ok: false,\n        source: 'slack',\n        verified: true,\n        reason: `signed request from unexpected workspace ${body.team_id}`,\n        body,\n      };\n    }\n    return { ok: true, source: 'slack', verified: true, reason: result.reason, body };\n  }\n\n  const rawBodyMissing = typeof rawBody !== 'string' || rawBody === '';\n  if (rawBodyMissing && config.slackAllowUnverified) {\n    const ok = Boolean(config.slackTeamId) && body.team_id === config.slackTeamId;\n    return {\n      ok,\n      source: 'slack',\n      verified: false,\n      reason: ok\n        ? 'DEGRADED: raw body unavailable, accepted on SLACK_TEAM_ID match only'\n        : 'raw body unavailable and SLACK_TEAM_ID did not match',\n      body,\n    };\n  }\n\n  return { ok: false, source: 'slack', verified: false, reason: result.reason, body };\n}\n// --- end src/lib/slack-verify.js\n\n/**\n * Node: \"Authenticate Request\"\n *\n * /ask is a read interface over every indexed document, so it authenticates\n * before it retrieves. Slack requests are checked against the app signing\n * secret; anything else needs a bearer token.\n *\n * The signature is computed over the raw request body, which the Webhook node\n * is configured to keep (`rawBody: true`) and hands over as binary. Two ways of\n * getting at it are tried because which one works depends on the binary data\n * mode the instance is running in.\n */\n\nconst cfg = readConfig(requireEnv($env), QUERY_CONFIG_SPEC);\nconst item = $input.first();\nconst headers = item.json?.headers ?? {};\n\nconst context = typeof this === 'undefined' ? null : this;\n\nasync function readRawBody() {\n  try {\n    if (context?.helpers?.getBinaryDataBuffer) {\n      const buffer = await context.helpers.getBinaryDataBuffer(0, 'data');\n      if (buffer) return buffer.toString('utf8');\n    }\n  } catch (error) {\n    console.log(`getBinaryDataBuffer unavailable (${error.message}); trying inline binary`);\n  }\n  const inline = item.binary?.data?.data;\n  if (typeof inline === 'string' && inline) return Buffer.from(inline, 'base64').toString('utf8');\n  return undefined;\n}\n\nconst rawBody = await readRawBody();\n\nconst result = authenticateRequest({\n  headers,\n  rawBody,\n  parsedBody: item.json?.body ?? {},\n  config: cfg,\n  now: Math.floor(Date.now() / 1000),\n});\n\nif (!result.ok) {\n  console.log(`REJECTED /ask request: ${result.reason}`);\n} else if (!result.verified) {\n  console.log(`WARNING: accepted an unverified /ask request - ${result.reason}`);\n}\n\nreturn [\n  {\n    json: {\n      authorized: result.ok,\n      verified: result.verified,\n      auth_reason: result.reason,\n      source: result.source,\n      body: result.body ?? {},\n      received_at: new Date().toISOString(),\n      started_ms: Date.now(),\n    },\n  },\n];\n"
      },
      "id": "08b43712-e63a-4de9-aa01-72ca3b043454",
      "name": "Authenticate Request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        600,
        190
      ],
      "notes": "Slack signature (HMAC + replay window), or a bearer token for other callers."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "83af6e08-a51b-4841-a5af-abf018c64561",
              "leftValue": "={{ $json.authorized }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "6a4b0f00-ef26-4687-a248-b5013c44f496",
      "name": "Authorized?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        900,
        190
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ error: \"unauthorized\", reason: $json.auth_reason }) }}",
        "options": {
          "responseCode": 401
        }
      },
      "id": "874423d3-3794-4f3f-a9e2-77d933f372a4",
      "name": "Respond: Unauthorized",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1200,
        570
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// --- inlined from src/lib/config.js ---------------------------\n/**\n * Environment reading + validation shared by every workflow.\n *\n * Workflows fail fast with one readable message naming the missing variable,\n * rather than throwing \"undefined is not a string\" six nodes downstream.\n */\n\n/**\n * @param {object} env    Usually n8n's `$env`.\n * @param {object[]} spec [{ key, name, required, default, type }]\n */\nfunction readConfig(env, spec) {\n  const source = env ?? {};\n  const out = {};\n  const missing = [];\n  const invalid = [];\n\n  for (const entry of spec) {\n    const raw = source[entry.key];\n    let value = raw === undefined || raw === null || raw === '' ? entry.default : raw;\n\n    if (value === undefined || value === null || value === '') {\n      if (entry.required) missing.push(entry.key);\n      out[entry.name] = entry.default ?? null;\n      continue;\n    }\n\n    switch (entry.type) {\n      case 'number': {\n        const n = Number(value);\n        if (!Number.isFinite(n)) invalid.push(`${entry.key} must be a number (got \"${value}\")`);\n        value = n;\n        break;\n      }\n      case 'boolean':\n        value = value === true || String(value).trim().toLowerCase() === 'true' || String(value) === '1';\n        break;\n      case 'json':\n        if (typeof value === 'string') {\n          try {\n            value = JSON.parse(value);\n          } catch {\n            invalid.push(`${entry.key} must be valid JSON (got \"${String(value).slice(0, 40)}\")`);\n            value = entry.default;\n          }\n        }\n        break;\n      case 'list':\n        value = String(value)\n          .split(',')\n          .map((v) => v.trim())\n          .filter(Boolean);\n        break;\n      case 'url':\n        value = String(value).trim().replace(/\\/+$/, '');\n        if (!/^https?:\\/\\//i.test(value)) {\n          invalid.push(`${entry.key} must start with https:// (got \"${value}\")`);\n        }\n        break;\n      default:\n        value = String(value).trim();\n    }\n\n    out[entry.name] = value;\n  }\n\n  const problems = [];\n  if (missing.length) problems.push(`missing: ${missing.join(', ')}`);\n  if (invalid.length) problems.push(invalid.join('; '));\n  if (problems.length) {\n    throw new Error(\n      `DocuQuery configuration problem - ${problems.join(' | ')}. ` +\n        `Set these in your n8n environment (see .env.example) and restart n8n.`,\n    );\n  }\n\n  return out;\n}\n\n/**\n * n8n hides `$env` when N8N_BLOCK_ENV_ACCESS_IN_NODE is on. Detect it here so\n * the failure names the setting instead of surfacing as \"$env is not defined\".\n */\nfunction requireEnv(env) {\n  if (!env || typeof env !== 'object') {\n    throw new Error(\n      'This node cannot read environment variables. Set ' +\n        'N8N_BLOCK_ENV_ACCESS_IN_NODE=false on the n8n container and restart it.',\n    );\n  }\n  return env;\n}\n\nconst INGESTION_CONFIG_SPEC = [\n  { key: 'DRIVE_FOLDER_ID', name: 'driveFolderId', required: true },\n  { key: 'PINECONE_INDEX_HOST', name: 'pineconeHost', required: true, type: 'url' },\n  { key: 'PINECONE_NAMESPACE', name: 'pineconeNamespace', default: 'docuquery' },\n  { key: 'PINECONE_DIMENSION', name: 'pineconeDimension', type: 'number', default: 1536 },\n  { key: 'EMBEDDING_MODEL', name: 'embeddingModel', default: 'text-embedding-3-small' },\n  { key: 'EMBED_BATCH_SIZE', name: 'embedBatchSize', type: 'number', default: 96 },\n  { key: 'CHUNK_TOKENS', name: 'chunkTokens', type: 'number', default: 500 },\n  { key: 'CHUNK_OVERLAP_TOKENS', name: 'overlapTokens', type: 'number', default: 50 },\n  { key: 'AIRTABLE_BASE_ID', name: 'airtableBaseId', required: true },\n  { key: 'AIRTABLE_DOCUMENTS_TABLE', name: 'documentsTable', default: 'Documents' },\n  { key: 'MAX_INGEST_RETRIES', name: 'maxRetries', type: 'number', default: 3 },\n  { key: 'DEPARTMENTS', name: 'departments', type: 'list', default: 'General' },\n  { key: 'DEFAULT_DEPARTMENT', name: 'defaultDepartment', default: 'General' },\n  { key: 'DEPARTMENT_FOLDER_MAP', name: 'departmentFolderMap', type: 'json', default: {} },\n];\n\nconst QUERY_CONFIG_SPEC = [\n  { key: 'PINECONE_INDEX_HOST', name: 'pineconeHost', required: true, type: 'url' },\n  { key: 'PINECONE_NAMESPACE', name: 'pineconeNamespace', default: 'docuquery' },\n  { key: 'EMBEDDING_MODEL', name: 'embeddingModel', default: 'text-embedding-3-small' },\n  { key: 'LLM_PROVIDER', name: 'llmProvider', default: 'openai' },\n  { key: 'LLM_MODEL', name: 'llmModel', default: 'gpt-4o' },\n  { key: 'LLM_MAX_TOKENS', name: 'llmMaxTokens', type: 'number', default: 900 },\n  { key: 'LLM_TEMPERATURE', name: 'llmTemperature', type: 'number', default: 0 },\n  { key: 'LLM_BASE_URL', name: 'llmBaseUrl', default: '' },\n  { key: 'RETRIEVAL_TOP_K', name: 'topK', type: 'number', default: 8 },\n  { key: 'RETRIEVAL_MIN_SCORE', name: 'minScore', type: 'number', default: 0.32 },\n  { key: 'RETRIEVAL_MAX_PER_DOC', name: 'maxPerDoc', type: 'number', default: 4 },\n  { key: 'RETRIEVAL_MAX_CONTEXT_TOKENS', name: 'maxContextTokens', type: 'number', default: 6000 },\n  { key: 'DEPARTMENTS', name: 'departments', type: 'list', default: 'General' },\n  { key: 'SLACK_SIGNING_SECRET', name: 'slackSigningSecret', default: '' },\n  { key: 'SLACK_TEAM_ID', name: 'slackTeamId', default: '' },\n  { key: 'SLACK_ALLOW_UNVERIFIED', name: 'slackAllowUnverified', type: 'boolean', default: false },\n  { key: 'QUERY_API_TOKEN', name: 'queryApiToken', default: '' },\n];\n// --- end src/lib/config.js\n\n// --- inlined from src/lib/query-parse.js ----------------------\n/**\n * Turn \"/ask dept:HR how much parental leave do I get?\" into a retrieval plan.\n *\n * Filters are stripped from the question before it reaches the embedding model:\n * embedding \"dept:HR\" alongside the question pollutes the vector and costs\n * recall for no benefit, since the filter is applied by Pinecone metadata\n * instead.\n */\n\nconst HELP_TEXT = [\n  '*DocuQuery* - ask a question about the indexed documents.',\n  '',\n  '`/ask how many days of parental leave do I get?`',\n  '`/ask dept:HR what is the notice period?`  - restrict to one department',\n  '`/ask k:12 what does the security policy say about laptops?`  - widen retrieval',\n  '',\n  'Answers cite the source document, and say so when the documents do not cover it.',\n].join('\\n');\n\nfunction helpText(departments = []) {\n  if (!departments.length) return HELP_TEXT;\n  return `${HELP_TEXT}\\n\\nDepartments: ${departments.join(', ')}`;\n}\n\nfunction matchDepartment(value, known) {\n  const raw = String(value ?? '').trim();\n  if (!raw) return null;\n  const exact = known.find((k) => k.toLowerCase() === raw.toLowerCase());\n  if (exact) return exact;\n  const prefix = known.filter((k) => k.toLowerCase().startsWith(raw.toLowerCase()));\n  return prefix.length === 1 ? prefix[0] : null;\n}\n\n/**\n * @returns {{question, department, topK, isHelp, unknownDepartment, flags}}\n */\nfunction parseQuery(input, { departments = [], defaultTopK = 8, maxTopK = 30 } = {}) {\n  let text = String(input ?? '').trim();\n  const flags = {};\n  let unknownDepartment = null;\n  let department = null;\n  let topK = defaultTopK;\n\n  // dept:HR | department:HR | in:HR | --dept HR\n  const deptRe = /(?:^|\\s)(?:--)?(?:dept|department|in)[:=\\s]+(\"[^\"]+\"|\\S+)/i;\n  const deptMatch = deptRe.exec(text);\n  if (deptMatch) {\n    const value = deptMatch[1].replace(/^\"|\"$/g, '');\n    const resolved = matchDepartment(value, departments);\n    if (resolved) department = resolved;\n    else unknownDepartment = value;\n    text = text.replace(deptMatch[0], ' ');\n  }\n\n  // k:12 | topk:12 | --k 12\n  const kRe = /(?:^|\\s)(?:--)?(?:k|topk|top_k)[:=\\s]+(\\d{1,3})/i;\n  const kMatch = kRe.exec(text);\n  if (kMatch) {\n    topK = Math.min(maxTopK, Math.max(1, Number(kMatch[1])));\n    flags.topKOverridden = true;\n    text = text.replace(kMatch[0], ' ');\n  }\n\n  const question = text.replace(/\\s+/g, ' ').trim();\n  const isHelp = !question || /^(help|\\?|usage)$/i.test(question);\n\n  return { question, department, topK, isHelp, unknownDepartment, flags };\n}\n\n/** Pinecone metadata filter. Null means \"search everything\". */\nfunction buildFilter({ department } = {}) {\n  if (!department) return null;\n  return { department: { $eq: department } };\n}\n\n/**\n * Body for a Slack request that never reaches retrieval - help, or a\n * department the index has never seen.\n */\nfunction shortCircuitMessage({ isHelp, unknownDepartment, departments }) {\n  if (isHelp) return helpText(departments);\n  if (unknownDepartment) {\n    return (\n      `I do not have a department called *${unknownDepartment}*. ` +\n      `Known departments: ${departments.join(', ') || 'none indexed yet'}.`\n    );\n  }\n  return null;\n}\n// --- end src/lib/query-parse.js\n\n// --- inlined from src/lib/slack-format.js ---------------------\n/**\n * Slack presentation: Markdown -> mrkdwn, and the Block Kit payload.\n *\n * Slack is not Markdown. Bold is *one* asterisk, italics are underscores, links\n * are <url|label>, and &, < and > must be escaped first. Models write Markdown,\n * so translate rather than hope.\n */\n\nconst SECTION_LIMIT = 2900; // Slack hard-caps section text at 3000 chars.\n\nfunction toMrkdwn(input) {\n  let text = String(input ?? '');\n\n  // Escape before injecting any <url|label>, or the injected markup gets eaten.\n  text = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n\n  // Links: [label](url)\n  text = text.replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)/g, '<$2|$1>');\n\n  // Bold first, via a placeholder, so the italic pass cannot eat the markers.\n  // The sentinel is a NUL byte, which cannot appear in model output.\n  const NUL = String.fromCharCode(0);\n  const BOLD = `${NUL}B${NUL}`;\n  text = text.replace(/\\*\\*([^*]+)\\*\\*/g, `${BOLD}$1${BOLD}`);\n  text = text.replace(/__([^_]+)__/g, `${BOLD}$1${BOLD}`);\n\n  // Remaining single markers are italics in Markdown, underscores in Slack.\n  text = text.replace(/(^|[\\s(])\\*([^*\\n]+)\\*(?=[\\s).,!?]|$)/g, '$1_$2_');\n\n  text = text.split(BOLD).join('*');\n\n  // Headings become bold lines; Slack has no heading syntax in mrkdwn.\n  text = text.replace(/^\\s{0,3}#{1,6}\\s+(.+)$/gm, '*$1*');\n\n  // Normalise bullets.\n  text = text.replace(/^\\s*[-*+]\\s+/gm, '- ');\n\n  return text.trim();\n}\n\n/** Split long text across sections so nothing is silently truncated by Slack. */\nfunction sectionBlocks(text) {\n  const blocks = [];\n  let rest = text;\n  while (rest.length > SECTION_LIMIT) {\n    let cut = rest.lastIndexOf('\\n', SECTION_LIMIT);\n    if (cut < SECTION_LIMIT * 0.5) cut = rest.lastIndexOf(' ', SECTION_LIMIT);\n    if (cut < SECTION_LIMIT * 0.5) cut = SECTION_LIMIT;\n    blocks.push({ type: 'section', text: { type: 'mrkdwn', text: rest.slice(0, cut).trim() } });\n    rest = rest.slice(cut).trim();\n  }\n  if (rest) blocks.push({ type: 'section', text: { type: 'mrkdwn', text: rest } });\n  return blocks;\n}\n\nfunction sourceLine(source) {\n  const label = source.drive_url\n    ? `<${source.drive_url}|${source.filename}>`\n    : `*${source.filename}*`;\n  const bits = [source.page_label, source.department].filter(Boolean).join(' - ');\n  return `[${source.n}] ${label}${bits ? ` - ${bits}` : ''}`;\n}\n\n/**\n * @returns {{blocks: object[], text: string}} `text` is the notification\n *   fallback Slack shows in sidebars and push notifications.\n */\nfunction buildAnswerBlocks({\n  question,\n  answer,\n  answered = true,\n  sources = [],\n  followup = null,\n  department = null,\n  model = null,\n  latencyMs = null,\n  topScore = null,\n} = {}) {\n  const blocks = [\n    {\n      type: 'context',\n      elements: [\n        {\n          type: 'mrkdwn',\n          text: `:mag: *${toMrkdwn(question)}*${department ? `  ·  _${department}_` : ''}`,\n        },\n      ],\n    },\n    ...sectionBlocks(toMrkdwn(answer)),\n  ];\n\n  if (answered && sources.length) {\n    blocks.push({ type: 'divider' });\n    blocks.push({\n      type: 'section',\n      text: { type: 'mrkdwn', text: `*Sources*\\n${sources.map(sourceLine).join('\\n')}` },\n    });\n  }\n\n  if (followup) {\n    blocks.push({\n      type: 'context',\n      elements: [{ type: 'mrkdwn', text: `:bulb: ${toMrkdwn(followup)}` }],\n    });\n  }\n\n  const footer = [\n    model ? `model ${model}` : null,\n    topScore != null ? `top match ${topScore}` : null,\n    latencyMs != null ? `${latencyMs} ms` : null,\n  ]\n    .filter(Boolean)\n    .join('  ·  ');\n  if (footer) {\n    blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: footer }] });\n  }\n\n  return {\n    blocks,\n    text: answered\n      ? `${String(answer).slice(0, 200)}`\n      : 'No answer found in the indexed documents.',\n  };\n}\n\n/** The immediate 200 Slack needs within 3 seconds, before retrieval starts. */\nfunction ackPayload(question) {\n  return {\n    response_type: 'ephemeral',\n    text: `:mag: Searching the document index for _${String(question).slice(0, 150)}_ ...`,\n  };\n}\n// --- end src/lib/slack-format.js\n\n/**\n * Node: \"Parse Question\"\n *\n * Separates the question from its filters, and decides whether this request can\n * be answered without touching the index at all (help text, or a department\n * that does not exist). Those short-circuits matter: they are the cheapest\n * possible response and they keep obviously-unanswerable queries away from the\n * model.\n */\n\nconst cfg = readConfig(requireEnv($env), QUERY_CONFIG_SPEC);\nconst auth = $input.first().json;\nconst body = auth.body ?? {};\n\nconst isSlack = auth.source === 'slack';\nconst rawText = isSlack ? body.text : body.question ?? body.text ?? body.q;\n\nconst parsed = parseQuery(rawText, {\n  departments: cfg.departments,\n  defaultTopK: cfg.topK,\n});\n\n// A non-Slack caller can also pass filters as JSON fields.\nconst department =\n  parsed.department ??\n  (body.department ? String(body.department) : null);\nconst topK = Number(body.top_k ?? body.topK ?? parsed.topK) || cfg.topK;\n\nconst shortCircuit = shortCircuitMessage({\n  isHelp: parsed.isHelp,\n  unknownDepartment: parsed.unknownDepartment,\n  departments: cfg.departments,\n});\n\nreturn [\n  {\n    json: {\n      ...auth,\n      question: parsed.question,\n      department,\n      top_k: Math.min(30, Math.max(1, topK)),\n      filter: buildFilter({ department }),\n      is_slack: isSlack,\n      // Slack must get a 200 within 3 seconds, so it receives an immediate\n      // acknowledgement and the real answer arrives later via response_url.\n      // A plain webhook caller instead blocks and gets the answer in the\n      // response body.\n      needs_ack: isSlack,\n      response_url: body.response_url ?? null,\n      channel_id: body.channel_id ?? null,\n      channel_name: body.channel_name ?? null,\n      user_id: body.user_id ?? null,\n      user_name: body.user_name ?? null,\n      team_id: body.team_id ?? null,\n      short_circuit: shortCircuit,\n      should_retrieve: !shortCircuit,\n      ack: ackPayload(parsed.question || 'your question'),\n    },\n  },\n];\n"
      },
      "id": "117a049e-3d64-4b42-a75a-cde1a9f802a6",
      "name": "Parse Question",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1200,
        190
      ],
      "notes": "Filters like \"dept:HR\" are stripped before embedding - they belong in the Pinecone metadata filter, not in the vector."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "5100bf78-1953-4b39-a2c7-511cbda28179",
              "leftValue": "={{ $json.needs_ack }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "bfa1fc5d-8316-459b-aed2-79968d3280bd",
      "name": "Needs Slack Ack?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1500,
        190
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify($json.ack) }}",
        "options": {}
      },
      "id": "c92e9e34-1813-44cd-a1d0-bde24bb0ad29",
      "name": "Respond: Slack Ack",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1800,
        0
      ],
      "notes": "Beats the 3s deadline. Execution continues past this node."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "92394d8b-1991-4119-a6cb-f9741294430a",
              "leftValue": "={{ $json.should_retrieve }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "15a9482b-61d9-4ece-add8-af1dab4ce7f1",
      "name": "Should Retrieve?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2100,
        190
      ],
      "notes": "Help text and unknown-department replies never touch the index."
    },
    {
      "parameters": {
        "options": {
          "timeout": 30000
        },
        "method": "POST",
        "url": "https://api.openai.com/v1/embeddings",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: $env.EMBEDDING_MODEL || \"text-embedding-3-small\", input: $json.question }) }}"
      },
      "id": "a3aef673-826d-430a-ad46-718185ff5279",
      "name": "OpenAI: Embed Question",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2400,
        95
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "notes": "Must be the same model the documents were indexed with."
    },
    {
      "parameters": {
        "options": {
          "timeout": 30000
        },
        "method": "POST",
        "url": "={{ $env.PINECONE_INDEX_HOST }}/query",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "X-Pinecone-API-Version",
              "value": "={{ $env.PINECONE_API_VERSION || \"2025-01\" }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ namespace: $env.PINECONE_NAMESPACE || \"docuquery\", vector: $json.data[0].embedding, topK: $('Parse Question').first().json.top_k, includeMetadata: true, filter: $('Parse Question').first().json.filter || undefined }) }}"
      },
      "id": "6b60f688-4cdf-4e14-a5ee-75f43ce87d5a",
      "name": "Pinecone: Query",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2700,
        95
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "notes": "Credential: Header Auth, header name \"Api-Key\"."
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// --- inlined from src/lib/config.js ---------------------------\n/**\n * Environment reading + validation shared by every workflow.\n *\n * Workflows fail fast with one readable message naming the missing variable,\n * rather than throwing \"undefined is not a string\" six nodes downstream.\n */\n\n/**\n * @param {object} env    Usually n8n's `$env`.\n * @param {object[]} spec [{ key, name, required, default, type }]\n */\nfunction readConfig(env, spec) {\n  const source = env ?? {};\n  const out = {};\n  const missing = [];\n  const invalid = [];\n\n  for (const entry of spec) {\n    const raw = source[entry.key];\n    let value = raw === undefined || raw === null || raw === '' ? entry.default : raw;\n\n    if (value === undefined || value === null || value === '') {\n      if (entry.required) missing.push(entry.key);\n      out[entry.name] = entry.default ?? null;\n      continue;\n    }\n\n    switch (entry.type) {\n      case 'number': {\n        const n = Number(value);\n        if (!Number.isFinite(n)) invalid.push(`${entry.key} must be a number (got \"${value}\")`);\n        value = n;\n        break;\n      }\n      case 'boolean':\n        value = value === true || String(value).trim().toLowerCase() === 'true' || String(value) === '1';\n        break;\n      case 'json':\n        if (typeof value === 'string') {\n          try {\n            value = JSON.parse(value);\n          } catch {\n            invalid.push(`${entry.key} must be valid JSON (got \"${String(value).slice(0, 40)}\")`);\n            value = entry.default;\n          }\n        }\n        break;\n      case 'list':\n        value = String(value)\n          .split(',')\n          .map((v) => v.trim())\n          .filter(Boolean);\n        break;\n      case 'url':\n        value = String(value).trim().replace(/\\/+$/, '');\n        if (!/^https?:\\/\\//i.test(value)) {\n          invalid.push(`${entry.key} must start with https:// (got \"${value}\")`);\n        }\n        break;\n      default:\n        value = String(value).trim();\n    }\n\n    out[entry.name] = value;\n  }\n\n  const problems = [];\n  if (missing.length) problems.push(`missing: ${missing.join(', ')}`);\n  if (invalid.length) problems.push(invalid.join('; '));\n  if (problems.length) {\n    throw new Error(\n      `DocuQuery configuration problem - ${problems.join(' | ')}. ` +\n        `Set these in your n8n environment (see .env.example) and restart n8n.`,\n    );\n  }\n\n  return out;\n}\n\n/**\n * n8n hides `$env` when N8N_BLOCK_ENV_ACCESS_IN_NODE is on. Detect it here so\n * the failure names the setting instead of surfacing as \"$env is not defined\".\n */\nfunction requireEnv(env) {\n  if (!env || typeof env !== 'object') {\n    throw new Error(\n      'This node cannot read environment variables. Set ' +\n        'N8N_BLOCK_ENV_ACCESS_IN_NODE=false on the n8n container and restart it.',\n    );\n  }\n  return env;\n}\n\nconst INGESTION_CONFIG_SPEC = [\n  { key: 'DRIVE_FOLDER_ID', name: 'driveFolderId', required: true },\n  { key: 'PINECONE_INDEX_HOST', name: 'pineconeHost', required: true, type: 'url' },\n  { key: 'PINECONE_NAMESPACE', name: 'pineconeNamespace', default: 'docuquery' },\n  { key: 'PINECONE_DIMENSION', name: 'pineconeDimension', type: 'number', default: 1536 },\n  { key: 'EMBEDDING_MODEL', name: 'embeddingModel', default: 'text-embedding-3-small' },\n  { key: 'EMBED_BATCH_SIZE', name: 'embedBatchSize', type: 'number', default: 96 },\n  { key: 'CHUNK_TOKENS', name: 'chunkTokens', type: 'number', default: 500 },\n  { key: 'CHUNK_OVERLAP_TOKENS', name: 'overlapTokens', type: 'number', default: 50 },\n  { key: 'AIRTABLE_BASE_ID', name: 'airtableBaseId', required: true },\n  { key: 'AIRTABLE_DOCUMENTS_TABLE', name: 'documentsTable', default: 'Documents' },\n  { key: 'MAX_INGEST_RETRIES', name: 'maxRetries', type: 'number', default: 3 },\n  { key: 'DEPARTMENTS', name: 'departments', type: 'list', default: 'General' },\n  { key: 'DEFAULT_DEPARTMENT', name: 'defaultDepartment', default: 'General' },\n  { key: 'DEPARTMENT_FOLDER_MAP', name: 'departmentFolderMap', type: 'json', default: {} },\n];\n\nconst QUERY_CONFIG_SPEC = [\n  { key: 'PINECONE_INDEX_HOST', name: 'pineconeHost', required: true, type: 'url' },\n  { key: 'PINECONE_NAMESPACE', name: 'pineconeNamespace', default: 'docuquery' },\n  { key: 'EMBEDDING_MODEL', name: 'embeddingModel', default: 'text-embedding-3-small' },\n  { key: 'LLM_PROVIDER', name: 'llmProvider', default: 'openai' },\n  { key: 'LLM_MODEL', name: 'llmModel', default: 'gpt-4o' },\n  { key: 'LLM_MAX_TOKENS', name: 'llmMaxTokens', type: 'number', default: 900 },\n  { key: 'LLM_TEMPERATURE', name: 'llmTemperature', type: 'number', default: 0 },\n  { key: 'LLM_BASE_URL', name: 'llmBaseUrl', default: '' },\n  { key: 'RETRIEVAL_TOP_K', name: 'topK', type: 'number', default: 8 },\n  { key: 'RETRIEVAL_MIN_SCORE', name: 'minScore', type: 'number', default: 0.32 },\n  { key: 'RETRIEVAL_MAX_PER_DOC', name: 'maxPerDoc', type: 'number', default: 4 },\n  { key: 'RETRIEVAL_MAX_CONTEXT_TOKENS', name: 'maxContextTokens', type: 'number', default: 6000 },\n  { key: 'DEPARTMENTS', name: 'departments', type: 'list', default: 'General' },\n  { key: 'SLACK_SIGNING_SECRET', name: 'slackSigningSecret', default: '' },\n  { key: 'SLACK_TEAM_ID', name: 'slackTeamId', default: '' },\n  { key: 'SLACK_ALLOW_UNVERIFIED', name: 'slackAllowUnverified', type: 'boolean', default: false },\n  { key: 'QUERY_API_TOKEN', name: 'queryApiToken', default: '' },\n];\n// --- end src/lib/config.js\n\n// --- inlined from src/lib/citations.js ------------------------\n/**\n * Turning Pinecone matches into a citable context block.\n *\n * Two jobs:\n *   1. Decide which retrieved chunks are worth showing the model (score floor,\n *      per-document cap, token budget). Everything the model cannot see, it\n *      cannot cite - and everything it can see, it might.\n *   2. Number the sources by *document*, so an answer citing [2] points at a\n *      file a human can open, not at chunk 47.\n */\n\nconst CONTEXT_DEFAULTS = {\n  minScore: 0.32,\n  maxTokens: 6000,\n  maxPerDoc: 4,\n  maxChunks: 12,\n  maxSources: 6,\n  charsPerToken: 4,\n};\n\nfunction pageLabel(source) {\n  const pages = source.pages.filter((p) => Number.isFinite(p)).sort((a, b) => a - b);\n  if (!pages.length) return '';\n  const first = pages[0];\n  const last = pages[pages.length - 1];\n  return first === last ? `p. ${first}` : `pp. ${first}-${last}`;\n}\n\n/**\n * @param {object[]} matches Pinecone `matches` array.\n * @returns {{passages, sources, contextText, tokens, topScore, considered, dropped}}\n */\nfunction selectContext(matches, options = {}) {\n  const opts = { ...CONTEXT_DEFAULTS, ...options };\n  const ranked = [...(matches ?? [])].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));\n\n  const seenIds = new Set();\n  const perDoc = new Map();\n  const sourcesByDoc = new Map();\n  const passages = [];\n  let tokens = 0;\n  let dropped = 0;\n\n  for (const match of ranked) {\n    const meta = match.metadata ?? {};\n    const text = String(meta.text ?? '').trim();\n    const score = Number(match.score ?? 0);\n    const docId = String(meta.doc_id ?? 'unknown');\n    const id = String(match.id ?? `${docId}#${meta.chunk_index ?? passages.length}`);\n\n    if (!text) continue;\n    if (seenIds.has(id)) continue;\n    if (score < opts.minScore) {\n      dropped += 1;\n      continue;\n    }\n    if ((perDoc.get(docId) ?? 0) >= opts.maxPerDoc) {\n      dropped += 1;\n      continue;\n    }\n    if (!sourcesByDoc.has(docId) && sourcesByDoc.size >= opts.maxSources) {\n      dropped += 1;\n      continue;\n    }\n\n    const cost = Math.ceil(text.length / opts.charsPerToken);\n    if (passages.length && tokens + cost > opts.maxTokens) {\n      dropped += 1;\n      continue;\n    }\n\n    if (!sourcesByDoc.has(docId)) {\n      sourcesByDoc.set(docId, {\n        n: sourcesByDoc.size + 1,\n        doc_id: docId,\n        filename: String(meta.filename ?? 'unknown.pdf'),\n        title: String(meta.title ?? meta.filename ?? 'unknown.pdf'),\n        department: meta.department ?? null,\n        drive_url: meta.drive_url ?? null,\n        upload_date: meta.upload_date ?? null,\n        pages: [],\n        chunks: [],\n        top_score: score,\n      });\n    }\n\n    const source = sourcesByDoc.get(docId);\n    const pageStart = Number.isFinite(Number(meta.page_start)) ? Number(meta.page_start) : null;\n    const pageEnd = Number.isFinite(Number(meta.page_end)) ? Number(meta.page_end) : pageStart;\n    if (pageStart != null) source.pages.push(pageStart);\n    if (pageEnd != null && pageEnd !== pageStart) source.pages.push(pageEnd);\n    source.chunks.push(Number(meta.chunk_index ?? -1));\n    source.top_score = Math.max(source.top_score, score);\n\n    seenIds.add(id);\n    perDoc.set(docId, (perDoc.get(docId) ?? 0) + 1);\n    tokens += cost;\n    passages.push({\n      n: source.n,\n      id,\n      doc_id: docId,\n      filename: source.filename,\n      section: meta.section ?? null,\n      page_start: pageStart,\n      page_end: pageEnd,\n      score: Number(score.toFixed(4)),\n      text,\n    });\n\n    if (passages.length >= opts.maxChunks) break;\n  }\n\n  const sources = [...sourcesByDoc.values()].map((s) => ({\n    ...s,\n    pages: [...new Set(s.pages)].sort((a, b) => a - b),\n    page_label: pageLabel(s),\n    top_score: Number(s.top_score.toFixed(4)),\n  }));\n\n  return {\n    passages,\n    sources,\n    contextText: renderContext(passages),\n    tokens,\n    considered: ranked.length,\n    dropped,\n    topScore: ranked.length ? Number((ranked[0].score ?? 0).toFixed(4)) : 0,\n  };\n}\n\n/** The literal text handed to the model. Every passage is labelled with its citation number. */\nfunction renderContext(passages) {\n  return passages\n    .map((p) => {\n      const locator = [\n        p.filename,\n        p.section ? `\"${p.section}\"` : null,\n        p.page_start ? (p.page_end && p.page_end !== p.page_start ? `pp. ${p.page_start}-${p.page_end}` : `p. ${p.page_start}`) : null,\n      ]\n        .filter(Boolean)\n        .join(' | ');\n      return `[${p.n}] ${locator}\\n${p.text}`;\n    })\n    .join('\\n\\n---\\n\\n');\n}\n\n/**\n * Keep only the sources the model actually cited. If it cited nothing (or cited\n * numbers that do not exist) fall back to everything we gave it, so the user\n * still gets something to check the answer against.\n */\nfunction usedSources(sources, used) {\n  const valid = (Array.isArray(used) ? used : [])\n    .map((n) => Number(n))\n    .filter((n) => Number.isInteger(n) && sources.some((s) => s.n === n));\n  if (!valid.length) return sources;\n  const set = new Set(valid);\n  return sources.filter((s) => set.has(s.n));\n}\n\n/** Plain-text source list, used by the generic webhook response. */\nfunction renderSourceList(sources) {\n  return sources\n    .map((s) => [`[${s.n}] ${s.filename}`, s.page_label, s.department].filter(Boolean).join(' - '))\n    .join('\\n');\n}\n// --- end src/lib/citations.js\n\n/**\n * Node: \"Assemble Context\"\n *\n * Applies the score floor, the per-document cap and the token budget, then\n * numbers the surviving passages by source document.\n *\n * The per-document cap is what stops one long, well-matching handbook from\n * filling every slot and hiding a shorter document that actually answers the\n * question. The score floor is what lets the next node decline to answer\n * instead of feeding the model weak matches it will dutifully summarise.\n */\n\nconst cfg = readConfig(requireEnv($env), QUERY_CONFIG_SPEC);\nconst query = $('Parse Question').first().json;\nconst response = $input.first().json;\n\nconst matches = Array.isArray(response.matches) ? response.matches : [];\n\nconst selection = selectContext(matches, {\n  minScore: cfg.minScore,\n  maxTokens: cfg.maxContextTokens,\n  maxPerDoc: cfg.maxPerDoc,\n});\n\nconsole.log(\n  `\"${query.question}\" -> ${matches.length} match(es), ` +\n    `${selection.passages.length} kept from ${selection.sources.length} document(s), ` +\n    `top score ${selection.topScore}`,\n);\n\nreturn [\n  {\n    json: {\n      ...query,\n      matches_returned: matches.length,\n      passages: selection.passages,\n      sources: selection.sources,\n      context_text: selection.contextText,\n      context_tokens: selection.tokens,\n      top_score: selection.topScore,\n      dropped: selection.dropped,\n      has_context: selection.passages.length > 0,\n    },\n  },\n];\n"
      },
      "id": "850bb748-30af-4532-a1c4-01e5aeb15366",
      "name": "Assemble Context",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3000,
        95
      ],
      "notes": "Score floor, per-document cap, token budget, then number sources by document."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "f5817011-063b-45d9-a788-e829638d6329",
              "leftValue": "={{ $json.has_context }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "4007d60c-5733-42cd-a3bf-590271f88a97",
      "name": "Has Context?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3300,
        95
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// --- inlined from src/lib/config.js ---------------------------\n/**\n * Environment reading + validation shared by every workflow.\n *\n * Workflows fail fast with one readable message naming the missing variable,\n * rather than throwing \"undefined is not a string\" six nodes downstream.\n */\n\n/**\n * @param {object} env    Usually n8n's `$env`.\n * @param {object[]} spec [{ key, name, required, default, type }]\n */\nfunction readConfig(env, spec) {\n  const source = env ?? {};\n  const out = {};\n  const missing = [];\n  const invalid = [];\n\n  for (const entry of spec) {\n    const raw = source[entry.key];\n    let value = raw === undefined || raw === null || raw === '' ? entry.default : raw;\n\n    if (value === undefined || value === null || value === '') {\n      if (entry.required) missing.push(entry.key);\n      out[entry.name] = entry.default ?? null;\n      continue;\n    }\n\n    switch (entry.type) {\n      case 'number': {\n        const n = Number(value);\n        if (!Number.isFinite(n)) invalid.push(`${entry.key} must be a number (got \"${value}\")`);\n        value = n;\n        break;\n      }\n      case 'boolean':\n        value = value === true || String(value).trim().toLowerCase() === 'true' || String(value) === '1';\n        break;\n      case 'json':\n        if (typeof value === 'string') {\n          try {\n            value = JSON.parse(value);\n          } catch {\n            invalid.push(`${entry.key} must be valid JSON (got \"${String(value).slice(0, 40)}\")`);\n            value = entry.default;\n          }\n        }\n        break;\n      case 'list':\n        value = String(value)\n          .split(',')\n          .map((v) => v.trim())\n          .filter(Boolean);\n        break;\n      case 'url':\n        value = String(value).trim().replace(/\\/+$/, '');\n        if (!/^https?:\\/\\//i.test(value)) {\n          invalid.push(`${entry.key} must start with https:// (got \"${value}\")`);\n        }\n        break;\n      default:\n        value = String(value).trim();\n    }\n\n    out[entry.name] = value;\n  }\n\n  const problems = [];\n  if (missing.length) problems.push(`missing: ${missing.join(', ')}`);\n  if (invalid.length) problems.push(invalid.join('; '));\n  if (problems.length) {\n    throw new Error(\n      `DocuQuery configuration problem - ${problems.join(' | ')}. ` +\n        `Set these in your n8n environment (see .env.example) and restart n8n.`,\n    );\n  }\n\n  return out;\n}\n\n/**\n * n8n hides `$env` when N8N_BLOCK_ENV_ACCESS_IN_NODE is on. Detect it here so\n * the failure names the setting instead of surfacing as \"$env is not defined\".\n */\nfunction requireEnv(env) {\n  if (!env || typeof env !== 'object') {\n    throw new Error(\n      'This node cannot read environment variables. Set ' +\n        'N8N_BLOCK_ENV_ACCESS_IN_NODE=false on the n8n container and restart it.',\n    );\n  }\n  return env;\n}\n\nconst INGESTION_CONFIG_SPEC = [\n  { key: 'DRIVE_FOLDER_ID', name: 'driveFolderId', required: true },\n  { key: 'PINECONE_INDEX_HOST', name: 'pineconeHost', required: true, type: 'url' },\n  { key: 'PINECONE_NAMESPACE', name: 'pineconeNamespace', default: 'docuquery' },\n  { key: 'PINECONE_DIMENSION', name: 'pineconeDimension', type: 'number', default: 1536 },\n  { key: 'EMBEDDING_MODEL', name: 'embeddingModel', default: 'text-embedding-3-small' },\n  { key: 'EMBED_BATCH_SIZE', name: 'embedBatchSize', type: 'number', default: 96 },\n  { key: 'CHUNK_TOKENS', name: 'chunkTokens', type: 'number', default: 500 },\n  { key: 'CHUNK_OVERLAP_TOKENS', name: 'overlapTokens', type: 'number', default: 50 },\n  { key: 'AIRTABLE_BASE_ID', name: 'airtableBaseId', required: true },\n  { key: 'AIRTABLE_DOCUMENTS_TABLE', name: 'documentsTable', default: 'Documents' },\n  { key: 'MAX_INGEST_RETRIES', name: 'maxRetries', type: 'number', default: 3 },\n  { key: 'DEPARTMENTS', name: 'departments', type: 'list', default: 'General' },\n  { key: 'DEFAULT_DEPARTMENT', name: 'defaultDepartment', default: 'General' },\n  { key: 'DEPARTMENT_FOLDER_MAP', name: 'departmentFolderMap', type: 'json', default: {} },\n];\n\nconst QUERY_CONFIG_SPEC = [\n  { key: 'PINECONE_INDEX_HOST', name: 'pineconeHost', required: true, type: 'url' },\n  { key: 'PINECONE_NAMESPACE', name: 'pineconeNamespace', default: 'docuquery' },\n  { key: 'EMBEDDING_MODEL', name: 'embeddingModel', default: 'text-embedding-3-small' },\n  { key: 'LLM_PROVIDER', name: 'llmProvider', default: 'openai' },\n  { key: 'LLM_MODEL', name: 'llmModel', default: 'gpt-4o' },\n  { key: 'LLM_MAX_TOKENS', name: 'llmMaxTokens', type: 'number', default: 900 },\n  { key: 'LLM_TEMPERATURE', name: 'llmTemperature', type: 'number', default: 0 },\n  { key: 'LLM_BASE_URL', name: 'llmBaseUrl', default: '' },\n  { key: 'RETRIEVAL_TOP_K', name: 'topK', type: 'number', default: 8 },\n  { key: 'RETRIEVAL_MIN_SCORE', name: 'minScore', type: 'number', default: 0.32 },\n  { key: 'RETRIEVAL_MAX_PER_DOC', name: 'maxPerDoc', type: 'number', default: 4 },\n  { key: 'RETRIEVAL_MAX_CONTEXT_TOKENS', name: 'maxContextTokens', type: 'number', default: 6000 },\n  { key: 'DEPARTMENTS', name: 'departments', type: 'list', default: 'General' },\n  { key: 'SLACK_SIGNING_SECRET', name: 'slackSigningSecret', default: '' },\n  { key: 'SLACK_TEAM_ID', name: 'slackTeamId', default: '' },\n  { key: 'SLACK_ALLOW_UNVERIFIED', name: 'slackAllowUnverified', type: 'boolean', default: false },\n  { key: 'QUERY_API_TOKEN', name: 'queryApiToken', default: '' },\n];\n// --- end src/lib/config.js\n\n// --- inlined from src/lib/llm-request.js ----------------------\n/**\n * Provider-agnostic answer synthesis.\n *\n * The retrieval layer above and the formatting layer below never learn which\n * model answered. This module owns the whole difference: it builds the request\n * body for OpenAI-shaped APIs (OpenAI, Groq, Together, vLLM...) or Anthropic's\n * Messages API, and normalises both responses back to one shape.\n *\n * Swapping providers is an env change (LLM_PROVIDER / LLM_MODEL) plus picking\n * the matching credential on the \"LLM: Anthropic\" node. See docs/setup.md.\n */\n\nconst SYSTEM_PROMPT = [\n  'You are DocuQuery, an assistant that answers questions about the internal',\n  'documents of the company you serve. You are given numbered passages retrieved',\n  'from those documents.',\n  '',\n  'Rules:',\n  '1. Answer ONLY from the passages. You have no other knowledge of this company.',\n  '2. Cite the passage number after every factual claim, like this: \"Employees accrue',\n  '   15 days of leave per year [2].\" A sentence drawing on two passages cites both.',\n  '3. Quote exact wording for figures, dates, deadlines, thresholds and money amounts.',\n  '   Never round, estimate, or infer a number that is not written in a passage.',\n  '4. If the passages do not answer the question, set \"answered\" to false and say what',\n  '   is missing. Do not guess, and do not pad a non-answer with related information.',\n  '5. If passages disagree, say so explicitly and cite each side.',\n  '6. Be direct. Two or three sentences beats a paragraph. No preamble.',\n  '',\n  'Respond with a single JSON object and nothing else:',\n  '{\"answered\": boolean, \"answer\": string, \"used_sources\": number[], \"followup\": string|null}',\n  '',\n  '\"used_sources\" lists only the passage numbers you actually cited. \"followup\" is an',\n  'optional short suggestion for narrowing the question, or null.',\n].join('\\n');\n\nfunction buildUserPrompt({ question, contextText, department }) {\n  const scope = department\n    ? `Retrieval was filtered to the \"${department}\" department.`\n    : 'Retrieval covered all indexed departments.';\n  return [`Question: ${question}`, '', scope, '', 'Passages:', contextText].join('\\n');\n}\n\nconst OPENAI_COMPATIBLE_BASES = {\n  openai: 'https://api.openai.com/v1',\n  groq: 'https://api.groq.com/openai/v1',\n};\n\n/**\n * @returns {{provider, family, url, body, headers}} `family` is what the\n *   Switch node routes on: \"anthropic\" or \"openai\".\n */\nfunction buildLlmRequest({\n  provider = 'openai',\n  model,\n  question,\n  contextText,\n  department = null,\n  maxTokens = 900,\n  temperature = 0,\n  baseUrl = '',\n} = {}) {\n  const name = String(provider).toLowerCase().trim();\n  const userPrompt = buildUserPrompt({ question, contextText, department });\n\n  if (name === 'anthropic') {\n    return {\n      provider: name,\n      family: 'anthropic',\n      url: 'https://api.anthropic.com/v1/messages',\n      headers: { 'anthropic-version': '2023-06-01' },\n      body: {\n        model: model || 'claude-sonnet-5',\n        max_tokens: maxTokens,\n        temperature,\n        system: SYSTEM_PROMPT,\n        messages: [\n          { role: 'user', content: userPrompt },\n          // Prefill forces JSON without a response_format parameter. The \"{\" is\n          // put back by extractLlmText().\n          { role: 'assistant', content: '{' },\n        ],\n      },\n    };\n  }\n\n  if (name === 'openai-compatible' && !baseUrl) {\n    throw new Error('LLM_PROVIDER=openai-compatible requires LLM_BASE_URL to be set.');\n  }\n\n  const base =\n    OPENAI_COMPATIBLE_BASES[name] ||\n    String(baseUrl || '').replace(/\\/+$/, '') ||\n    OPENAI_COMPATIBLE_BASES.openai;\n\n  return {\n    provider: name,\n    family: 'openai',\n    url: `${base}/chat/completions`,\n    headers: {},\n    body: {\n      model: model || 'gpt-4o',\n      temperature,\n      max_tokens: maxTokens,\n      response_format: { type: 'json_object' },\n      messages: [\n        { role: 'system', content: SYSTEM_PROMPT },\n        { role: 'user', content: userPrompt },\n      ],\n    },\n  };\n}\n\nfunction extractLlmText(family, response) {\n  if (!response || typeof response !== 'object') {\n    throw new Error('Empty response from the language model.');\n  }\n  if (response.error) {\n    const message = response.error.message ?? JSON.stringify(response.error);\n    throw new Error(`Language model returned an error: ${message}`);\n  }\n\n  if (family === 'anthropic') {\n    const blocks = Array.isArray(response.content) ? response.content : [];\n    const text = blocks\n      .filter((b) => b && b.type === 'text')\n      .map((b) => b.text)\n      .join('');\n    return {\n      // Undo the \"{\" prefill.\n      text: text.trimStart().startsWith('{') ? text : `{${text}`,\n      finishReason: response.stop_reason ?? null,\n      usage: {\n        prompt_tokens: response.usage?.input_tokens ?? null,\n        completion_tokens: response.usage?.output_tokens ?? null,\n      },\n      model: response.model ?? null,\n    };\n  }\n\n  const choice = Array.isArray(response.choices) ? response.choices[0] : null;\n  if (!choice) throw new Error('Language model response contained no choices.');\n  return {\n    text: choice.message?.content ?? '',\n    finishReason: choice.finish_reason ?? null,\n    usage: {\n      prompt_tokens: response.usage?.prompt_tokens ?? null,\n      completion_tokens: response.usage?.completion_tokens ?? null,\n    },\n    model: response.model ?? null,\n  };\n}\n\n/**\n * Parse the model's JSON. Models occasionally wrap it in a fence or add a\n * sentence around it; a malformed envelope should not lose a good answer, so\n * the fallback treats the raw text as the answer.\n */\nfunction parseAnswer(text, sourceCount = 0) {\n  const raw = String(text ?? '').trim();\n  const unfenced = raw\n    .replace(/^```(?:json)?\\s*/i, '')\n    .replace(/\\s*```$/, '')\n    .trim();\n\n  let candidate = unfenced;\n  if (!candidate.startsWith('{')) {\n    const start = candidate.indexOf('{');\n    const end = candidate.lastIndexOf('}');\n    if (start >= 0 && end > start) candidate = candidate.slice(start, end + 1);\n  }\n\n  try {\n    const parsed = JSON.parse(candidate);\n    const used = Array.isArray(parsed.used_sources)\n      ? parsed.used_sources\n          .map((n) => Number(n))\n          .filter((n) => Number.isInteger(n) && n >= 1 && (!sourceCount || n <= sourceCount))\n      : [];\n    return {\n      answered: parsed.answered !== false,\n      answer: String(parsed.answer ?? '').trim(),\n      used_sources: used,\n      followup: parsed.followup ? String(parsed.followup) : null,\n      parse_error: null,\n    };\n  } catch (error) {\n    return {\n      answered: Boolean(unfenced),\n      answer: unfenced,\n      used_sources: [],\n      followup: null,\n      parse_error: error.message,\n    };\n  }\n}\n// --- end src/lib/llm-request.js\n\n/**\n * Node: \"Build LLM Request\"\n *\n * Emits a ready-to-send url and body for whichever provider LLM_PROVIDER names.\n * The `family` field is what the Switch node routes on, so that each provider\n * keeps its own n8n credential rather than having an API key travel through\n * workflow data where it would show up in execution logs.\n */\n\nconst cfg = readConfig(requireEnv($env), QUERY_CONFIG_SPEC);\nconst query = $input.first().json;\n\nconst request = buildLlmRequest({\n  provider: cfg.llmProvider,\n  model: cfg.llmModel,\n  question: query.question,\n  contextText: query.context_text,\n  department: query.department,\n  maxTokens: cfg.llmMaxTokens,\n  temperature: cfg.llmTemperature,\n  baseUrl: cfg.llmBaseUrl,\n});\n\nreturn [\n  {\n    json: {\n      ...query,\n      llm_provider: request.provider,\n      llm_family: request.family,\n      llm_model: cfg.llmModel,\n      llm_url: request.url,\n      llm_headers: request.headers,\n      llm_body: request.body,\n    },\n  },\n];\n"
      },
      "id": "06446924-387b-4247-adfd-0997349d7fd9",
      "name": "Build LLM Request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3600,
        0
      ],
      "notes": "Provider-shaped body. No API key travels through workflow data."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "dc92d7c9-cf3c-410b-ae04-572dc186cdf3",
              "leftValue": "={{ $json.llm_family }}",
              "rightValue": "anthropic",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "a6f7ce5f-f158-4725-a385-98b6884860b6",
      "name": "Is Anthropic?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3900,
        0
      ]
    },
    {
      "parameters": {
        "options": {
          "timeout": 120000
        },
        "method": "POST",
        "url": "={{ $json.llm_url }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.llm_body) }}"
      },
      "id": "a9413939-fa34-44f5-a2af-8ca0e00f0297",
      "name": "LLM: OpenAI-Compatible",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4200,
        -114
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 4000,
      "notes": "Also serves Groq / Together / vLLM: the URL comes from LLM_PROVIDER + LLM_BASE_URL, and the credential just carries that provider key as a bearer token."
    },
    {
      "parameters": {
        "options": {
          "timeout": 120000
        },
        "method": "POST",
        "url": "={{ $json.llm_url }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.llm_body) }}"
      },
      "id": "cf58eadd-11de-4172-a63f-f1d62efef1f1",
      "name": "LLM: Anthropic",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4200,
        152
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 4000,
      "notes": "Only runs when LLM_PROVIDER=anthropic. Needs an Anthropic credential selected."
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// --- inlined from src/lib/config.js ---------------------------\n/**\n * Environment reading + validation shared by every workflow.\n *\n * Workflows fail fast with one readable message naming the missing variable,\n * rather than throwing \"undefined is not a string\" six nodes downstream.\n */\n\n/**\n * @param {object} env    Usually n8n's `$env`.\n * @param {object[]} spec [{ key, name, required, default, type }]\n */\nfunction readConfig(env, spec) {\n  const source = env ?? {};\n  const out = {};\n  const missing = [];\n  const invalid = [];\n\n  for (const entry of spec) {\n    const raw = source[entry.key];\n    let value = raw === undefined || raw === null || raw === '' ? entry.default : raw;\n\n    if (value === undefined || value === null || value === '') {\n      if (entry.required) missing.push(entry.key);\n      out[entry.name] = entry.default ?? null;\n      continue;\n    }\n\n    switch (entry.type) {\n      case 'number': {\n        const n = Number(value);\n        if (!Number.isFinite(n)) invalid.push(`${entry.key} must be a number (got \"${value}\")`);\n        value = n;\n        break;\n      }\n      case 'boolean':\n        value = value === true || String(value).trim().toLowerCase() === 'true' || String(value) === '1';\n        break;\n      case 'json':\n        if (typeof value === 'string') {\n          try {\n            value = JSON.parse(value);\n          } catch {\n            invalid.push(`${entry.key} must be valid JSON (got \"${String(value).slice(0, 40)}\")`);\n            value = entry.default;\n          }\n        }\n        break;\n      case 'list':\n        value = String(value)\n          .split(',')\n          .map((v) => v.trim())\n          .filter(Boolean);\n        break;\n      case 'url':\n        value = String(value).trim().replace(/\\/+$/, '');\n        if (!/^https?:\\/\\//i.test(value)) {\n          invalid.push(`${entry.key} must start with https:// (got \"${value}\")`);\n        }\n        break;\n      default:\n        value = String(value).trim();\n    }\n\n    out[entry.name] = value;\n  }\n\n  const problems = [];\n  if (missing.length) problems.push(`missing: ${missing.join(', ')}`);\n  if (invalid.length) problems.push(invalid.join('; '));\n  if (problems.length) {\n    throw new Error(\n      `DocuQuery configuration problem - ${problems.join(' | ')}. ` +\n        `Set these in your n8n environment (see .env.example) and restart n8n.`,\n    );\n  }\n\n  return out;\n}\n\n/**\n * n8n hides `$env` when N8N_BLOCK_ENV_ACCESS_IN_NODE is on. Detect it here so\n * the failure names the setting instead of surfacing as \"$env is not defined\".\n */\nfunction requireEnv(env) {\n  if (!env || typeof env !== 'object') {\n    throw new Error(\n      'This node cannot read environment variables. Set ' +\n        'N8N_BLOCK_ENV_ACCESS_IN_NODE=false on the n8n container and restart it.',\n    );\n  }\n  return env;\n}\n\nconst INGESTION_CONFIG_SPEC = [\n  { key: 'DRIVE_FOLDER_ID', name: 'driveFolderId', required: true },\n  { key: 'PINECONE_INDEX_HOST', name: 'pineconeHost', required: true, type: 'url' },\n  { key: 'PINECONE_NAMESPACE', name: 'pineconeNamespace', default: 'docuquery' },\n  { key: 'PINECONE_DIMENSION', name: 'pineconeDimension', type: 'number', default: 1536 },\n  { key: 'EMBEDDING_MODEL', name: 'embeddingModel', default: 'text-embedding-3-small' },\n  { key: 'EMBED_BATCH_SIZE', name: 'embedBatchSize', type: 'number', default: 96 },\n  { key: 'CHUNK_TOKENS', name: 'chunkTokens', type: 'number', default: 500 },\n  { key: 'CHUNK_OVERLAP_TOKENS', name: 'overlapTokens', type: 'number', default: 50 },\n  { key: 'AIRTABLE_BASE_ID', name: 'airtableBaseId', required: true },\n  { key: 'AIRTABLE_DOCUMENTS_TABLE', name: 'documentsTable', default: 'Documents' },\n  { key: 'MAX_INGEST_RETRIES', name: 'maxRetries', type: 'number', default: 3 },\n  { key: 'DEPARTMENTS', name: 'departments', type: 'list', default: 'General' },\n  { key: 'DEFAULT_DEPARTMENT', name: 'defaultDepartment', default: 'General' },\n  { key: 'DEPARTMENT_FOLDER_MAP', name: 'departmentFolderMap', type: 'json', default: {} },\n];\n\nconst QUERY_CONFIG_SPEC = [\n  { key: 'PINECONE_INDEX_HOST', name: 'pineconeHost', required: true, type: 'url' },\n  { key: 'PINECONE_NAMESPACE', name: 'pineconeNamespace', default: 'docuquery' },\n  { key: 'EMBEDDING_MODEL', name: 'embeddingModel', default: 'text-embedding-3-small' },\n  { key: 'LLM_PROVIDER', name: 'llmProvider', default: 'openai' },\n  { key: 'LLM_MODEL', name: 'llmModel', default: 'gpt-4o' },\n  { key: 'LLM_MAX_TOKENS', name: 'llmMaxTokens', type: 'number', default: 900 },\n  { key: 'LLM_TEMPERATURE', name: 'llmTemperature', type: 'number', default: 0 },\n  { key: 'LLM_BASE_URL', name: 'llmBaseUrl', default: '' },\n  { key: 'RETRIEVAL_TOP_K', name: 'topK', type: 'number', default: 8 },\n  { key: 'RETRIEVAL_MIN_SCORE', name: 'minScore', type: 'number', default: 0.32 },\n  { key: 'RETRIEVAL_MAX_PER_DOC', name: 'maxPerDoc', type: 'number', default: 4 },\n  { key: 'RETRIEVAL_MAX_CONTEXT_TOKENS', name: 'maxContextTokens', type: 'number', default: 6000 },\n  { key: 'DEPARTMENTS', name: 'departments', type: 'list', default: 'General' },\n  { key: 'SLACK_SIGNING_SECRET', name: 'slackSigningSecret', default: '' },\n  { key: 'SLACK_TEAM_ID', name: 'slackTeamId', default: '' },\n  { key: 'SLACK_ALLOW_UNVERIFIED', name: 'slackAllowUnverified', type: 'boolean', default: false },\n  { key: 'QUERY_API_TOKEN', name: 'queryApiToken', default: '' },\n];\n// --- end src/lib/config.js\n\n// --- inlined from src/lib/citations.js ------------------------\n/**\n * Turning Pinecone matches into a citable context block.\n *\n * Two jobs:\n *   1. Decide which retrieved chunks are worth showing the model (score floor,\n *      per-document cap, token budget). Everything the model cannot see, it\n *      cannot cite - and everything it can see, it might.\n *   2. Number the sources by *document*, so an answer citing [2] points at a\n *      file a human can open, not at chunk 47.\n */\n\nconst CONTEXT_DEFAULTS = {\n  minScore: 0.32,\n  maxTokens: 6000,\n  maxPerDoc: 4,\n  maxChunks: 12,\n  maxSources: 6,\n  charsPerToken: 4,\n};\n\nfunction pageLabel(source) {\n  const pages = source.pages.filter((p) => Number.isFinite(p)).sort((a, b) => a - b);\n  if (!pages.length) return '';\n  const first = pages[0];\n  const last = pages[pages.length - 1];\n  return first === last ? `p. ${first}` : `pp. ${first}-${last}`;\n}\n\n/**\n * @param {object[]} matches Pinecone `matches` array.\n * @returns {{passages, sources, contextText, tokens, topScore, considered, dropped}}\n */\nfunction selectContext(matches, options = {}) {\n  const opts = { ...CONTEXT_DEFAULTS, ...options };\n  const ranked = [...(matches ?? [])].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));\n\n  const seenIds = new Set();\n  const perDoc = new Map();\n  const sourcesByDoc = new Map();\n  const passages = [];\n  let tokens = 0;\n  let dropped = 0;\n\n  for (const match of ranked) {\n    const meta = match.metadata ?? {};\n    const text = String(meta.text ?? '').trim();\n    const score = Number(match.score ?? 0);\n    const docId = String(meta.doc_id ?? 'unknown');\n    const id = String(match.id ?? `${docId}#${meta.chunk_index ?? passages.length}`);\n\n    if (!text) continue;\n    if (seenIds.has(id)) continue;\n    if (score < opts.minScore) {\n      dropped += 1;\n      continue;\n    }\n    if ((perDoc.get(docId) ?? 0) >= opts.maxPerDoc) {\n      dropped += 1;\n      continue;\n    }\n    if (!sourcesByDoc.has(docId) && sourcesByDoc.size >= opts.maxSources) {\n      dropped += 1;\n      continue;\n    }\n\n    const cost = Math.ceil(text.length / opts.charsPerToken);\n    if (passages.length && tokens + cost > opts.maxTokens) {\n      dropped += 1;\n      continue;\n    }\n\n    if (!sourcesByDoc.has(docId)) {\n      sourcesByDoc.set(docId, {\n        n: sourcesByDoc.size + 1,\n        doc_id: docId,\n        filename: String(meta.filename ?? 'unknown.pdf'),\n        title: String(meta.title ?? meta.filename ?? 'unknown.pdf'),\n        department: meta.department ?? null,\n        drive_url: meta.drive_url ?? null,\n        upload_date: meta.upload_date ?? null,\n        pages: [],\n        chunks: [],\n        top_score: score,\n      });\n    }\n\n    const source = sourcesByDoc.get(docId);\n    const pageStart = Number.isFinite(Number(meta.page_start)) ? Number(meta.page_start) : null;\n    const pageEnd = Number.isFinite(Number(meta.page_end)) ? Number(meta.page_end) : pageStart;\n    if (pageStart != null) source.pages.push(pageStart);\n    if (pageEnd != null && pageEnd !== pageStart) source.pages.push(pageEnd);\n    source.chunks.push(Number(meta.chunk_index ?? -1));\n    source.top_score = Math.max(source.top_score, score);\n\n    seenIds.add(id);\n    perDoc.set(docId, (perDoc.get(docId) ?? 0) + 1);\n    tokens += cost;\n    passages.push({\n      n: source.n,\n      id,\n      doc_id: docId,\n      filename: source.filename,\n      section: meta.section ?? null,\n      page_start: pageStart,\n      page_end: pageEnd,\n      score: Number(score.toFixed(4)),\n      text,\n    });\n\n    if (passages.length >= opts.maxChunks) break;\n  }\n\n  const sources = [...sourcesByDoc.values()].map((s) => ({\n    ...s,\n    pages: [...new Set(s.pages)].sort((a, b) => a - b),\n    page_label: pageLabel(s),\n    top_score: Number(s.top_score.toFixed(4)),\n  }));\n\n  return {\n    passages,\n    sources,\n    contextText: renderContext(passages),\n    tokens,\n    considered: ranked.length,\n    dropped,\n    topScore: ranked.length ? Number((ranked[0].score ?? 0).toFixed(4)) : 0,\n  };\n}\n\n/** The literal text handed to the model. Every passage is labelled with its citation number. */\nfunction renderContext(passages) {\n  return passages\n    .map((p) => {\n      const locator = [\n        p.filename,\n        p.section ? `\"${p.section}\"` : null,\n        p.page_start ? (p.page_end && p.page_end !== p.page_start ? `pp. ${p.page_start}-${p.page_end}` : `p. ${p.page_start}`) : null,\n      ]\n        .filter(Boolean)\n        .join(' | ');\n      return `[${p.n}] ${locator}\\n${p.text}`;\n    })\n    .join('\\n\\n---\\n\\n');\n}\n\n/**\n * Keep only the sources the model actually cited. If it cited nothing (or cited\n * numbers that do not exist) fall back to everything we gave it, so the user\n * still gets something to check the answer against.\n */\nfunction usedSources(sources, used) {\n  const valid = (Array.isArray(used) ? used : [])\n    .map((n) => Number(n))\n    .filter((n) => Number.isInteger(n) && sources.some((s) => s.n === n));\n  if (!valid.length) return sources;\n  const set = new Set(valid);\n  return sources.filter((s) => set.has(s.n));\n}\n\n/** Plain-text source list, used by the generic webhook response. */\nfunction renderSourceList(sources) {\n  return sources\n    .map((s) => [`[${s.n}] ${s.filename}`, s.page_label, s.department].filter(Boolean).join(' - '))\n    .join('\\n');\n}\n// --- end src/lib/citations.js\n\n// --- inlined from src/lib/llm-request.js ----------------------\n/**\n * Provider-agnostic answer synthesis.\n *\n * The retrieval layer above and the formatting layer below never learn which\n * model answered. This module owns the whole difference: it builds the request\n * body for OpenAI-shaped APIs (OpenAI, Groq, Together, vLLM...) or Anthropic's\n * Messages API, and normalises both responses back to one shape.\n *\n * Swapping providers is an env change (LLM_PROVIDER / LLM_MODEL) plus picking\n * the matching credential on the \"LLM: Anthropic\" node. See docs/setup.md.\n */\n\nconst SYSTEM_PROMPT = [\n  'You are DocuQuery, an assistant that answers questions about the internal',\n  'documents of the company you serve. You are given numbered passages retrieved',\n  'from those documents.',\n  '',\n  'Rules:',\n  '1. Answer ONLY from the passages. You have no other knowledge of this company.',\n  '2. Cite the passage number after every factual claim, like this: \"Employees accrue',\n  '   15 days of leave per year [2].\" A sentence drawing on two passages cites both.',\n  '3. Quote exact wording for figures, dates, deadlines, thresholds and money amounts.',\n  '   Never round, estimate, or infer a number that is not written in a passage.',\n  '4. If the passages do not answer the question, set \"answered\" to false and say what',\n  '   is missing. Do not guess, and do not pad a non-answer with related information.',\n  '5. If passages disagree, say so explicitly and cite each side.',\n  '6. Be direct. Two or three sentences beats a paragraph. No preamble.',\n  '',\n  'Respond with a single JSON object and nothing else:',\n  '{\"answered\": boolean, \"answer\": string, \"used_sources\": number[], \"followup\": string|null}',\n  '',\n  '\"used_sources\" lists only the passage numbers you actually cited. \"followup\" is an',\n  'optional short suggestion for narrowing the question, or null.',\n].join('\\n');\n\nfunction buildUserPrompt({ question, contextText, department }) {\n  const scope = department\n    ? `Retrieval was filtered to the \"${department}\" department.`\n    : 'Retrieval covered all indexed departments.';\n  return [`Question: ${question}`, '', scope, '', 'Passages:', contextText].join('\\n');\n}\n\nconst OPENAI_COMPATIBLE_BASES = {\n  openai: 'https://api.openai.com/v1',\n  groq: 'https://api.groq.com/openai/v1',\n};\n\n/**\n * @returns {{provider, family, url, body, headers}} `family` is what the\n *   Switch node routes on: \"anthropic\" or \"openai\".\n */\nfunction buildLlmRequest({\n  provider = 'openai',\n  model,\n  question,\n  contextText,\n  department = null,\n  maxTokens = 900,\n  temperature = 0,\n  baseUrl = '',\n} = {}) {\n  const name = String(provider).toLowerCase().trim();\n  const userPrompt = buildUserPrompt({ question, contextText, department });\n\n  if (name === 'anthropic') {\n    return {\n      provider: name,\n      family: 'anthropic',\n      url: 'https://api.anthropic.com/v1/messages',\n      headers: { 'anthropic-version': '2023-06-01' },\n      body: {\n        model: model || 'claude-sonnet-5',\n        max_tokens: maxTokens,\n        temperature,\n        system: SYSTEM_PROMPT,\n        messages: [\n          { role: 'user', content: userPrompt },\n          // Prefill forces JSON without a response_format parameter. The \"{\" is\n          // put back by extractLlmText().\n          { role: 'assistant', content: '{' },\n        ],\n      },\n    };\n  }\n\n  if (name === 'openai-compatible' && !baseUrl) {\n    throw new Error('LLM_PROVIDER=openai-compatible requires LLM_BASE_URL to be set.');\n  }\n\n  const base =\n    OPENAI_COMPATIBLE_BASES[name] ||\n    String(baseUrl || '').replace(/\\/+$/, '') ||\n    OPENAI_COMPATIBLE_BASES.openai;\n\n  return {\n    provider: name,\n    family: 'openai',\n    url: `${base}/chat/completions`,\n    headers: {},\n    body: {\n      model: model || 'gpt-4o',\n      temperature,\n      max_tokens: maxTokens,\n      response_format: { type: 'json_object' },\n      messages: [\n        { role: 'system', content: SYSTEM_PROMPT },\n        { role: 'user', content: userPrompt },\n      ],\n    },\n  };\n}\n\nfunction extractLlmText(family, response) {\n  if (!response || typeof response !== 'object') {\n    throw new Error('Empty response from the language model.');\n  }\n  if (response.error) {\n    const message = response.error.message ?? JSON.stringify(response.error);\n    throw new Error(`Language model returned an error: ${message}`);\n  }\n\n  if (family === 'anthropic') {\n    const blocks = Array.isArray(response.content) ? response.content : [];\n    const text = blocks\n      .filter((b) => b && b.type === 'text')\n      .map((b) => b.text)\n      .join('');\n    return {\n      // Undo the \"{\" prefill.\n      text: text.trimStart().startsWith('{') ? text : `{${text}`,\n      finishReason: response.stop_reason ?? null,\n      usage: {\n        prompt_tokens: response.usage?.input_tokens ?? null,\n        completion_tokens: response.usage?.output_tokens ?? null,\n      },\n      model: response.model ?? null,\n    };\n  }\n\n  const choice = Array.isArray(response.choices) ? response.choices[0] : null;\n  if (!choice) throw new Error('Language model response contained no choices.');\n  return {\n    text: choice.message?.content ?? '',\n    finishReason: choice.finish_reason ?? null,\n    usage: {\n      prompt_tokens: response.usage?.prompt_tokens ?? null,\n      completion_tokens: response.usage?.completion_tokens ?? null,\n    },\n    model: response.model ?? null,\n  };\n}\n\n/**\n * Parse the model's JSON. Models occasionally wrap it in a fence or add a\n * sentence around it; a malformed envelope should not lose a good answer, so\n * the fallback treats the raw text as the answer.\n */\nfunction parseAnswer(text, sourceCount = 0) {\n  const raw = String(text ?? '').trim();\n  const unfenced = raw\n    .replace(/^```(?:json)?\\s*/i, '')\n    .replace(/\\s*```$/, '')\n    .trim();\n\n  let candidate = unfenced;\n  if (!candidate.startsWith('{')) {\n    const start = candidate.indexOf('{');\n    const end = candidate.lastIndexOf('}');\n    if (start >= 0 && end > start) candidate = candidate.slice(start, end + 1);\n  }\n\n  try {\n    const parsed = JSON.parse(candidate);\n    const used = Array.isArray(parsed.used_sources)\n      ? parsed.used_sources\n          .map((n) => Number(n))\n          .filter((n) => Number.isInteger(n) && n >= 1 && (!sourceCount || n <= sourceCount))\n      : [];\n    return {\n      answered: parsed.answered !== false,\n      answer: String(parsed.answer ?? '').trim(),\n      used_sources: used,\n      followup: parsed.followup ? String(parsed.followup) : null,\n      parse_error: null,\n    };\n  } catch (error) {\n    return {\n      answered: Boolean(unfenced),\n      answer: unfenced,\n      used_sources: [],\n      followup: null,\n      parse_error: error.message,\n    };\n  }\n}\n// --- end src/lib/llm-request.js\n\n// --- inlined from src/lib/slack-format.js ---------------------\n/**\n * Slack presentation: Markdown -> mrkdwn, and the Block Kit payload.\n *\n * Slack is not Markdown. Bold is *one* asterisk, italics are underscores, links\n * are <url|label>, and &, < and > must be escaped first. Models write Markdown,\n * so translate rather than hope.\n */\n\nconst SECTION_LIMIT = 2900; // Slack hard-caps section text at 3000 chars.\n\nfunction toMrkdwn(input) {\n  let text = String(input ?? '');\n\n  // Escape before injecting any <url|label>, or the injected markup gets eaten.\n  text = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n\n  // Links: [label](url)\n  text = text.replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)/g, '<$2|$1>');\n\n  // Bold first, via a placeholder, so the italic pass cannot eat the markers.\n  // The sentinel is a NUL byte, which cannot appear in model output.\n  const NUL = String.fromCharCode(0);\n  const BOLD = `${NUL}B${NUL}`;\n  text = text.replace(/\\*\\*([^*]+)\\*\\*/g, `${BOLD}$1${BOLD}`);\n  text = text.replace(/__([^_]+)__/g, `${BOLD}$1${BOLD}`);\n\n  // Remaining single markers are italics in Markdown, underscores in Slack.\n  text = text.replace(/(^|[\\s(])\\*([^*\\n]+)\\*(?=[\\s).,!?]|$)/g, '$1_$2_');\n\n  text = text.split(BOLD).join('*');\n\n  // Headings become bold lines; Slack has no heading syntax in mrkdwn.\n  text = text.replace(/^\\s{0,3}#{1,6}\\s+(.+)$/gm, '*$1*');\n\n  // Normalise bullets.\n  text = text.replace(/^\\s*[-*+]\\s+/gm, '- ');\n\n  return text.trim();\n}\n\n/** Split long text across sections so nothing is silently truncated by Slack. */\nfunction sectionBlocks(text) {\n  const blocks = [];\n  let rest = text;\n  while (rest.length > SECTION_LIMIT) {\n    let cut = rest.lastIndexOf('\\n', SECTION_LIMIT);\n    if (cut < SECTION_LIMIT * 0.5) cut = rest.lastIndexOf(' ', SECTION_LIMIT);\n    if (cut < SECTION_LIMIT * 0.5) cut = SECTION_LIMIT;\n    blocks.push({ type: 'section', text: { type: 'mrkdwn', text: rest.slice(0, cut).trim() } });\n    rest = rest.slice(cut).trim();\n  }\n  if (rest) blocks.push({ type: 'section', text: { type: 'mrkdwn', text: rest } });\n  return blocks;\n}\n\nfunction sourceLine(source) {\n  const label = source.drive_url\n    ? `<${source.drive_url}|${source.filename}>`\n    : `*${source.filename}*`;\n  const bits = [source.page_label, source.department].filter(Boolean).join(' - ');\n  return `[${source.n}] ${label}${bits ? ` - ${bits}` : ''}`;\n}\n\n/**\n * @returns {{blocks: object[], text: string}} `text` is the notification\n *   fallback Slack shows in sidebars and push notifications.\n */\nfunction buildAnswerBlocks({\n  question,\n  answer,\n  answered = true,\n  sources = [],\n  followup = null,\n  department = null,\n  model = null,\n  latencyMs = null,\n  topScore = null,\n} = {}) {\n  const blocks = [\n    {\n      type: 'context',\n      elements: [\n        {\n          type: 'mrkdwn',\n          text: `:mag: *${toMrkdwn(question)}*${department ? `  ·  _${department}_` : ''}`,\n        },\n      ],\n    },\n    ...sectionBlocks(toMrkdwn(answer)),\n  ];\n\n  if (answered && sources.length) {\n    blocks.push({ type: 'divider' });\n    blocks.push({\n      type: 'section',\n      text: { type: 'mrkdwn', text: `*Sources*\\n${sources.map(sourceLine).join('\\n')}` },\n    });\n  }\n\n  if (followup) {\n    blocks.push({\n      type: 'context',\n      elements: [{ type: 'mrkdwn', text: `:bulb: ${toMrkdwn(followup)}` }],\n    });\n  }\n\n  const footer = [\n    model ? `model ${model}` : null,\n    topScore != null ? `top match ${topScore}` : null,\n    latencyMs != null ? `${latencyMs} ms` : null,\n  ]\n    .filter(Boolean)\n    .join('  ·  ');\n  if (footer) {\n    blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: footer }] });\n  }\n\n  return {\n    blocks,\n    text: answered\n      ? `${String(answer).slice(0, 200)}`\n      : 'No answer found in the indexed documents.',\n  };\n}\n\n/** The immediate 200 Slack needs within 3 seconds, before retrieval starts. */\nfunction ackPayload(question) {\n  return {\n    response_type: 'ephemeral',\n    text: `:mag: Searching the document index for _${String(question).slice(0, 150)}_ ...`,\n  };\n}\n// --- end src/lib/slack-format.js\n\n/**\n * Node: \"Format Answer\"\n *\n * Normalises the provider response, keeps only the sources the model actually\n * cited, and renders both deliverables: Slack blocks and a JSON body for the\n * generic webhook.\n *\n * Dropping uncited sources matters. Listing everything retrieved implies the\n * answer rests on all of it, which makes the citation meaningless as a way to\n * check the answer.\n */\n\nconst cfg = readConfig(requireEnv($env), QUERY_CONFIG_SPEC);\n// Read from the request builder, not $input: $input is the raw provider\n// response, and only one of the two provider branches will have run.\nconst query = $('Build LLM Request').first().json;\nconst response = $input.first().json;\n\nconst extracted = extractLlmText(query.llm_family, response);\nconst parsed = parseAnswer(extracted.text, query.sources.length);\n\nif (parsed.parse_error) {\n  console.log(`Model did not return valid JSON (${parsed.parse_error}); using raw text.`);\n}\n\nconst answered = parsed.answered && Boolean(parsed.answer);\nconst cited = answered ? usedSources(query.sources, parsed.used_sources) : [];\n\nconst answerText = answered\n  ? parsed.answer\n  : parsed.answer ||\n    `I could not find an answer to that in the indexed documents.${\n      query.department ? ` Retrieval was limited to *${query.department}*.` : ''\n    }`;\n\nconst latencyMs = query.started_ms ? Date.now() - Number(query.started_ms) : null;\n\nconst slack = buildAnswerBlocks({\n  question: query.question,\n  answer: answerText,\n  answered,\n  sources: cited,\n  followup: parsed.followup,\n  department: query.department,\n  model: extracted.model ?? query.llm_model,\n  latencyMs,\n  topScore: query.top_score,\n});\n\nreturn [\n  {\n    json: {\n      ...query,\n      answered,\n      answer: answerText,\n      followup: parsed.followup,\n      cited_sources: cited,\n      source_list: renderSourceList(cited),\n      usage: extracted.usage,\n      finish_reason: extracted.finishReason,\n      latency_ms: latencyMs,\n      slack_payload: {\n        response_type: 'in_channel',\n        replace_original: false,\n        text: slack.text,\n        blocks: slack.blocks,\n      },\n      webhook_payload: {\n        answered,\n        question: query.question,\n        department: query.department,\n        answer: answerText,\n        sources: cited.map((s) => ({\n          n: s.n,\n          doc_id: s.doc_id,\n          filename: s.filename,\n          pages: s.pages,\n          department: s.department,\n          url: s.drive_url,\n          score: s.top_score,\n        })),\n        followup: parsed.followup,\n        top_score: query.top_score,\n        model: extracted.model ?? query.llm_model,\n        latency_ms: latencyMs,\n      },\n    },\n  },\n];\n"
      },
      "id": "235445dd-71ef-4c06-a031-c7a3e25d47ab",
      "name": "Format Answer",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4500,
        0
      ],
      "notes": "Keeps only the sources the model cited, then renders Slack blocks and JSON."
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// --- inlined from src/lib/config.js ---------------------------\n/**\n * Environment reading + validation shared by every workflow.\n *\n * Workflows fail fast with one readable message naming the missing variable,\n * rather than throwing \"undefined is not a string\" six nodes downstream.\n */\n\n/**\n * @param {object} env    Usually n8n's `$env`.\n * @param {object[]} spec [{ key, name, required, default, type }]\n */\nfunction readConfig(env, spec) {\n  const source = env ?? {};\n  const out = {};\n  const missing = [];\n  const invalid = [];\n\n  for (const entry of spec) {\n    const raw = source[entry.key];\n    let value = raw === undefined || raw === null || raw === '' ? entry.default : raw;\n\n    if (value === undefined || value === null || value === '') {\n      if (entry.required) missing.push(entry.key);\n      out[entry.name] = entry.default ?? null;\n      continue;\n    }\n\n    switch (entry.type) {\n      case 'number': {\n        const n = Number(value);\n        if (!Number.isFinite(n)) invalid.push(`${entry.key} must be a number (got \"${value}\")`);\n        value = n;\n        break;\n      }\n      case 'boolean':\n        value = value === true || String(value).trim().toLowerCase() === 'true' || String(value) === '1';\n        break;\n      case 'json':\n        if (typeof value === 'string') {\n          try {\n            value = JSON.parse(value);\n          } catch {\n            invalid.push(`${entry.key} must be valid JSON (got \"${String(value).slice(0, 40)}\")`);\n            value = entry.default;\n          }\n        }\n        break;\n      case 'list':\n        value = String(value)\n          .split(',')\n          .map((v) => v.trim())\n          .filter(Boolean);\n        break;\n      case 'url':\n        value = String(value).trim().replace(/\\/+$/, '');\n        if (!/^https?:\\/\\//i.test(value)) {\n          invalid.push(`${entry.key} must start with https:// (got \"${value}\")`);\n        }\n        break;\n      default:\n        value = String(value).trim();\n    }\n\n    out[entry.name] = value;\n  }\n\n  const problems = [];\n  if (missing.length) problems.push(`missing: ${missing.join(', ')}`);\n  if (invalid.length) problems.push(invalid.join('; '));\n  if (problems.length) {\n    throw new Error(\n      `DocuQuery configuration problem - ${problems.join(' | ')}. ` +\n        `Set these in your n8n environment (see .env.example) and restart n8n.`,\n    );\n  }\n\n  return out;\n}\n\n/**\n * n8n hides `$env` when N8N_BLOCK_ENV_ACCESS_IN_NODE is on. Detect it here so\n * the failure names the setting instead of surfacing as \"$env is not defined\".\n */\nfunction requireEnv(env) {\n  if (!env || typeof env !== 'object') {\n    throw new Error(\n      'This node cannot read environment variables. Set ' +\n        'N8N_BLOCK_ENV_ACCESS_IN_NODE=false on the n8n container and restart it.',\n    );\n  }\n  return env;\n}\n\nconst INGESTION_CONFIG_SPEC = [\n  { key: 'DRIVE_FOLDER_ID', name: 'driveFolderId', required: true },\n  { key: 'PINECONE_INDEX_HOST', name: 'pineconeHost', required: true, type: 'url' },\n  { key: 'PINECONE_NAMESPACE', name: 'pineconeNamespace', default: 'docuquery' },\n  { key: 'PINECONE_DIMENSION', name: 'pineconeDimension', type: 'number', default: 1536 },\n  { key: 'EMBEDDING_MODEL', name: 'embeddingModel', default: 'text-embedding-3-small' },\n  { key: 'EMBED_BATCH_SIZE', name: 'embedBatchSize', type: 'number', default: 96 },\n  { key: 'CHUNK_TOKENS', name: 'chunkTokens', type: 'number', default: 500 },\n  { key: 'CHUNK_OVERLAP_TOKENS', name: 'overlapTokens', type: 'number', default: 50 },\n  { key: 'AIRTABLE_BASE_ID', name: 'airtableBaseId', required: true },\n  { key: 'AIRTABLE_DOCUMENTS_TABLE', name: 'documentsTable', default: 'Documents' },\n  { key: 'MAX_INGEST_RETRIES', name: 'maxRetries', type: 'number', default: 3 },\n  { key: 'DEPARTMENTS', name: 'departments', type: 'list', default: 'General' },\n  { key: 'DEFAULT_DEPARTMENT', name: 'defaultDepartment', default: 'General' },\n  { key: 'DEPARTMENT_FOLDER_MAP', name: 'departmentFolderMap', type: 'json', default: {} },\n];\n\nconst QUERY_CONFIG_SPEC = [\n  { key: 'PINECONE_INDEX_HOST', name: 'pineconeHost', required: true, type: 'url' },\n  { key: 'PINECONE_NAMESPACE', name: 'pineconeNamespace', default: 'docuquery' },\n  { key: 'EMBEDDING_MODEL', name: 'embeddingModel', default: 'text-embedding-3-small' },\n  { key: 'LLM_PROVIDER', name: 'llmProvider', default: 'openai' },\n  { key: 'LLM_MODEL', name: 'llmModel', default: 'gpt-4o' },\n  { key: 'LLM_MAX_TOKENS', name: 'llmMaxTokens', type: 'number', default: 900 },\n  { key: 'LLM_TEMPERATURE', name: 'llmTemperature', type: 'number', default: 0 },\n  { key: 'LLM_BASE_URL', name: 'llmBaseUrl', default: '' },\n  { key: 'RETRIEVAL_TOP_K', name: 'topK', type: 'number', default: 8 },\n  { key: 'RETRIEVAL_MIN_SCORE', name: 'minScore', type: 'number', default: 0.32 },\n  { key: 'RETRIEVAL_MAX_PER_DOC', name: 'maxPerDoc', type: 'number', default: 4 },\n  { key: 'RETRIEVAL_MAX_CONTEXT_TOKENS', name: 'maxContextTokens', type: 'number', default: 6000 },\n  { key: 'DEPARTMENTS', name: 'departments', type: 'list', default: 'General' },\n  { key: 'SLACK_SIGNING_SECRET', name: 'slackSigningSecret', default: '' },\n  { key: 'SLACK_TEAM_ID', name: 'slackTeamId', default: '' },\n  { key: 'SLACK_ALLOW_UNVERIFIED', name: 'slackAllowUnverified', type: 'boolean', default: false },\n  { key: 'QUERY_API_TOKEN', name: 'queryApiToken', default: '' },\n];\n// --- end src/lib/config.js\n\n// --- inlined from src/lib/slack-format.js ---------------------\n/**\n * Slack presentation: Markdown -> mrkdwn, and the Block Kit payload.\n *\n * Slack is not Markdown. Bold is *one* asterisk, italics are underscores, links\n * are <url|label>, and &, < and > must be escaped first. Models write Markdown,\n * so translate rather than hope.\n */\n\nconst SECTION_LIMIT = 2900; // Slack hard-caps section text at 3000 chars.\n\nfunction toMrkdwn(input) {\n  let text = String(input ?? '');\n\n  // Escape before injecting any <url|label>, or the injected markup gets eaten.\n  text = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n\n  // Links: [label](url)\n  text = text.replace(/\\[([^\\]]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)/g, '<$2|$1>');\n\n  // Bold first, via a placeholder, so the italic pass cannot eat the markers.\n  // The sentinel is a NUL byte, which cannot appear in model output.\n  const NUL = String.fromCharCode(0);\n  const BOLD = `${NUL}B${NUL}`;\n  text = text.replace(/\\*\\*([^*]+)\\*\\*/g, `${BOLD}$1${BOLD}`);\n  text = text.replace(/__([^_]+)__/g, `${BOLD}$1${BOLD}`);\n\n  // Remaining single markers are italics in Markdown, underscores in Slack.\n  text = text.replace(/(^|[\\s(])\\*([^*\\n]+)\\*(?=[\\s).,!?]|$)/g, '$1_$2_');\n\n  text = text.split(BOLD).join('*');\n\n  // Headings become bold lines; Slack has no heading syntax in mrkdwn.\n  text = text.replace(/^\\s{0,3}#{1,6}\\s+(.+)$/gm, '*$1*');\n\n  // Normalise bullets.\n  text = text.replace(/^\\s*[-*+]\\s+/gm, '- ');\n\n  return text.trim();\n}\n\n/** Split long text across sections so nothing is silently truncated by Slack. */\nfunction sectionBlocks(text) {\n  const blocks = [];\n  let rest = text;\n  while (rest.length > SECTION_LIMIT) {\n    let cut = rest.lastIndexOf('\\n', SECTION_LIMIT);\n    if (cut < SECTION_LIMIT * 0.5) cut = rest.lastIndexOf(' ', SECTION_LIMIT);\n    if (cut < SECTION_LIMIT * 0.5) cut = SECTION_LIMIT;\n    blocks.push({ type: 'section', text: { type: 'mrkdwn', text: rest.slice(0, cut).trim() } });\n    rest = rest.slice(cut).trim();\n  }\n  if (rest) blocks.push({ type: 'section', text: { type: 'mrkdwn', text: rest } });\n  return blocks;\n}\n\nfunction sourceLine(source) {\n  const label = source.drive_url\n    ? `<${source.drive_url}|${source.filename}>`\n    : `*${source.filename}*`;\n  const bits = [source.page_label, source.department].filter(Boolean).join(' - ');\n  return `[${source.n}] ${label}${bits ? ` - ${bits}` : ''}`;\n}\n\n/**\n * @returns {{blocks: object[], text: string}} `text` is the notification\n *   fallback Slack shows in sidebars and push notifications.\n */\nfunction buildAnswerBlocks({\n  question,\n  answer,\n  answered = true,\n  sources = [],\n  followup = null,\n  department = null,\n  model = null,\n  latencyMs = null,\n  topScore = null,\n} = {}) {\n  const blocks = [\n    {\n      type: 'context',\n      elements: [\n        {\n          type: 'mrkdwn',\n          text: `:mag: *${toMrkdwn(question)}*${department ? `  ·  _${department}_` : ''}`,\n        },\n      ],\n    },\n    ...sectionBlocks(toMrkdwn(answer)),\n  ];\n\n  if (answered && sources.length) {\n    blocks.push({ type: 'divider' });\n    blocks.push({\n      type: 'section',\n      text: { type: 'mrkdwn', text: `*Sources*\\n${sources.map(sourceLine).join('\\n')}` },\n    });\n  }\n\n  if (followup) {\n    blocks.push({\n      type: 'context',\n      elements: [{ type: 'mrkdwn', text: `:bulb: ${toMrkdwn(followup)}` }],\n    });\n  }\n\n  const footer = [\n    model ? `model ${model}` : null,\n    topScore != null ? `top match ${topScore}` : null,\n    latencyMs != null ? `${latencyMs} ms` : null,\n  ]\n    .filter(Boolean)\n    .join('  ·  ');\n  if (footer) {\n    blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: footer }] });\n  }\n\n  return {\n    blocks,\n    text: answered\n      ? `${String(answer).slice(0, 200)}`\n      : 'No answer found in the indexed documents.',\n  };\n}\n\n/** The immediate 200 Slack needs within 3 seconds, before retrieval starts. */\nfunction ackPayload(question) {\n  return {\n    response_type: 'ephemeral',\n    text: `:mag: Searching the document index for _${String(question).slice(0, 150)}_ ...`,\n  };\n}\n// --- end src/lib/slack-format.js\n\n/**\n * Node: \"Build No-Answer Response\"\n *\n * Two ways to arrive here: the request short-circuited before retrieval (help\n * text, unknown department), or retrieval came back with nothing above the\n * score floor.\n *\n * The second case never reaches the model. Handing it weak matches and asking\n * it to decline is both a wasted call and an invitation to answer anyway from\n * whatever the passages happened to mention - the failure mode this whole\n * pipeline exists to avoid.\n */\n\nconst cfg = readConfig(requireEnv($env), QUERY_CONFIG_SPEC);\nconst query = $input.first().json;\nconst latencyMs = query.started_ms ? Date.now() - Number(query.started_ms) : null;\n\nconst answer =\n  query.short_circuit ??\n  [\n    `I could not find anything about that in the indexed documents.`,\n    query.department ? `Retrieval was limited to *${query.department}*.` : null,\n    // Naming the score and the floor turns \"no answer\" into something the\n    // person asking can act on: a near miss is a tuning problem, a zero is a\n    // missing document.\n    query.matches_returned\n      ? `The closest match scored ${query.top_score}, below the ${cfg.minScore} relevance floor.`\n      : null,\n    `Try rephrasing, dropping any department filter, or check that the document is indexed.`,\n  ]\n    .filter(Boolean)\n    .join(' ');\n\nconst isInformational = Boolean(query.short_circuit);\n\nconst slack = buildAnswerBlocks({\n  question: query.question || 'help',\n  answer,\n  answered: false,\n  sources: [],\n  department: query.department,\n  latencyMs,\n  topScore: query.top_score ?? null,\n});\n\nreturn [\n  {\n    json: {\n      ...query,\n      answered: false,\n      answer,\n      cited_sources: [],\n      latency_ms: latencyMs,\n      slack_payload: {\n        // Unanswered queries stay ephemeral - no value in broadcasting them.\n        response_type: 'ephemeral',\n        replace_original: false,\n        text: slack.text,\n        blocks: slack.blocks,\n      },\n      webhook_payload: {\n        answered: false,\n        informational: isInformational,\n        question: query.question,\n        department: query.department,\n        answer,\n        sources: [],\n        top_score: query.top_score ?? null,\n        latency_ms: latencyMs,\n      },\n    },\n  },\n];\n"
      },
      "id": "170c9efa-2394-46d3-a7a1-2c8b6e120eb8",
      "name": "Build No-Answer Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4500,
        494
      ],
      "notes": "Nothing cleared the relevance floor, or the request short-circuited."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "ca3fc8d1-1ae3-4396-a519-4a882a6eb0c0",
              "leftValue": "={{ $json.is_slack }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "2fdbe176-e97a-4895-abd9-f72d20ba9acf",
      "name": "Deliver To Slack?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        4800,
        228
      ]
    },
    {
      "parameters": {
        "options": {
          "timeout": 15000
        },
        "method": "POST",
        "url": "={{ $json.response_url }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.slack_payload) }}"
      },
      "id": "a9d6acd9-518e-44d5-a862-5f42e4fa8c81",
      "name": "Slack: Post Answer",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5100,
        76
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 1000,
      "notes": "response_url is pre-signed by Slack, so this call needs no credential."
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify($json.webhook_payload) }}",
        "options": {}
      },
      "id": "09ab1c89-bcfa-43c2-a02a-2ca50e9da350",
      "name": "Respond: Answer",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        5100,
        380
      ],
      "notes": "The non-Slack path: the caller blocked for this."
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * Node: \"Build Query Log\"\n *\n * Every question, answered or not, lands in Airtable. The unanswered ones are\n * the useful half: they are a list of what people expect the document set to\n * cover and it does not, which is the input to deciding what to add next.\n */\n\n/**\n * Delivery has already happened by this point, so $input holds a Slack or\n * webhook response. The answer itself comes from whichever of the two\n * answer-shaping nodes actually ran.\n */\nfunction firstOf(...nodeNames) {\n  for (const name of nodeNames) {\n    try {\n      return $(name).first().json;\n    } catch {\n      // Node did not run in this execution; try the next.\n    }\n  }\n  return {};\n}\n\nconst query = firstOf('Format Answer', 'Build No-Answer Response');\n\nreturn [\n  {\n    json: {\n      fields: {\n        timestamp: new Date().toISOString(),\n        question: String(query.question ?? '').slice(0, 2000),\n        department: query.department ?? null,\n        source: query.source ?? 'webhook',\n        user: query.user_name ?? query.user_id ?? null,\n        channel: query.channel_name ?? query.channel_id ?? null,\n        answered: Boolean(query.answered),\n        top_score: query.top_score ?? null,\n        matches_returned: query.matches_returned ?? 0,\n        sources_cited: (query.cited_sources ?? []).map((s) => s.filename).join(', '),\n        model: query.llm_model ?? null,\n        prompt_tokens: query.usage?.prompt_tokens ?? null,\n        completion_tokens: query.usage?.completion_tokens ?? null,\n        latency_ms: query.latency_ms ?? null,\n        verified_request: Boolean(query.verified),\n        execution_id: $execution.id,\n      },\n    },\n  },\n];\n"
      },
      "id": "3465400f-4451-45e2-af1a-585830fff140",
      "name": "Build Query Log",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5400,
        228
      ]
    },
    {
      "parameters": {
        "options": {
          "timeout": 15000
        },
        "method": "POST",
        "url": "=https://api.airtable.com/v0/{{ $env.AIRTABLE_BASE_ID }}/{{ encodeURIComponent($env.AIRTABLE_QUERIES_TABLE || \"Queries\") }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "airtableTokenApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ typecast: true, records: [{ fields: $json.fields }] }) }}"
      },
      "id": "4877ec8c-cd12-40df-a3e9-5d02a161235e",
      "name": "Airtable: Log Query",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5700,
        228
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 1000,
      "onError": "continueRegularOutput",
      "notes": "Logging must never break an answer that was already delivered."
    },
    {
      "parameters": {},
      "id": "7f8419a6-0f48-48e0-a3e2-e72221e8ae25",
      "name": "Query Complete",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        6000,
        228
      ]
    },
    {
      "parameters": {
        "content": "## Why there are two Respond nodes\n\nSlack kills a slash command that has not returned 200 within **3 seconds**.\nEmbedding + retrieval + generation takes longer than that.\n\nSo a Slack request gets an immediate ephemeral ack, and the answer is\nPOSTed to the `response_url` Slack supplied when it is ready.\n\nA plain webhook caller has no deadline and blocks for the real answer.",
        "width": 560,
        "height": 280,
        "color": 4
      },
      "id": "9edb00b4-5d6a-4929-a845-04a2c12e5715",
      "name": "Note: The 3-Second Rule",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1440,
        -420
      ]
    },
    {
      "parameters": {
        "content": "## What keeps answers honest\n\n1. **Score floor** (`RETRIEVAL_MIN_SCORE`) - weak matches never reach the model.\n2. **Per-document cap** - one long handbook cannot crowd out a shorter\n   document that actually answers the question.\n3. **Prompt** - answer only from the passages; decline if they do not cover it.\n4. **Cited sources only** - sources the model did not cite are dropped from\n   the reply, so a citation stays a real pointer rather than a reading list.",
        "width": 600,
        "height": 300,
        "color": 5
      },
      "id": "cf07888a-5419-48ff-aa11-b4328195ef32",
      "name": "Note: Grounding",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3060,
        -520
      ]
    },
    {
      "parameters": {
        "content": "## Changing the model\n\n`LLM_PROVIDER` = `openai` | `groq` | `anthropic` | `openai-compatible`\n\nEverything except the Anthropic body shape is the same request, so the\nOpenAI-compatible node serves Groq, Together and self-hosted vLLM too -\npoint `LLM_BASE_URL` at them and put their key in the credential.\n\nRetrieval and citation logic never learn which model answered.",
        "width": 560,
        "height": 280,
        "color": 6
      },
      "id": "c570f8ae-f966-42c6-ad19-b58d4b940c47",
      "name": "Note: Swapping Providers",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        4020,
        -560
      ]
    }
  ],
  "connections": {
    "Webhook: /ask": {
      "main": [
        [
          {
            "node": "Validate Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Config": {
      "main": [
        [
          {
            "node": "Authenticate Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Authenticate Request": {
      "main": [
        [
          {
            "node": "Authorized?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Authorized?": {
      "main": [
        [
          {
            "node": "Parse Question",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond: Unauthorized",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Question": {
      "main": [
        [
          {
            "node": "Needs Slack Ack?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Needs Slack Ack?": {
      "main": [
        [
          {
            "node": "Respond: Slack Ack",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Should Retrieve?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond: Slack Ack": {
      "main": [
        [
          {
            "node": "Should Retrieve?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Should Retrieve?": {
      "main": [
        [
          {
            "node": "OpenAI: Embed Question",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build No-Answer Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI: Embed Question": {
      "main": [
        [
          {
            "node": "Pinecone: Query",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pinecone: Query": {
      "main": [
        [
          {
            "node": "Assemble Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assemble Context": {
      "main": [
        [
          {
            "node": "Has Context?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Context?": {
      "main": [
        [
          {
            "node": "Build LLM Request",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build No-Answer Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build LLM Request": {
      "main": [
        [
          {
            "node": "Is Anthropic?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is Anthropic?": {
      "main": [
        [
          {
            "node": "LLM: Anthropic",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "LLM: OpenAI-Compatible",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM: OpenAI-Compatible": {
      "main": [
        [
          {
            "node": "Format Answer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM: Anthropic": {
      "main": [
        [
          {
            "node": "Format Answer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Answer": {
      "main": [
        [
          {
            "node": "Deliver To Slack?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build No-Answer Response": {
      "main": [
        [
          {
            "node": "Deliver To Slack?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Deliver To Slack?": {
      "main": [
        [
          {
            "node": "Slack: Post Answer",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond: Answer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack: Post Answer": {
      "main": [
        [
          {
            "node": "Build Query Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond: Answer": {
      "main": [
        [
          {
            "node": "Build Query Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Query Log": {
      "main": [
        [
          {
            "node": "Airtable: Log Query",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable: Log Query": {
      "main": [
        [
          {
            "node": "Query Complete",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  }
}