{
  "name": "[Subworkflow.ai] Better Web Research with Jev",
  "nodes": [
    {
      "parameters": {},
      "id": "3e7a7733-ecf8-400a-b56d-f10038e51804",
      "name": "Test Manually",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -224,
        0
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "need",
              "name": "need",
              "type": "string",
              "value": "A tool for capturing and triaging application errors and exceptions from our backend services."
            },
            {
              "id": "searchQuery",
              "name": "searchQuery",
              "type": "string",
              "value": "error tracking tool self-hosted EU data residency SOC 2"
            },
            {
              "id": "mustHave",
              "name": "mustHave",
              "type": "array",
              "value": "[{\"id\":\"eu_residency\",\"question\":\"Can this product store customer data exclusively in the EU, either as a hosted EU region or by self-hosting inside the EU?\"},{\"id\":\"self_host\",\"question\":\"Can this product be self-hosted or run on infrastructure the customer controls?\"},{\"id\":\"soc2\",\"question\":\"Is this product SOC 2 certified, or does it publish an equivalent audited security attestation?\"}]"
            },
            {
              "id": "niceToHave",
              "name": "niceToHave",
              "type": "array",
              "value": "[{\"id\":\"open_source\",\"question\":\"Is the core of this product open source?\"}]"
            },
            {
              "id": "topN",
              "name": "topN",
              "type": "number",
              "value": 5
            }
          ]
        },
        "options": {}
      },
      "id": "590d149e-6ee0-4cc6-b987-340a28455402",
      "name": "Requirement",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Turn the requirement spec into an Apify SERP request.\n//\n// The search string and the judging criteria are different artifacts: Google\n// needs keywords, Jev needs the full requirement. Conflating them is why naive\n// \"just search the requirement\" retrieval ranks so badly.\nconst ACTOR = 'apify~google-search-scraper';\nconst RESULTS = 30;\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const src = item.json.body ?? item.json;\n\n  const parseMaybe = (v) => {\n    if (typeof v !== 'string') return v;\n    try { return JSON.parse(v); } catch { return v; }\n  };\n\n  const need = String(src.need ?? '').trim();\n  const searchQuery = String(src.searchQuery ?? need).trim();\n  const mustHave = parseMaybe(src.mustHave) ?? [];\n  const niceToHave = parseMaybe(src.niceToHave) ?? [];\n\n  if (!need) throw new Error('`need` is required: a plain description of what is being looked for');\n  if (!searchQuery) throw new Error('`searchQuery` is required');\n  if (!Array.isArray(mustHave) || mustHave.length === 0) {\n    throw new Error('`mustHave` must be a non-empty array of { id, question }');\n  }\n  for (const c of [...mustHave, ...niceToHave]) {\n    if (!c?.id || !c?.question) throw new Error('every constraint needs an `id` and a `question`');\n  }\n\n  out.push({\n    json: {\n      requestId: src.requestId ?? $execution.id,\n      startedAt: Date.now(),\n      need,\n      searchQuery,\n      mustHave,\n      niceToHave,\n      topN: Number(src.topN ?? 5),\n      actor: ACTOR,\n      body_request: {\n        queries: searchQuery,\n        resultsPerPage: RESULTS,\n        maxPagesPerQuery: 1,\n        languageCode: 'en',\n        countryCode: 'gb',\n      },\n    },\n  });\n}\n\nif (out.length === 0) throw new Error('No requirement supplied');\nreturn out;\n"
      },
      "id": "06edb64b-0107-497f-b4d9-9a4868122145",
      "name": "Build Search Request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        224,
        96
      ]
    },
    {
      "parameters": {
        "operation": "Run actor and get dataset",
        "actorSource": "store",
        "actorId": {
          "__rl": true,
          "mode": "id",
          "value": "apify~google-search-scraper"
        },
        "customBody": "={{ JSON.stringify($json.body_request) }}"
      },
      "id": "0efba317-4770-4fa7-bcfd-c127ad911085",
      "name": "Apify: Google Search",
      "type": "@apify/n8n-nodes-apify.apify",
      "typeVersion": 1,
      "position": [
        512,
        96
      ]
    },
    {
      "parameters": {
        "jsCode": "// SERP -> deduped candidates -> one Jev request per candidate.\n//\n// One question set PER (query, candidate) pair, judged independently. That is\n// the re-ranking method from TypeSafe's cookbook, and it is only affordable\n// because each judgement costs a fraction of a cent.\nconst MODEL = '~typesafe/jev-latest';\nconst MAX_CANDIDATES = 30;\n\n// n8n's Code sandbox does not expose `URL`, so parse the host by hand.\n// Returns null for anything that is not an http(s) URL.\nconst hostOf = (u) => {\n  const m = /^https?:\\/\\/([^/?#]+)/i.exec(String(u || ''));\n  return m ? m[1].replace(/^www\\./i, '').toLowerCase() : null;\n};\n\nconst spec = $('Build Search Request').first().json;\nconst res = $input.all().map((i) => i.json);\n\n// The actor returns one item per query, with organicResults inside it.\nconst organic = [];\nfor (const r of res) {\n  if (Array.isArray(r?.organicResults)) organic.push(...r.organicResults);\n  else if (r?.url && r?.title) organic.push(r); // already flattened\n}\nif (organic.length === 0) throw new Error('Search returned no organic results');\n\n// Dedupe by registrable-ish domain: search returns many pages per vendor, and\n// judging the same vendor five times wastes calls and skews the ranking.\nconst seen = new Set();\nconst candidates = [];\nconst skipped = [];\nfor (const r of organic) {\n  const domain = hostOf(r.url);\n  if (!domain) { skipped.push(r.url); continue; }   // not an http(s) result\n  if (seen.has(domain)) continue;\n  seen.add(domain);\n  candidates.push({\n    position: r.position ?? candidates.length + 1,\n    title: String(r.title ?? '').trim(),\n    url: r.url,\n    domain,\n    snippet: String(r.description ?? r.snippet ?? '').trim(),\n  });\n  if (candidates.length >= MAX_CANDIDATES) break;\n}\n\nconst out = candidates.map((c) => ({\n  json: {\n    requestId: spec.requestId,\n    startedAt: spec.startedAt,\n    candidate: c,\n    body_request: {\n      model: MODEL,\n      // Snippets are weak evidence. Stage one only decides whether something is\n      // a candidate at all - the hard constraints wait for the full page.\n      state: {\n        looking_for: spec.need,\n        candidate_title: c.title,\n        candidate_url: c.url,\n        candidate_snippet: c.snippet,\n      },\n      questions: {\n        fit: {\n          type: 'score',\n          instructions: 'Judging only from the title and snippet, how well does this look like the kind of product described in `looking_for`?',\n          criteria: [\n            'unrelated to what is being looked for',\n            'same broad area, but not the kind of product wanted',\n            'plausibly the right kind of product, hard to tell',\n            'clearly the right kind of product',\n            'clearly the right kind of product and an obvious front-runner',\n          ],\n        },\n        names_specific_product: {\n          type: 'noul',\n          instructions: 'Does this page appear to be about one specific named product, rather than a category, a blog topic, or a general page?',\n        },\n        is_listicle: {\n          type: 'noul',\n          instructions: 'Is this a roundup or comparison article covering several products, such as \"the 10 best X\"? Such a page may be useful reading but is not itself a candidate product.',\n        },\n        is_vendor_page: {\n          type: 'noul',\n          instructions: \"Is this page published by the product's own vendor, as opposed to a third-party review, forum, or directory?\",\n        },\n        plausibly_qualifies: {\n          type: 'noul',\n          instructions: 'Is there anything in the title or snippet that rules this out as a match for `looking_for`? Answer yes if nothing rules it out. Absence of detail is not grounds to rule it out at this stage.',\n        },\n      },\n      session_id: String(spec.requestId ?? $execution.id).slice(0, 256),\n    },\n  },\n}));\n\nif (out.length === 0) {\n  throw new Error(\n    `No candidates survived deduplication: ${organic.length} result(s) in, ` +\n    `${skipped.length} had an unusable URL (e.g. ${skipped.slice(0, 2).join(', ') || 'none'})`,\n  );\n}\nreturn out;\n"
      },
      "id": "dfc71209-4214-4f1b-89fb-7b973478882f",
      "name": "Build Candidates",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        688,
        96
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://openrouter.ai/api/alpha/decisions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openRouterApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.body_request) }}",
        "options": {
          "batching": {
            "batch": {
              "batchSize": 10,
              "batchInterval": 0
            }
          },
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "57741eed-cd97-4d1d-8a2a-5679446e92cd",
      "name": "Rerank (stage 1)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        864,
        96
      ],
      "credentials": {
        "openRouterApi": {
          "id": "O3r1xV6tLDukbaqy",
          "name": "n8n-jimleuk-20260918"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// --- shared decision core (inlined into each Code node at build time) -------\n// Mirrors TypeSafe's published answer contract:\n//   Choice -> { choice, probabilities, confidence }\n//   Score  -> { score (continuous, probability-weighted), probabilities, confidence }\n//   Noul   -> { noul } : probability of yes, and NO confidence value\n// See https://docs.typesafe.ai/primitives.md\nfunction tsDecision(q, probs, extra) {\n  const probabilities = {};\n  q.candidates.forEach((c, j) => { probabilities[c] = Number(probs[j].toFixed(6)); });\n\n  let bestIdx = 0;\n  for (let j = 1; j < probs.length; j++) if (probs[j] > probs[bestIdx]) bestIdx = j;\n\n  if (q.type === 'noul') {\n    const yesIdx = q.candidates.indexOf('yes');\n    const p = probs[yesIdx >= 0 ? yesIdx : probs.length - 1];\n    // Jev returns no confidence for a Noul: the probability IS the answer.\n    // A value near 0.5 means yes and no are similarly likely - NOT medium intensity.\n    return Object.assign({\n      type: 'noul',\n      noul: Number(p.toFixed(6)),\n      value: p >= 0.5, // convenience for code that needs a hard boolean\n      probabilities,\n    }, extra || {});\n  }\n\n  if (q.type === 'score') {\n    // Jev's `score` is the probability-weighted position on the ordered levels,\n    // not the argmax index - their own docs threshold on `score > 1.5`.\n    return Object.assign({\n      type: 'score',\n      score: Number(probs.reduce((acc, p, j) => acc + p * j, 0).toFixed(6)),\n      level: q.candidates[bestIdx],\n      levelIndex: bestIdx,\n      probabilities,\n      confidence: Number(probs[bestIdx].toFixed(6)),\n    }, extra || {});\n  }\n\n  return Object.assign({\n    type: 'choice',\n    choice: q.candidates[bestIdx],\n    probabilities,\n    confidence: Number(probs[bestIdx].toFixed(6)),\n  }, extra || {});\n}\n\n// Vote counting -> renormalised probabilities over declared candidates only.\nfunction tsProbs(q, tokens) {\n  const counts = {};\n  for (const k of q.keys) counts[k] = 0;\n  for (const t of tokens) {\n    const tok = String(t ?? '').trim().toUpperCase().charAt(0);\n    if (Object.prototype.hasOwnProperty.call(counts, tok)) counts[tok] += 1;\n  }\n  const valid = Object.values(counts).reduce((a, b) => a + b, 0);\n  return {\n    valid,\n    degenerate: valid === 0,\n    probs: valid === 0 ? q.keys.map(() => 1 / q.keys.length) : q.keys.map((k) => counts[k] / valid),\n  };\n}\n\n// Aggregate. Jev excludes Noul from confidence, so ambiguous Nouls are tracked\n// on their own axis rather than folded into minConfidence.\nfunction tsSummary(decisions) {\n  let minConfidence = 1;\n  let maxNoulAmbiguity = 0;\n  for (const d of decisions) {\n    if (d.type === 'noul') {\n      const amb = 1 - Math.abs(2 * d.noul - 1); // 1 at p=0.5, 0 at p=0 or 1\n      if (amb > maxNoulAmbiguity) maxNoulAmbiguity = amb;\n    } else if (d.confidence < minConfidence) {\n      minConfidence = d.confidence;\n    }\n  }\n  return {\n    minConfidence: Number(minConfidence.toFixed(6)),\n    maxNoulAmbiguity: Number(maxNoulAmbiguity.toFixed(6)),\n  };\n}\n\n// Collapse stage-one judgements into a shortlist.\n//\n// This is the step the whole workflow exists for: search order in, judged order\n// out. Everything here is policy, so it lives in code and can be retuned without\n// re-running a single inference.\nconst MIN_FIT = 1.5;              // below this it is not the right kind of product\nconst MIN_SPECIFIC = 0.4;         // must look like one named product\nconst MAX_LISTICLE = 0.6;         // roundups are reading material, not candidates\nconst MIN_PLAUSIBLE = 0.35;       // something in the snippet ruled it out\n\nconst spec = $('Build Search Request').first().json;\nconst prompts = $('Build Candidates').all();\nconst responses = $input.all();\n\nconst scored = [];\n\nfor (let i = 0; i < responses.length; i++) {\n  const meta = prompts[i].json;\n  const res = responses[i].json;\n  if (!res || typeof res.answers !== 'object') {\n    const msg = res?.error?.message ?? JSON.stringify(res)?.slice(0, 200);\n    throw new Error(`Stage-one rerank returned no answers for ${meta.candidate?.url}: ${msg}`);\n  }\n  const a = res.answers;\n  const p = (k) => (typeof a[k]?.noul === 'number' ? a[k].noul : null);\n\n  const fit = typeof a.fit?.score === 'number' ? a.fit.score : 0;\n  const specific = p('names_specific_product') ?? 0;\n  const listicle = p('is_listicle') ?? 0;\n  const vendorPage = p('is_vendor_page') ?? 0;\n  const plausible = p('plausibly_qualifies') ?? 0;\n\n  const drops = [];\n  if (fit < MIN_FIT) drops.push('not the right kind of product');\n  if (listicle > MAX_LISTICLE) drops.push('roundup article, not a product');\n  if (specific < MIN_SPECIFIC) drops.push('not one specific product');\n  if (plausible < MIN_PLAUSIBLE) drops.push('snippet rules it out');\n\n  scored.push({\n    ...meta.candidate,\n    searchPosition: meta.candidate.position,\n    fit: Number(fit.toFixed(4)),\n    fitConfidence: a.fit?.confidence ?? null,\n    namesSpecificProduct: specific,\n    isListicle: listicle,\n    isVendorPage: vendorPage,\n    plausiblyQualifies: plausible,\n    shortlisted: drops.length === 0,\n    droppedBecause: drops.length ? drops.join('; ') : null,\n  });\n}\n\n// Rank by judged fit, tie-broken by how confident that judgement was.\nconst shortlist = scored\n  .filter((c) => c.shortlisted)\n  .sort((x, y) => y.fit - x.fit || (y.fitConfidence ?? 0) - (x.fitConfidence ?? 0))\n  .slice(0, spec.topN || 5)\n  .map((c, i) => ({ ...c, rerankPosition: i + 1 }));\n\nconst dropped = scored.filter((c) => !c.shortlisted);\n// How far the top pick climbed. If this is 0 the rerank changed nothing.\nconst movement = shortlist.length ? shortlist[0].searchPosition - 1 : null;\n\nreturn [\n  {\n    json: {\n      requestId: spec.requestId,\n      startedAt: spec.startedAt,\n      need: spec.need,\n      searchQuery: spec.searchQuery,\n      mustHave: spec.mustHave,\n      niceToHave: spec.niceToHave,\n      topN: spec.topN,\n\n      candidatesSeen: scored.length,\n      shortlistCount: shortlist.length,\n      droppedCount: dropped.length,\n      listiclesDropped: dropped.filter((d) => d.isListicle > MAX_LISTICLE).length,\n      topPickClimbedFrom: shortlist[0]?.searchPosition ?? null,\n      positionsGained: movement,\n\n      shortlist,\n      dropped,\n      stageOneCalls: responses.length,\n      stageOneMs: Date.now() - (spec.startedAt ?? Date.now()),\n    },\n  },\n];\n"
      },
      "id": "c81c69c3-590b-4965-948e-bbcb6c11a5f2",
      "name": "Rank Shortlist",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        96
      ]
    },
    {
      "parameters": {
        "jsCode": "const r = $input.first().json;\nif (!r.shortlist?.length) throw new Error(\"nothing shortlisted to scrape\");\nreturn r.shortlist.map((c) => ({ json: { url: c.url, domain: c.domain, product: c.title } }));"
      },
      "id": "0e12ae6a-7de6-494e-b719-e720e566cc99",
      "name": "Fan Out Shortlist",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1328,
        96
      ]
    },
    {
      "parameters": {
        "operation": "Scrape single URL",
        "url": "={{ $json.url }}"
      },
      "id": "6718a84e-ba9f-4b53-bdb9-29a0b9cafd23",
      "name": "Apify: Scrape Page",
      "type": "@apify/n8n-nodes-apify.apify",
      "typeVersion": 1,
      "position": [
        1504,
        96
      ]
    },
    {
      "parameters": {
        "jsCode": "// Stage two: the hard constraints, judged against the full page.\n//\n// A 160-character snippet almost never states whether a product is SOC 2\n// certified or can be self-hosted. Asking at stage one would reject everything\n// for lack of evidence rather than lack of compliance - which is exactly the\n// mistake this split avoids.\nconst MODEL = '~typesafe/jev-latest';\nconst MAX_CHARS = 18000; // keep each request inside Jev's 32k window\n\n// n8n's Code sandbox does not expose `URL`, so parse the host by hand.\n// Returns null for anything that is not an http(s) URL.\nconst hostOf = (u) => {\n  const m = /^https?:\\/\\/([^/?#]+)/i.exec(String(u || ''));\n  return m ? m[1].replace(/^www\\./i, '').toLowerCase() : null;\n};\n\nconst ranked = $('Rank Shortlist').first().json;\nconst pages = $input.all().map((i) => i.json);\n\nconst byUrl = new Map();\nfor (const p of pages) {\n  const url = p?.url ?? p?.loadedUrl ?? p?.metadata?.url;\n  if (!url) continue;\n  const text = p.text ?? p.markdown ?? p.content ?? '';\n  byUrl.set(String(url).replace(/\\/$/, ''), String(text));\n}\n\nconst out = [];\n\nfor (const c of ranked.shortlist) {\n  const key = String(c.url).replace(/\\/$/, '');\n  let text = byUrl.get(key);\n  if (text === undefined) {\n    // Fall back to a domain match: crawlers often follow a redirect.\n    for (const [u, t] of byUrl) {\n      if (hostOf(u) === c.domain) { text = t; break; }\n    }\n  }\n\n  const questions = {\n    fit: {\n      type: 'score',\n      instructions: 'Now that the full page is visible, how well does this product match `looking_for`?',\n      criteria: [\n        'not the kind of product being looked for',\n        'adjacent, but would not be chosen for this need',\n        'a workable match with reservations',\n        'a good match',\n        'an excellent match',\n      ],\n    },\n    evidence_is_explicit: {\n      type: 'noul',\n      instructions: 'Does `page_content` state the relevant facts outright, rather than leaving them to be inferred from marketing language?',\n    },\n    is_current: {\n      type: 'noul',\n      instructions: 'Does this page describe a product that is currently available and maintained, rather than one that is archived, deprecated, or discontinued?',\n    },\n  };\n\n  // One Noul per declared constraint. Swapping the constraint list in the\n  // request changes what gets asked - no code change.\n  for (const m of ranked.mustHave) {\n    questions['must_' + m.id] = {\n      type: 'noul',\n      instructions: m.question,\n      criteria: {\n        true: 'the page gives good reason to believe this is true',\n        false: 'the page contradicts this, or gives no reason to believe it',\n      },\n    };\n  }\n  for (const n of ranked.niceToHave ?? []) {\n    questions['nice_' + n.id] = { type: 'noul', instructions: n.question };\n  }\n\n  out.push({\n    json: {\n      requestId: ranked.requestId,\n      startedAt: ranked.startedAt,\n      candidate: c,\n      pageFound: text !== undefined,\n      pageChars: text ? text.length : 0,\n      body_request: {\n        model: MODEL,\n        state: {\n          looking_for: ranked.need,\n          product: c.title,\n          url: c.url,\n          page_content: (text ?? '(the page could not be fetched)').slice(0, MAX_CHARS),\n        },\n        questions,\n        session_id: String(ranked.requestId ?? $execution.id).slice(0, 256),\n      },\n    },\n  });\n}\n\nif (out.length === 0) throw new Error('Nothing on the shortlist to verify');\nreturn out;\n"
      },
      "id": "d9e06841-bbb7-45c4-9b96-ea8f406719be",
      "name": "Build Verify Requests",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1680,
        96
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://openrouter.ai/api/alpha/decisions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openRouterApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.body_request) }}",
        "options": {
          "batching": {
            "batch": {
              "batchSize": 10,
              "batchInterval": 0
            }
          },
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "8bcedd16-5506-4345-b0e9-c6d99ffe7987",
      "name": "Verify (stage 2)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        1856,
        96
      ],
      "credentials": {
        "openRouterApi": {
          "id": "O3r1xV6tLDukbaqy",
          "name": "n8n-jimleuk-20260918"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// --- shared decision core (inlined into each Code node at build time) -------\n// Mirrors TypeSafe's published answer contract:\n//   Choice -> { choice, probabilities, confidence }\n//   Score  -> { score (continuous, probability-weighted), probabilities, confidence }\n//   Noul   -> { noul } : probability of yes, and NO confidence value\n// See https://docs.typesafe.ai/primitives.md\nfunction tsDecision(q, probs, extra) {\n  const probabilities = {};\n  q.candidates.forEach((c, j) => { probabilities[c] = Number(probs[j].toFixed(6)); });\n\n  let bestIdx = 0;\n  for (let j = 1; j < probs.length; j++) if (probs[j] > probs[bestIdx]) bestIdx = j;\n\n  if (q.type === 'noul') {\n    const yesIdx = q.candidates.indexOf('yes');\n    const p = probs[yesIdx >= 0 ? yesIdx : probs.length - 1];\n    // Jev returns no confidence for a Noul: the probability IS the answer.\n    // A value near 0.5 means yes and no are similarly likely - NOT medium intensity.\n    return Object.assign({\n      type: 'noul',\n      noul: Number(p.toFixed(6)),\n      value: p >= 0.5, // convenience for code that needs a hard boolean\n      probabilities,\n    }, extra || {});\n  }\n\n  if (q.type === 'score') {\n    // Jev's `score` is the probability-weighted position on the ordered levels,\n    // not the argmax index - their own docs threshold on `score > 1.5`.\n    return Object.assign({\n      type: 'score',\n      score: Number(probs.reduce((acc, p, j) => acc + p * j, 0).toFixed(6)),\n      level: q.candidates[bestIdx],\n      levelIndex: bestIdx,\n      probabilities,\n      confidence: Number(probs[bestIdx].toFixed(6)),\n    }, extra || {});\n  }\n\n  return Object.assign({\n    type: 'choice',\n    choice: q.candidates[bestIdx],\n    probabilities,\n    confidence: Number(probs[bestIdx].toFixed(6)),\n  }, extra || {});\n}\n\n// Vote counting -> renormalised probabilities over declared candidates only.\nfunction tsProbs(q, tokens) {\n  const counts = {};\n  for (const k of q.keys) counts[k] = 0;\n  for (const t of tokens) {\n    const tok = String(t ?? '').trim().toUpperCase().charAt(0);\n    if (Object.prototype.hasOwnProperty.call(counts, tok)) counts[tok] += 1;\n  }\n  const valid = Object.values(counts).reduce((a, b) => a + b, 0);\n  return {\n    valid,\n    degenerate: valid === 0,\n    probs: valid === 0 ? q.keys.map(() => 1 / q.keys.length) : q.keys.map((k) => counts[k] / valid),\n  };\n}\n\n// Aggregate. Jev excludes Noul from confidence, so ambiguous Nouls are tracked\n// on their own axis rather than folded into minConfidence.\nfunction tsSummary(decisions) {\n  let minConfidence = 1;\n  let maxNoulAmbiguity = 0;\n  for (const d of decisions) {\n    if (d.type === 'noul') {\n      const amb = 1 - Math.abs(2 * d.noul - 1); // 1 at p=0.5, 0 at p=0 or 1\n      if (amb > maxNoulAmbiguity) maxNoulAmbiguity = amb;\n    } else if (d.confidence < minConfidence) {\n      minConfidence = d.confidence;\n    }\n  }\n  return {\n    minConfidence: Number(minConfidence.toFixed(6)),\n    maxNoulAmbiguity: Number(maxNoulAmbiguity.toFixed(6)),\n  };\n}\n\n// Final ranking. Hard constraints gate; everything else is a weighted score.\n// All of this is policy, kept in code so it can change without re-running any\n// inference - the raw judgements are unaffected by a change of weights.\nconst MUST_THRESHOLD = 0.5;       // below this, the constraint is not met\nconst MIN_FIT_QUALIFY = 2.0;      // must also be the right KIND of product:\n                                  // an adjacent tool can satisfy every hard\n                                  // constraint and still be the wrong answer\nconst EVIDENCE_MIN = 0.4;         // stated outright vs inferred from marketing\nconst CURRENT_MIN = 0.5;          // still a live product\n\nconst W_FIT = 0.6;                // weight on overall fit\nconst W_MUST = 0.3;               // weight on how confidently constraints are met\nconst W_NICE = 0.1;               // weight on the nice-to-haves\n\nconst ranked = $('Rank Shortlist').first().json;\nconst prompts = $('Build Verify Requests').all();\nconst responses = $input.all();\n\nconst results = [];\n\nfor (let i = 0; i < responses.length; i++) {\n  const meta = prompts[i].json;\n  const res = responses[i].json;\n  if (!res || typeof res.answers !== 'object') {\n    const msg = res?.error?.message ?? JSON.stringify(res)?.slice(0, 200);\n    throw new Error(`Verification returned no answers for ${meta.candidate?.url}: ${msg}`);\n  }\n  const a = res.answers;\n  const p = (k) => (typeof a[k]?.noul === 'number' ? a[k].noul : null);\n\n  const fit = typeof a.fit?.score === 'number' ? a.fit.score : 0;\n  const evidence = p('evidence_is_explicit') ?? 0;\n  const current = p('is_current') ?? 1;\n\n  const musts = ranked.mustHave.map((m) => {\n    const prob = p('must_' + m.id) ?? 0;\n    return { id: m.id, question: m.question, probability: prob, met: prob >= MUST_THRESHOLD };\n  });\n  const nices = (ranked.niceToHave ?? []).map((n) => {\n    const prob = p('nice_' + n.id) ?? 0;\n    return { id: n.id, probability: prob, met: prob >= MUST_THRESHOLD };\n  });\n\n  const failed = musts.filter((m) => !m.met);\n  const mustMean = musts.length ? musts.reduce((s, m) => s + m.probability, 0) / musts.length : 1;\n  const niceMean = nices.length ? nices.reduce((s, n) => s + n.probability, 0) / nices.length : 0;\n\n  // fit is 0..4; normalise so every term is 0..1 before weighting.\n  const score = W_FIT * (fit / 4) + W_MUST * mustMean + W_NICE * niceMean;\n\n  const flags = [];\n  if (!meta.pageFound) flags.push('page could not be fetched; judged without content');\n  if (evidence < EVIDENCE_MIN) flags.push('claims are implied rather than stated');\n  if (current < CURRENT_MIN) flags.push('may be discontinued');\n\n  const wrongCategory = fit < MIN_FIT_QUALIFY;\n  if (wrongCategory) flags.push('meets the constraints but is not the kind of product asked for');\n\n  results.push({\n    rank: null,\n    product: meta.candidate.title,\n    url: meta.candidate.url,\n    domain: meta.candidate.domain,\n\n    searchPosition: meta.candidate.searchPosition,\n    shortlistPosition: meta.candidate.rerankPosition,\n\n    qualifies: failed.length === 0 && !wrongCategory,\n    failedConstraints: failed.map((m) => m.id),\n    wrongCategory,\n    mustHave: musts,\n    niceToHave: nices,\n\n    fit: Number(fit.toFixed(4)),\n    fitConfidence: a.fit?.confidence ?? null,\n    score: Number(score.toFixed(4)),\n    evidenceIsExplicit: evidence,\n    isCurrent: current,\n    flags,\n    pageChars: meta.pageChars,\n  });\n}\n\n// Qualifying candidates first, then by weighted score. A disqualified candidate\n// is still returned - knowing what failed and why is the useful part.\nconst ordered = results\n  .sort((x, y) => Number(y.qualifies) - Number(x.qualifies) || y.score - x.score)\n  .map((r, i) => ({ ...r, rank: i + 1 }));\n\nconst winner = ordered.find((r) => r.qualifies) ?? null;\n\nreturn [\n  {\n    json: {\n      requestId: ranked.requestId,\n      need: ranked.need,\n      searchQuery: ranked.searchQuery,\n\n      // The headline: where the winner sat in Google, and where it sits now.\n      recommendation: winner ? winner.product : null,\n      recommendationUrl: winner ? winner.url : null,\n      winnerSearchPosition: winner ? winner.searchPosition : null,\n      positionsGained: winner ? winner.searchPosition - 1 : null,\n\n      candidatesSeen: ranked.candidatesSeen,\n      listiclesDropped: ranked.listiclesDropped,\n      shortlisted: ranked.shortlistCount,\n      qualifying: ordered.filter((r) => r.qualifies).length,\n      disqualified: ordered.filter((r) => !r.qualifies).length,\n\n      llmCalls: ranked.stageOneCalls + responses.length,\n      elapsedMs: Date.now() - (ranked.startedAt ?? Date.now()),\n\n      results: ordered,\n      droppedAtStageOne: ranked.dropped,\n    },\n  },\n];\n"
      },
      "id": "8f277b96-73d0-4ded-a30f-6865deaeac87",
      "name": "Finalise Ranking",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2160,
        96
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "shortlist",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "b1a8bdee-3027-4fc9-9ec1-52a9f4092597",
      "name": "Shortlist Request",
      "webhookId": "94ed1c69-2a69-4bc6-a014-d3f479077d2b",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        192
      ]
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "5a1784c6-1151-4771-94ef-cd549fd987f8",
      "name": "Respond",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2352,
        96
      ]
    },
    {
      "parameters": {
        "content": "# Better Deep Web Research using Re-ranking with Jev\n\nSearch ranks by what is popular but this template ranks by what you actually asked for. This template simulates a vendor search scenario which deploys a wide to narrow search strategy utilising Jev as a ranking classifier on the results. \n\n### Fast search, then judgement\nGoogle returns ~30 candidates. Jev scores **each one independently against the requirement** - one question set per candidate, which is the method from TypeSafe's re-ranking cookbook (top-1 accuracy 5% → 18% on their benchmark).\n\n### Cheap wide, then expensive narrow\nRe-ranking 30 snippets costs a fraction of a cent. Scraping 30 pages does not. The re-rank cuts the scrape bill 6x and pays for itself.\n\n### Policy lives in code\nHard gate: any must-have below 0.5 disqualifies, however good the fit. Score: 0.6 × fit + 0.3 × constraint confidence + 0.1 × nice-to-haves. Change the weights without re-running a single inference.",
        "height": 640,
        "width": 560
      },
      "id": "eef4fe37-669f-443a-9915-097aca8f9d09",
      "name": "How this works",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -912,
        -240
      ]
    },
    {
      "parameters": {
        "content": "## 2. Search Wide for Relevant Candidates using [Apify](https://www.apify.com?fpr=414q6)",
        "height": 336,
        "width": 768,
        "color": 5
      },
      "id": "ad5e46d9-435f-4715-91f5-9135e0889a53",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        432,
        -16
      ]
    },
    {
      "parameters": {
        "content": "## 3. Narrow Search on Shortlisted Candidates",
        "height": 336,
        "width": 816,
        "color": 5
      },
      "id": "45e6c11f-93cc-4279-bf84-4485d57dbffc",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1232,
        -16
      ]
    },
    {
      "parameters": {
        "content": "## 1.  Define Vendor Requirements",
        "height": 480,
        "width": 688,
        "color": 7
      },
      "id": "d4128e29-f6a8-47b5-afd4-1625a5dad001",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -288,
        -112
      ]
    },
    {
      "parameters": {
        "content": "## 4. Finalise Ranking of Vendors",
        "height": 336,
        "width": 480,
        "color": 7
      },
      "id": "73cfdc61-ddf4-4d77-9a8c-810a090d8313",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2064,
        -16
      ]
    },
    {
      "parameters": {
        "content": "## ⚠️ OpenRouter Requirement\nWe're using Jev via OpenRouter so you'll need an OpenRouter key and credits. Alternatively, swap this out for another Jev provider.",
        "height": 144,
        "width": 432,
        "color": 6
      },
      "id": "38f7f3be-4f5d-4048-9c5e-a70a4bac79c3",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        880,
        352
      ]
    },
    {
      "parameters": {
        "content": "## ⚠️ Apify Requirement\nWe're using Apify for websearch so you'll need an Apify key. The free account has $5 in credits - [sign up here](https://www.apify.com?fpr=414q6) (affliate link supports my work!)",
        "height": 144,
        "width": 432,
        "color": 6
      },
      "id": "107a76b0-90e1-4b67-a286-79dd50f81d82",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        432,
        352
      ]
    },
    {
      "parameters": {
        "content": "[![](https://cdn.subworkflow.ai/marketing/banner-300x100.png?v=20260918)](https://subworkflow.ai)",
        "height": 128,
        "width": 336,
        "color": 7
      },
      "id": "539e7560-c239-4963-a643-e4a8f9a10a26",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -928,
        400
      ]
    }
  ],
  "pinData": {
    "Apify: Google Search": [
      {
        "json": {
          "searchQuery": {
            "term": "error tracking tool self-hosted EU data residency SOC 2"
          },
          "organicResults": [
            {
              "position": 1,
              "title": "The 12 Best Error Tracking Tools in 2026 (Ranked & Reviewed)",
              "url": "https://blog.devtoolsweekly.com/best-error-tracking-tools-2026",
              "description": "We tested 12 error tracking platforms. Our top picks for startups, enterprises and self-hosters, with pricing comparison and feature tables."
            },
            {
              "position": 2,
              "title": "Kestrel — Error Monitoring for Modern Engineering Teams",
              "url": "https://kestrel.io/",
              "description": "Catch every exception before your users do. Real-time alerts, release tracking and 30-day retention. Trusted by 4,000 teams. Start free."
            },
            {
              "position": 3,
              "title": "Error tracking explained: a beginner's guide",
              "url": "https://www.codecraft.dev/guides/error-tracking-explained",
              "description": "What error tracking is, why it matters, and how it differs from logging and APM. A primer for engineers new to observability."
            },
            {
              "position": 4,
              "title": "Beacon Error Tracking — Pricing",
              "url": "https://beacon.dev/pricing",
              "description": "Simple per-seat pricing. SOC 2 Type II certified. SSO on every plan. 14-day trial, no card required."
            },
            {
              "position": 5,
              "title": "What error tracker does everyone use these days? : r/devops",
              "url": "https://www.reddit.com/r/devops/comments/1abc234/what_error_tracker",
              "description": "47 comments. Mostly people arguing about self-hosting vs SaaS and whether the EU hosting options are real."
            },
            {
              "position": 6,
              "title": "Sentinel Labs — Unified Observability Platform",
              "url": "https://sentinellabs.com/platform",
              "description": "Metrics, traces and logs in one place. Built for platform teams running Kubernetes at scale. SOC 2 and ISO 27001."
            },
            {
              "position": 7,
              "title": "9 Sentry Alternatives Worth Considering",
              "url": "https://www.stackreport.io/sentry-alternatives",
              "description": "Open source and commercial alternatives compared on price, hosting model and data residency."
            },
            {
              "position": 8,
              "title": "Halon — Self-hosted error tracking, EU-first",
              "url": "https://halon.eu/",
              "description": "Error and exception tracking you run yourself, or hosted by us in Frankfurt. SOC 2 Type II. Full data residency control."
            },
            {
              "position": 9,
              "title": "Kestrel Docs — Data retention and regions",
              "url": "https://docs.kestrel.io/regions",
              "description": "Kestrel stores all customer data in us-east-1. Region selection is on our roadmap."
            },
            {
              "position": 10,
              "title": "Error Tracking Software Reviews 2026 | SoftwareChoice",
              "url": "https://www.softwarechoice.com/categories/error-tracking",
              "description": "Compare 40+ error tracking products. Verified user reviews, pricing and feature comparison."
            },
            {
              "position": 11,
              "title": "Stackwatch — Open source error tracking",
              "url": "https://stackwatch.io/",
              "description": "Apache-2.0 licensed error tracking. Deploy with Docker Compose or Helm. Run it anywhere, own your data."
            },
            {
              "position": 12,
              "title": "Halon vs Kestrel: which should you choose?",
              "url": "https://halon.eu/compare/kestrel",
              "description": "An honest comparison of hosting models, data residency and compliance posture."
            }
          ]
        }
      }
    ],
    "Apify: Scrape Page": [
      {
        "json": {
          "url": "https://halon.eu/",
          "title": "Halon — Self-hosted error tracking, EU-first",
          "markdown": "Halon — error tracking you control.\n\nHalon captures exceptions, stack traces and release context from your backend services and shows you what broke, where, and for how many users.\n\nDEPLOYMENT\nRun Halon yourself with Docker Compose, Helm or a single binary. There is no phone-home and no hosted dependency — a self-hosted Halon instance never contacts our infrastructure. If you would rather not operate it, Halon Cloud runs in Frankfurt (eu-central-1) and all customer data, including stack traces and user identifiers, stays within the EU. There is no US region and no cross-border replication.\n\nCOMPLIANCE\nHalon is SOC 2 Type II certified; our latest report covers the twelve months to June 2026 and is available under NDA from the trust centre. We are also ISO 27001 certified and act as a GDPR data processor with a standard DPA.\n\nPRICING\nSelf-hosted Community is free for up to 5 users. Self-hosted Business is £40/user/month and adds SSO and audit logs. Halon Cloud starts at £26/user/month.\n\nHalon is actively developed; 4.2 shipped in August 2026."
        }
      },
      {
        "json": {
          "url": "https://kestrel.io/",
          "title": "Kestrel — Error Monitoring for Modern Engineering Teams",
          "markdown": "Kestrel — catch every exception before your users do.\n\nReal-time exception tracking with release health, suspect-commit detection and 30-day retention. Over 4,000 engineering teams use Kestrel.\n\nHOW IT WORKS\nKestrel is a fully managed cloud service. Install the SDK, deploy, and errors appear in your dashboard within seconds. There is nothing to run and nothing to patch — we handle scaling, upgrades and storage.\n\nWe are often asked about self-hosting. Kestrel is cloud-only; we do not offer an on-premise or self-managed distribution, and we have no plans to. Our infrastructure runs in us-east-1 and us-west-2. A European region is on the roadmap for 2027.\n\nSECURITY\nSOC 2 Type II certified. Data encrypted in transit and at rest. SSO and SCIM on Enterprise.\n\nPRICING\nTeam $29/user/month. Business $49/user/month. Enterprise on request."
        }
      },
      {
        "json": {
          "url": "https://beacon.dev/pricing",
          "title": "Beacon Error Tracking — Pricing",
          "markdown": "Beacon — pricing.\n\nStarter $19/user/month · Growth $39/user/month · Enterprise from $12k/year.\n\nEvery plan includes unlimited projects, SSO, and 90-day retention.\n\nSECURITY AND COMPLIANCE\nBeacon is SOC 2 Type II certified and completes an annual penetration test. Full details in our trust centre.\n\nHOSTING\nBeacon is a hosted service. You can choose your data region at signup: US (Virginia), EU (Dublin) or Australia (Sydney). Data stays in the region you pick and is not replicated elsewhere. Customers on Enterprise can request a dedicated single-tenant deployment in their chosen region, managed by us.\n\nWe do not distribute Beacon for customer-operated installation. Self-hosting is not available on any plan.\n\nBeacon is a Beacon Software Ltd product. Last updated September 2026."
        }
      },
      {
        "json": {
          "url": "https://stackwatch.io/",
          "title": "Stackwatch — Open source error tracking",
          "markdown": "Stackwatch — open source error tracking.\n\nApache-2.0 licensed. Deploy with Docker Compose, Helm, or from source. Run it on your own hardware, in your own VPC, in whatever country you like — Stackwatch has no hosted offering at all, so data residency is entirely a function of where you deploy it.\n\nFEATURES\nException grouping, stack traces with source maps, release tracking, alerting via webhook, Slack and PagerDuty. SDKs for Node, Python, Go, Ruby, Java and .NET.\n\nCOMPLIANCE\nStackwatch is a community-maintained open source project. We do not hold any security certifications — there is no company to certify. We have not undergone a SOC 2 audit and do not plan to. If you need an audited attestation you will need to certify your own deployment as part of your own scope.\n\nActively maintained. v3.1 released July 2026. 340 contributors."
        }
      },
      {
        "json": {
          "url": "https://sentinellabs.com/platform",
          "title": "Sentinel Labs — Unified Observability Platform",
          "markdown": "Sentinel Labs — metrics, traces and logs in one platform.\n\nBuilt for platform engineering teams running Kubernetes at scale. Ingest OpenTelemetry from anywhere, correlate across signals, and build SLO dashboards.\n\nSentinel Labs is an observability platform. We are not an error tracking or exception management product — customers typically run a dedicated error tracker alongside Sentinel and forward events to us for correlation.\n\nDEPLOYMENT\nSaaS in us-east-1, eu-west-1 and ap-southeast-2. Self-managed available on Enterprise via our Helm chart.\n\nCOMPLIANCE\nSOC 2 Type II, ISO 27001, HIPAA BAA available."
        }
      },
      {
        "json": {
          "url": "https://blog.devtoolsweekly.com/best-error-tracking-tools-2026",
          "title": "The 12 Best Error Tracking Tools in 2026 (Ranked & Reviewed)",
          "markdown": "The 12 Best Error Tracking Tools in 2026.\n\nWe spent three weeks testing the leading error tracking platforms. Here is our ranking.\n\n1. Kestrel — best overall\n2. Beacon — best for compliance-conscious teams\n3. Halon — best self-hosted option\n4. Stackwatch — best free option\n...\n\nThis post contains affiliate links. DevTools Weekly may earn a commission."
        }
      }
    ]
  },
  "connections": {
    "Test Manually": {
      "main": [
        [
          {
            "node": "Requirement",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Requirement": {
      "main": [
        [
          {
            "node": "Build Search Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Search Request": {
      "main": [
        [
          {
            "node": "Apify: Google Search",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apify: Google Search": {
      "main": [
        [
          {
            "node": "Build Candidates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Candidates": {
      "main": [
        [
          {
            "node": "Rerank (stage 1)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rerank (stage 1)": {
      "main": [
        [
          {
            "node": "Rank Shortlist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rank Shortlist": {
      "main": [
        [
          {
            "node": "Fan Out Shortlist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fan Out Shortlist": {
      "main": [
        [
          {
            "node": "Apify: Scrape Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apify: Scrape Page": {
      "main": [
        [
          {
            "node": "Build Verify Requests",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Verify Requests": {
      "main": [
        [
          {
            "node": "Verify (stage 2)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify (stage 2)": {
      "main": [
        [
          {
            "node": "Finalise Ranking",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Finalise Ranking": {
      "main": [
        [
          {
            "node": "Respond",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Shortlist Request": {
      "main": [
        [
          {
            "node": "Build Search Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate"
  },
  "versionId": "e3e0114a-691e-49dc-b436-e2f60258c32d",
  "meta": {
    "instanceId": "b9f144fdc910a1e14e522063b576e7e28af8b611858295f590957fc8454b2836"
  },
  "nodeGroups": [],
  "id": "69NiFqVhv7twZRcp",
  "tags": []
}