{
  "id": "wf003-knowledge-extraction-00000",
  "name": "WF-003-knowledge-extraction",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 * * * *"
            }
          ]
        }
      },
      "id": "c4000000-0003-0003-0003-000000000001",
      "name": "Schedule 1hr",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        240,
        512
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT t.id, t.title, t.description, t.source FROM topics t WHERE t.status = 'approved' AND EXISTS (SELECT 1 FROM raw_research rr WHERE rr.topic_id = t.id AND rr.status = 'ingested') ORDER BY t.created_at ASC LIMIT 1",
        "options": {}
      },
      "id": "c4000000-0003-0003-0003-000000000002",
      "name": "Poll Approved Topics",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        464,
        512
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 1
          },
          "conditions": [
            {
              "id": "c4000000-0003-0003-0003-000000000030",
              "leftValue": "={{ $json.id }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "c4000000-0003-0003-0003-000000000003",
      "name": "Has Topics?",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.2,
      "position": [
        688,
        512
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "{{ `SELECT rr.content_text, rr.source_url, rr.source_name, ${$json.id} AS topic_id, '${($json.title || '').replace(/'/g, \"''\")}' AS topic_title, '${($json.description || '').replace(/'/g, \"''\")}' AS topic_description FROM raw_research rr WHERE rr.topic_id = ${$json.id} AND rr.status = 'ingested' LIMIT 20` }}",
        "options": {}
      },
      "id": "c4000000-0003-0003-0003-000000000004",
      "name": "Fetch Raw Research",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        912,
        512
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const items = $input.all();\nreturn items.map((i, idx) => ({\n  json: {\n    topic_id: i.json.topic_id,\n    topic_title: i.json.topic_title,\n    topic_description: i.json.topic_description,\n    research_text: i.json.content_text || '',\n    source_url: i.json.source_url || null,\n    source_name: i.json.source_name || null\n  },\n  pairedItem: { item: idx }\n}));"
      },
      "id": "c4000000-0003-0003-0003-000000000005",
      "name": "Merge Research",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1120,
        512
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT pv.system_prompt, pv.user_prompt_template, pv.model_params FROM prompt_versions pv JOIN prompts p ON p.id = pv.prompt_id WHERE p.name = 'knowledge_extract' AND pv.version = p.current_version AND p.is_active = true LIMIT 1",
        "options": {}
      },
      "id": "c4000000-0003-0003-0003-000000000006",
      "name": "Load Prompt",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1344,
        512
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const research = $('Merge Research').item.json;\nconst prompt = $input.item.json;\n\nconst userPrompt = (prompt.user_prompt_template || '')\n  .replace('{{ topic_title }}', research.topic_title || '')\n  .replace('{{ research_text }}', research.research_text || '');\n\nreturn [{\n  json: {\n    topic_id: research.topic_id,\n    topic_title: research.topic_title,\n    source_url: research.source_url,\n    source_name: research.source_name,\n    system_prompt: prompt.system_prompt,\n    user_prompt: userPrompt,\n    task_type: 'knowledge_extract',\n    output_format: 'text',\n    workflow_name: 'WF-003-knowledge-extraction'\n  }\n}];"
      },
      "id": "c4000000-0003-0003-0003-000000000007",
      "name": "Build Prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1568,
        512
      ]
    },
    {
      "parameters": {
        "workflowId": {
          "__rl": true,
          "value": "2673a82a-e155-4d0c-85b4-92922b74849d",
          "mode": "id"
        },
        "options": {}
      },
      "id": "c4000000-0003-0003-0003-000000000008",
      "name": "Call SUB-001",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1.1,
      "position": [
        1792,
        512
      ]
    },
    {
      "parameters": {
        "jsCode": "const raw = $input.item.json;\nconst ctx = $('Build Prompt').item.json;\nconst topicId = ctx.topic_id;\n\n// Lenient extractor: prefer clean JSON, else salvage record-by-record from text.\nfunction extractRecords(rj, txt) {\n  // 1. Response was structured JSON \u2014 the LLM built the array for us.\n  if (Array.isArray(rj)) return rj;\n  if (rj && typeof rj === 'object') {\n    const arr = Object.values(rj).find(v => Array.isArray(v));\n    if (arr) return arr;\n  }\n  const text = String(txt || '');\n  if (!text) return [];\n\n  // 2. Try full-array parse.\n  const arrMatch = text.match(/\\[[\\s\\S]*\\]/);\n  if (arrMatch) {\n    try { const p = JSON.parse(arrMatch[0]); if (Array.isArray(p)) return p; } catch (_) {}\n  }\n\n  // 3. Salvage: greedy scan for balanced {...} objects and try each in isolation.\n  //    One bad quote in one record no longer nukes the whole batch.\n  const salvaged = [];\n  const start = text.indexOf('{');\n  if (start === -1) return [];\n  let depth = 0, from = -1, inStr = false, esc = false;\n  for (let i = start; i < text.length; i++) {\n    const c = text[i];\n    if (esc) { esc = false; continue; }\n    if (c === '\\\\') { esc = true; continue; }\n    if (c === '\"') { inStr = !inStr; continue; }\n    if (inStr) continue;\n    if (c === '{') { if (depth === 0) from = i; depth++; }\n    else if (c === '}') {\n      depth--;\n      if (depth === 0 && from !== -1) {\n        const chunk = text.slice(from, i + 1);\n        try { salvaged.push(JSON.parse(chunk)); }\n        catch (_) {\n          // Repair pass \u2014 collapse stray \" before a comma or } that follows text.\n          const repaired = chunk.replace(/\"\\s*-\\s*([A-Z][a-zA-Z .'-]{1,60})\"/g, ' - $1\"');\n          try { salvaged.push(JSON.parse(repaired)); } catch (__) { /* drop this record */ }\n        }\n        from = -1;\n      }\n    }\n  }\n  return salvaged;\n}\n\nconst records = extractRecords(raw.response_json, raw.response_text);\n\nconst validTypes = ['fact','statistic','quote','entity','event','definition'];\nconst esc = s => String(s || '').replace(/'/g, \"''\");\nconst srcUrl = ctx.source_url ? `'${esc(ctx.source_url)}'` : 'NULL';\nconst srcName = ctx.source_name ? `'${esc(ctx.source_name)}'` : 'NULL';\n\nconst values = records.slice(0, 30).map(r => {\n  const recType = validTypes.includes(r.record_type) ? r.record_type : 'fact';\n  const content = esc(String(r.content || '').substring(0, 2000));\n  const score = parseFloat(r.confidence_score) || 0.8;\n  return `(${topicId}, '${recType}', '${content}', ${srcUrl}, ${srcName}, ${score}, NOW())`;\n}).filter(v => v.includes(\"''\") === false || v.length > 40);  // drop truly empty content\n\nconst bulkSql = values.length > 0\n  ? `INSERT INTO knowledge_records (topic_id, record_type, content, source_url, source_name, confidence_score, created_at) VALUES ${values.join(', ')} ON CONFLICT DO NOTHING`\n  : `SELECT 0 AS inserted`;\n\nreturn [{ json: { topic_id: topicId, record_count: values.length, bulkSql, salvaged: records.length } }];\n"
      },
      "id": "c4000000-0003-0003-0003-000000000009",
      "name": "Parse & Build SQL",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2000,
        512
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "{{ $json.bulkSql }}",
        "options": {}
      },
      "id": "c4000000-0003-0003-0003-000000000010",
      "name": "Bulk Insert Records",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        2224,
        512
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "c4000000-0003-0003-0003-000000000015",
      "name": "Loop Over Sources",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        1376,
        800
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "{{ `SELECT COUNT(*) AS record_count, ${$('Poll Approved Topics').first().json.id} AS topic_id FROM knowledge_records WHERE topic_id = ${$('Poll Approved Topics').first().json.id}` }}",
        "options": {}
      },
      "id": "c4000000-0003-0003-0003-000000000012",
      "name": "Count Records",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        2672,
        512
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "{{ `UPDATE topics SET status = '${parseInt($json.record_count) >= 3 ? 'assigned' : 'archived'}', updated_at = NOW() WHERE id = ${$json.topic_id} RETURNING id, title, status` }}",
        "options": {}
      },
      "id": "c4000000-0003-0003-0003-000000000013",
      "name": "Update Topic",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        2880,
        512
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "{{ `INSERT INTO workflow_logs (workflow_name, workflow_id, execution_id, status, input_summary, output_summary, created_at) VALUES ('WF-003-knowledge-extraction', 'wf003-knowledge-extraction-00000', '${$execution.id}', 'completed', '{}', '{}', NOW())` }}",
        "options": {}
      },
      "id": "c4000000-0003-0003-0003-000000000014",
      "name": "Log Result",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        3104,
        512
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    }
  ],
  "connections": {
    "Schedule 1hr": {
      "main": [
        [
          {
            "node": "Poll Approved Topics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Poll Approved Topics": {
      "main": [
        [
          {
            "node": "Has Topics?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Topics?": {
      "main": [
        [
          {
            "node": "Fetch Raw Research",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Raw Research": {
      "main": [
        [
          {
            "node": "Merge Research",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Research": {
      "main": [
        [
          {
            "node": "Loop Over Sources",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Sources": {
      "main": [
        [
          {
            "node": "Count Records",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Load Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Prompt": {
      "main": [
        [
          {
            "node": "Build Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Prompt": {
      "main": [
        [
          {
            "node": "Call SUB-001",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Call SUB-001": {
      "main": [
        [
          {
            "node": "Parse & Build SQL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse & Build SQL": {
      "main": [
        [
          {
            "node": "Bulk Insert Records",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Bulk Insert Records": {
      "main": [
        [
          {
            "node": "Loop Over Sources",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Count Records": {
      "main": [
        [
          {
            "node": "Update Topic",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Topic": {
      "main": [
        [
          {
            "node": "Log Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate"
  }
}