{
  "settings": {
    "executionOrder": "v1",
    "timezone": "Asia/Seoul"
  },
  "staticData": null,
  "tags": [],
  "name": "OdidujI MVP - Image Upload",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "image-upload",
        "responseMode": "responseNode",
        "options": {
          "binaryData": true
        }
      },
      "id": "b6938e51-14ec-45ad-b532-950ad153d901",
      "name": "Webhook - image-upload",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -980,
        -220
      ]
    },
    {
      "parameters": {
        "jsCode": "const crypto = require('crypto');\nconst maxBytes = 10 * 1024 * 1024;\nconst allowedMime = new Set(['image/jpeg', 'image/png']);\nconst binaryKeys = Object.keys($binary || {});\nconst imageKey = binaryKeys[0];\nconst binary = imageKey ? $binary[imageKey] : null;\nconst fileName = binary?.fileName || $json?.file_name || 'upload.jpg';\nconst mimeType = binary?.mimeType || $json?.mime_type || '';\nconst ext = fileName.split('.').pop().toLowerCase();\nconst normalizedExt = ext === 'jpeg' ? 'jpg' : ext;\nconst validType = Boolean(binary) && allowedMime.has(mimeType) && ['jpg', 'png'].includes(normalizedExt);\nif (!validType) {\n  return [{ json: { status: 'rejected', validation_status: 'rejected', reject_reason: 'unsupported_file_type', allowed_extensions: ['jpg', 'png'], processing_log: ['received', 'rejected'] } }];\n}\nconst data = await this.helpers.getBinaryDataBuffer(0, imageKey);\nconst fileSize = data.length;\nif (fileSize > maxBytes) {\n  return [{ json: { status: 'rejected', validation_status: 'rejected', reject_reason: 'file_too_large', max_file_size_bytes: maxBytes, processing_log: ['received', 'rejected'] } }];\n}\nconst fileHash = crypto.createHash('sha256').update(data).digest('hex');\nconst liveId = String(Date.now()).slice(-9);\nconst captureId = `CAP-LIVE-${liveId}`;\nconst sourceImagePath = `/static/captures/${captureId}.${normalizedExt}`;\nreturn [{\n  json: {\n    upload_job_id: `JOB-${Date.now()}`,\n    capture_id: captureId,\n    status: 'validated',\n    validation_status: 'accepted',\n    file_hash: fileHash,\n    file_name: fileName,\n    file_ext: normalizedExt,\n    mime_type: mimeType,\n    file_size_bytes: fileSize,\n    source_image_path: sourceImagePath,\n    sqlite_db_path: $env.ODIDUJI_DB_PATH || '/database/odiduji.sqlite',\n    capture_dir: $env.ODIDUJI_CAPTURE_DIR || '/static/captures',\n    processing_log: ['received', 'validated']\n  },\n  binary: { data: binary }\n}];"
      },
      "id": "e308a01b-6ca6-4492-9de5-37621f3b4802",
      "name": "Validate JPG PNG and hash",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -740,
        -220
      ]
    },
    {
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{ $json.status }}",
              "operation": "equal",
              "value2": "rejected"
            }
          ]
        }
      },
      "id": "afd5b33e-1f9a-4b2c-a5e9-d90199168047",
      "name": "Rejected?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        -500,
        -220
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "bd4b2ba9-036c-42b2-a802-cd60176a635d",
      "name": "Respond rejected upload",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        -260,
        -360
      ]
    },
    {
      "parameters": {
        "jsCode": "const fs = require('fs');\nconst path = require('path');\nconst captureDir = $json.capture_dir || '/static/captures';\nfs.mkdirSync(captureDir, { recursive: true });\nconst filePath = path.join(captureDir, `${$json.capture_id}.${$json.file_ext}`);\nconst data = await this.helpers.getBinaryDataBuffer(0, 'data');\nfs.writeFileSync(filePath, data);\nreturn [{ json: { ...$json, status: 'received', disk_path: filePath, processing_log: [...$json.processing_log, 'stored_original'] }, binary: $binary }];"
      },
      "id": "e5fd5095-3bcf-46a4-8643-59f3bd7627a1",
      "name": "Store original capture",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -260,
        -120
      ]
    },
    {
      "parameters": {
        "jsCode": "const referenceText = [\n  'OdidujI\ub294 \ub300\ud559\uc0dd \ucea1\ucc98 \uc774\ubbf8\uc9c0\uc5d0\uc11c \ub9c8\uac10, \uae08\uc561, \uc81c\ucd9c\ubb3c, \uc2e0\uccad \uae30\uac04, \uc7a5\uc18c/\uc8fc\uc18c\ub97c \ucc3e\ub294 \uc11c\ube44\uc2a4\ub2e4.',\n  '\ubb38\uc11c \uc720\ud615\uc740 notice, assignment, scholarship, receipt, place_link, noise \uc911 \ud558\ub098\ub85c \ubcf8\ub2e4.',\n  'assignment\ub294 \uacfc\uc81c, \uc81c\ucd9c, \ub9c8\uac10, \ubcf4\uace0\uc11c, \ubc1c\ud45c\uc790\ub8cc, LMS/WISE \uacfc\uc81c \uc548\ub0b4\ub97c \uc6b0\uc120 \uadfc\uac70\ub85c \ubcf8\ub2e4.',\n  'scholarship\uc740 \uc7a5\ud559\uae08, \uadfc\ub85c\uc7a5\ud559, \uc9c0\uc6d0\uae08, \uc2e0\uccad \uae30\uac04, \uc81c\ucd9c \uc11c\ub958, \ud559\uc0dd\uc9c0\uc6d0\uc13c\ud130 \ubb38\uad6c\ub97c \uc6b0\uc120 \uadfc\uac70\ub85c \ubcf8\ub2e4.',\n  'receipt\ub294 \uc601\uc218\uc99d, \uacb0\uc81c, \uae08\uc561, \uc6d0, KRW, \uce74\ub4dc \uc2b9\uc778, \ud2f0\ucf13 \uac00\uaca9, \ub9e4\ucd9c\uc804\ud45c \ubb38\uad6c\ub97c \uc6b0\uc120 \uadfc\uac70\ub85c \ubcf8\ub2e4.',\n  'place_link\ub294 \uc7a5\uc18c, \uc8fc\uc18c, \uc704\uce58, \uac15\uc758\uc2e4, \ud68c\uc758\uc2e4, \uc13c\ud130, \uc9c0\ub3c4/\ub9c1\ud06c \ubb38\uad6c\ub97c \uc6b0\uc120 \uadfc\uac70\ub85c \ubcf8\ub2e4.',\n  '\ub0a0\uc9dc, \uae08\uc561, \uc81c\ucd9c\ubb3c\uc740 \uc6d0\ubb38 \ud45c\ud604\uc744 \ud6fc\uc190\ud558\uc9c0 \ub9d0\uace0 markdown\uacfc element \uc88c\ud45c\uc5d0 \ub0a8\uaca8\uc57c \ud55c\ub2e4.',\n  '\uadfc\uac70 \uc5c6\ub294 \uac1c\uc778\uc815\ubcf4, \ube44\ubc00\ubc88\ud638, \uce74\ub4dc\ubc88\ud638, \ud559\ubc88 \ucd94\ub860\uc740 noise \ub610\ub294 no-answer \ud6c4\ubcf4\ub85c \ub0a8\uaca8\uc57c \ud55c\ub2e4.'\n].join('\\n');\nconst referenceBlock = referenceText || '\ucc38\uace0\uc790\ub8cc \uc5c6\uc74c';\nconst parsePrompt = '\ub108\ub294 \ub300\ud559\uc0dd \ucea1\ucc98 \uc774\ubbf8\uc9c0\ub97c \uad6c\uc870\ud654\ud558\ub294 \ubb38\uc11c \ubd84\uc11d\uac00\uc57c. OCR \uacb0\uacfc\ub97c \uc784\uc758\ub85c \uc694\uc57d\ud558\uc9c0 \ub9d0\uace0, \uac80\uc0c9\uacfc \uadfc\uac70 \ud45c\uc2dc\uac00 \uac00\ub2a5\ud558\ub3c4\ub85d \uc6d0\ubb38\uc5d0 \uac00\uae4c\uc6b4 markdown\uacfc element \uad6c\uc870\ub97c \ub0a8\uaca8\uc918.\\n'\n  + '1. \ucc38\uace0\uc790\ub8cc\uac00 \uc788\uc73c\uba74 \ucc38\uace0\uc790\ub8cc\ub97c \uc6b0\uc120 \uae30\uc900\uc73c\ub85c \uc0bc\uc544 \ubb38\uc11c \uc720\ud615 \ud310\ub2e8\uc5d0 \ud544\uc694\ud55c \ub2e8\uc5b4\ub97c \ubcf4\uc874\ud574\uc918.\\n'\n  + '2. \ub9c8\uac10/\uae30\uac04/\uae08\uc561/\uc81c\ucd9c\ubb3c/\uc7a5\uc18c/\uc8fc\uc18c/\ucd9c\ucc98/\ubb38\uc758\ucc98\ub294 \uc904\ubc14\uafc8\uacfc \ubb38\ub9e5\uc744 \uc720\uc9c0\ud574\uc918.\\n'\n  + '3. \ub0a0\uc9dc\uc640 \uae08\uc561\uc740 \uc6d0\ubb38 \ud45c\uae30\ub97c \ubc18\ub4dc\uc2dc \ub0a8\uaca8\uc918. \uc608: 2026\ub144 5\uc6d4 7\uc77c, 5/24, 80,000\uc6d0.\\n'\n  + '4. \ud45c, \ubaa9\ub85d, \uc548\ub0b4\ubb38, \uba54\uc2e0\uc800 \ucea1\ucc98\uc758 \uac01 \ud56d\ubaa9\uc740 markdown list \ub610\ub294 element \ub2e8\uc704\ub85c \ubd84\ub9ac\ud574\uc918.\\n'\n  + '5. \ubd88\ud655\uc2e4\ud558\uac70\ub098 \uae68\uc9c4 \uae00\uc790\ub294 \ucd94\uc815\ud558\uc9c0 \ub9d0\uace0 \uc6d0\ubb38\uc5d0 \uac00\uae4c\uc6b4 \ud615\ud0dc\ub85c \ub0a8\uaca8\uc918.\\n\\n'\n  + '<\uc815\ubcf4>\\n' + referenceBlock + '\\n</\uc815\ubcf4>';\nconst classifyPrompt = '\uc544\ub798 parsed_markdown\uc744 \ubcf4\uace0 doc_type\uc744 notice, assignment, scholarship, receipt, place_link, noise \uc911 \ud558\ub098\ub85c \uace0\ub978\ub2e4. \ucc38\uace0\uc790\ub8cc \uae30\uc900\uc744 \uc6b0\uc120 \uc801\uc6a9\ud558\uace0, \uadfc\uac70\uac00 \uc57d\ud558\uba74 Classify_Ambiguous \ud6c4\ubcf4\ub97c \ub0a8\uae34\ub2e4.\\n\\n<\uc815\ubcf4>\\n' + referenceBlock + '\\n</\uc815\ubcf4>';\nconst extractPrompt = '\uc544\ub798 parsed_markdown\uc5d0\uc11c title, deadline, amount, required_submission, location_name, address, source\uc640 evidence_text\ub97c \ucd94\ucd9c\ud55c\ub2e4. \uac12\uc740 \ubc18\ub4dc\uc2dc \uc6d0\ubb38 \uadfc\uac70\uac00 \uc788\ub294 \uacbd\uc6b0\uc5d0\ub9cc \ucc44\uc6b4\ub2e4.\\n\\n<\uc815\ubcf4>\\n' + referenceBlock + '\\n</\uc815\ubcf4>';\nreturn [{ json: { ...$json, upstage_parse_url: $env.UPSTAGE_DOCUMENT_PARSE_URL || 'https://api.upstage.ai/v1/document-ai/document-parse', reference_text: referenceText, reference_used: true, parsePrompt, classifyPrompt, extractPrompt, parse_request: { ocr: 'force', output_format: 'markdown' }, status: 'parsed_pending' }, binary: $binary }];"
      },
      "id": "58d3eda4-ee5b-46dd-a492-4058f86150b0",
      "name": "Prepare Document Parse",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -20,
        -120
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.upstage_parse_url }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $env.UPSTAGE_API_KEY }}"
            }
          ]
        },
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "parameterType": "formBinaryData",
              "name": "document",
              "inputDataFieldName": "data"
            },
            {
              "name": "ocr",
              "value": "={{ $json.parse_request.ocr }}"
            },
            {
              "name": "output_format",
              "value": "={{ $json.parse_request.output_format }}"
            }
          ]
        },
        "options": {}
      },
      "id": "871f9953-b540-4809-85e8-ee1b421fe64a",
      "name": "Upstage Document Parse",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4,
      "position": [
        220,
        -120
      ],
      "continueOnFail": true
    },
    {
      "parameters": {
        "jsCode": "const response = $json || {};\nfunction toText(value) {\n  if (value === null || value === undefined) return '';\n  if (typeof value === 'string') return value;\n  if (Array.isArray(value)) return value.map(toText).filter(Boolean).join(String.fromCharCode(10));\n  if (typeof value === 'object') {\n    for (const key of ['parsed_markdown', 'markdown', 'text', 'content', 'html']) {\n      if (value[key] !== undefined && value[key] !== value) {\n        const nested = toText(value[key]);\n        if (nested) return nested;\n      }\n    }\n    if (Array.isArray(value.elements)) return toText(value.elements);\n    if (Array.isArray(value.pages)) return toText(value.pages);\n    if (value.content && value.content !== value) return toText(value.content);\n    return '';\n  }\n  return String(value);\n}\nconst content = toText(response.parsed_markdown || response.markdown || response.text || response.content || response.elements || response.pages || '');\nconst confidence = Number(response.confidence || response.parse_confidence || (content ? 0.8 : 0.5));\nreturn [{ json: { ...$('Store original capture').item.json, parsed_markdown: content, layout_blocks: response.elements || response.layout_blocks || response.pages || [], evidence_candidates: response.evidence_candidates || [], parse_confidence: confidence, status: 'parsed', processing_log: [...$('Store original capture').item.json.processing_log, 'parsed'] }, binary: $('Store original capture').item.binary }];"
      },
      "id": "3a07b5c4-92a6-4ee2-9268-df5b99533c0d",
      "name": "Normalize Parse Result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        460,
        -120
      ]
    },
    {
      "parameters": {
        "jsCode": "function normalizeText(value) {\n  if (value === null || value === undefined) return '';\n  if (typeof value === 'string') return value;\n  if (Array.isArray(value)) return value.map(normalizeText).filter(Boolean).join(String.fromCharCode(10));\n  if (typeof value === 'object') {\n    for (const key of ['parsed_markdown', 'markdown', 'text', 'content', 'html']) {\n      if (value[key] !== undefined && value[key] !== value) {\n        const nested = normalizeText(value[key]);\n        if (nested) return nested;\n      }\n    }\n    if (Array.isArray(value.elements)) return normalizeText(value.elements);\n    if (Array.isArray(value.pages)) return normalizeText(value.pages);\n    return JSON.stringify(value);\n  }\n  return String(value);\n}\nfunction hasAny(text, keywords) {\n  return keywords.some((keyword) => text.includes(keyword));\n}\nconst text = normalizeText($json.parsed_markdown);\nconst lowerText = text.toLowerCase();\nconst amountPattern = /(?:\\u20a9\\s*)?[0-9][0-9,]*\\s*(?:\\uC6D0|krw)/i;\nconst receiptKeywords = [\n  '\\uC601\\uC218\\uC99D',\n  '\\uACB0\\uC81C',\n  '\\uCE74\\uB4DC \\uC2B9\\uC778',\n  '\\uB9E4\\uCD9C\\uC804\\uD45C',\n  '\\uC608\\uB9E4',\n  '\\uD2F0\\uCF13',\n  '\\uC8FC\\uCC28\\uC815\\uC0B0',\n  '\\uC2E0\\uC6A9\\uCE74\\uB4DC',\n  '\\uD604\\uAE08\\uC601\\uC218\\uC99D'\n];\nconst paymentKeywords = [\n  '\\uACB0\\uC81C',\n  '\\uCE74\\uB4DC',\n  '\\uC2B9\\uC778',\n  '\\uC608\\uB9E4',\n  '\\uD2F0\\uCF13',\n  '\\uB9E4\\uCD9C',\n  '\\uC8FC\\uBB38'\n];\nconst scholarshipKeywords = [\n  '\\uC7A5\\uD559',\n  '\\uADFC\\uB85C\\uC7A5\\uD559',\n  '\\uC9C0\\uC6D0\\uAE08',\n  '\\uB4F1\\uB85D\\uAE08',\n  '\\uD559\\uC0DD\\uC9C0\\uC6D0\\uC13C\\uD130'\n];\nconst assignmentKeywords = [\n  '\\uACFC\\uC81C',\n  '\\uC81C\\uCD9C\\uBB3C',\n  '\\uBCF4\\uACE0\\uC11C',\n  '\\uB808\\uD3EC\\uD2B8',\n  '\\uBC1C\\uD45C\\uC790\\uB8CC',\n  '\\uACFC\\uC81C \\uC548\\uB0B4',\n  '\\uB9C8\\uAC10\\uC77C'\n];\nconst submissionContextKeywords = [\n  '\\uC218\\uC5C5',\n  '\\uAC15\\uC758',\n  'LMS',\n  'WISE',\n  '\\uACFC\\uBAA9',\n  '\\uAE30\\uD55C'\n];\nconst placeKeywords = [\n  '\\uC8FC\\uC18C',\n  '\\uC9C0\\uB3C4',\n  '\\uC624\\uC2DC\\uB294 \\uAE38',\n  '\\uC704\\uCE58 \\uACF5\\uC720',\n  '\\uAC15\\uC758\\uC2E4',\n  '\\uD68C\\uC758\\uC2E4'\n];\nlet docType = 'noise';\nif (!lowerText.trim()) {\n  docType = 'noise';\n} else if (hasAny(text, scholarshipKeywords)) {\n  docType = 'scholarship';\n} else if (hasAny(text, receiptKeywords) || (amountPattern.test(text) && hasAny(text, paymentKeywords))) {\n  docType = 'receipt';\n} else if (hasAny(text, assignmentKeywords) || (text.includes('\\uC81C\\uCD9C') && hasAny(text, submissionContextKeywords))) {\n  docType = 'assignment';\n} else if (hasAny(text, placeKeywords)) {\n  docType = 'place_link';\n} else {\n  docType = 'notice';\n}\nconst categoryConfidence = $json.parsed_markdown ? 0.72 : 0.5;\nconst secondary = docType === 'notice' ? 'assignment' : 'notice';\nconst candidates = [\n  { doc_type: docType, confidence: categoryConfidence },\n  { doc_type: secondary, confidence: 0.58 },\n  { doc_type: 'noise', confidence: lowerText.trim() ? 0.22 : 0.5 }\n];\nconst classificationState = categoryConfidence < 0.7 ? 'Classify_Ambiguous' : 'Classified';\nreturn [{ json: { ...$json, doc_type: docType, category_confidence: categoryConfidence, category_candidates: candidates, classification_state: classificationState, status: 'classified', processing_log: [...$json.processing_log, 'classified'] }, binary: $binary }];"
      },
      "id": "a93de515-68e8-4594-a2cf-9ac40236f852",
      "name": "Document Classify MVP",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        -120
      ]
    },
    {
      "parameters": {
        "jsCode": "const registry = {\n  notice: { schema_id: 'notice_v1', fields: ['title', 'source', 'created_date', 'event_date', 'deadline', 'has_submission', 'location_name'] },\n  assignment: { schema_id: 'assignment_v1', fields: ['title', 'deadline', 'required_submission', 'has_submission'] },\n  scholarship: { schema_id: 'scholarship_v1', fields: ['title', 'source', 'deadline', 'amount', 'required_submission', 'location_name'] },\n  receipt: { schema_id: 'receipt_v1', fields: ['title', 'created_date', 'amount', 'currency', 'location_name', 'address'] },\n  place_link: { schema_id: 'place_link_v1', fields: ['title', 'source', 'event_date', 'location_name', 'address'] },\n  noise: { schema_id: 'noise_v1', fields: ['title', 'source'] }\n};\nconst schema = registry[$json.doc_type];\nif (!schema) return [{ json: { ...$json, status: 'failed', error_code: 'schema_not_found' }, binary: $binary }];\nreturn [{ json: { ...$json, extract_schema_id: schema.schema_id, schema_json: schema, processing_log: [...$json.processing_log, 'schema_routed'] }, binary: $binary }];"
      },
      "id": "60fc7f04-a3d8-4bdc-b548-0a60ca91ee33",
      "name": "Schema Router",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        940,
        -120
      ]
    },
    {
      "parameters": {
        "jsCode": "function normalizeText(value) {\n  if (value === null || value === undefined) return '';\n  if (typeof value === 'string') return value;\n  if (Array.isArray(value)) return value.map(normalizeText).filter(Boolean).join(String.fromCharCode(10));\n  if (typeof value === 'object') {\n    for (const key of ['parsed_markdown', 'markdown', 'text', 'content', 'html']) {\n      if (value[key] !== undefined && value[key] !== value) {\n        const nested = normalizeText(value[key]);\n        if (nested) return nested;\n      }\n    }\n    if (Array.isArray(value.elements)) return normalizeText(value.elements);\n    if (Array.isArray(value.pages)) return normalizeText(value.pages);\n    return JSON.stringify(value);\n  }\n  return String(value);\n}\nconst text = normalizeText($json.parsed_markdown);\nconst lines = text.split(/\\n+/).map((line) => line.trim()).filter(Boolean);\nconst title = lines[0]?.replace(/^#+\\s*/, '') || $json.file_name;\nconst amountMatch = text.match(/([0-9][0-9,]*)\\s*(\uc6d0|krw|\u20a9)/i);\nconst dateMatch = text.match(/(20\\d{2}[.\\-\ub144\\s]+\\d{1,2}[.\\-\uc6d4\\s]+\\d{1,2})/);\nconst amount = amountMatch ? Number(amountMatch[1].replace(/,/g, '')) : null;\nconst deadline = dateMatch ? dateMatch[1].replace(/[\ub144\uc6d4.]/g, '-').replace(/\\s+/g, '').replace(/-$/g, '') : null;\nconst extractConfidence = text ? 0.72 : 0.5;\nconst evidence = {\n  title,\n  amount: amountMatch?.[0] || null,\n  deadline: dateMatch?.[0] || null,\n  doc_type: $json.doc_type\n};\nconst needsReview = [$json.parse_confidence, $json.category_confidence, extractConfidence].some((value) => Number(value) < 0.7);\nreturn [{ json: { ...$json, title, source: 'live_upload', deadline, amount, currency: amount ? 'KRW' : null, evidence_text: evidence, extract_confidence: extractConfidence, confidence: Math.min(Number($json.parse_confidence || 0), Number($json.category_confidence || 0), extractConfidence), needs_review: needsReview, status: 'extracted', processing_log: [...$json.processing_log, 'extracted', 'normalized'] }, binary: $binary }];"
      },
      "id": "e84dc4d0-97d8-4d45-bcef-5530a7f1328d",
      "name": "Information Extract MVP",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1180,
        -120
      ]
    },
    {
      "parameters": {
        "jsCode": "function loadSqlite3() {\n  try { return require('sqlite3'); } catch (firstError) {\n    try { return require('/usr/local/lib/node_modules/n8n/node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3'); }\n    catch (secondError) { throw new Error(`${firstError.message}; ${secondError.message}`); }\n  }\n}\nfunction openDb(dbPath) {\n  const sqlite3 = loadSqlite3();\n  const db = new sqlite3.Database(dbPath);\n  return {\n    exec(sql) { return new Promise((resolve, reject) => db.exec(sql, (error) => error ? reject(error) : resolve())); },\n    run(sql, params = []) { return new Promise((resolve, reject) => db.run(sql, params, function(error) { error ? reject(error) : resolve(this); })); },\n    get(sql, params = []) { return new Promise((resolve, reject) => db.get(sql, params, (error, row) => error ? reject(error) : resolve(row))); },\n    all(sql, params = []) { return new Promise((resolve, reject) => db.all(sql, params, (error, rows) => error ? reject(error) : resolve(rows || []))); },\n    close() { return new Promise((resolve, reject) => db.close((error) => error ? reject(error) : resolve())); },\n  };\n}\nconst fs = require('fs');\nlet db;\ntry {\n  db = openDb($json.sqlite_db_path);\n  await db.exec('PRAGMA foreign_keys = ON');\n  const duplicate = await db.get('SELECT capture_id, source_image_path FROM capture_records WHERE file_hash = ?', [$json.file_hash]);\n  if (duplicate) {\n    if ($json.disk_path && fs.existsSync($json.disk_path)) fs.unlinkSync($json.disk_path);\n    await db.close();\n    return [{ json: { ...$json, capture_id: duplicate.capture_id, source_image_path: duplicate.source_image_path, status: 'ready', cache_hit: true, processing_log: [...$json.processing_log, 'cache_hit', 'ready'] } }];\n  }\n  await db.exec('BEGIN IMMEDIATE');\n  await db.run(\n    `INSERT INTO capture_records (capture_id, source_image_path, file_hash, file_name, file_ext, mime_type, file_size_bytes, file_meta_json, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'ready')`,\n    [$json.capture_id, $json.source_image_path, $json.file_hash, $json.file_name, $json.file_ext, $json.mime_type, Number($json.file_size_bytes || 0), JSON.stringify({ upload_source: 'n8n', disk_path: $json.disk_path })]\n  );\n  await db.run(\n    `INSERT INTO metadata_index (capture_id, doc_type, title, source, deadline, amount, currency, evidence_text_json, parsed_markdown, layout_blocks_json, category_candidates_json, parse_confidence, category_confidence, extract_confidence, confidence, classification_state, needs_review) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n    [$json.capture_id, $json.doc_type, $json.title || null, $json.source || null, $json.deadline || null, $json.amount || null, $json.currency || null, JSON.stringify($json.evidence_text || {}), $json.parsed_markdown || null, JSON.stringify($json.layout_blocks || []), JSON.stringify($json.category_candidates || []), Number($json.parse_confidence || 0), Number($json.category_confidence || 0), Number($json.extract_confidence || 0), Number($json.confidence || 0), $json.classification_state || 'Classified', $json.needs_review ? 1 : 0]\n  );\n  await db.run(\n    `INSERT OR REPLACE INTO cache_entries (file_hash, capture_id, cached_parse_json, cached_classify_json, cached_extract_json, api_version) VALUES (?, ?, ?, ?, ?, ?)`,\n    [$json.file_hash, $json.capture_id, JSON.stringify({ parsed_markdown: $json.parsed_markdown, layout_blocks: $json.layout_blocks || [], parse_confidence: $json.parse_confidence }), JSON.stringify({ doc_type: $json.doc_type, category_confidence: $json.category_confidence, category_candidates: $json.category_candidates || [], classification_state: $json.classification_state }), JSON.stringify($json), 'phase3-n8n']\n  );\n  await db.exec('COMMIT');\n  await db.close();\n  return [{ json: { ...$json, status: 'ready', cache_hit: false, processing_log: [...$json.processing_log, 'indexed', 'ready'] } }];\n} catch (error) {\n  try { if (db) await db.exec('ROLLBACK'); } catch (_) {}\n  try { if (db) await db.close(); } catch (_) {}\n  return [{ json: { ...$json, status: 'failed', error_code: 'sqlite_write_failed', error_message: error.message, processing_log: [...($json.processing_log || []), 'sqlite_failed'] } }];\n}"
      },
      "id": "c85fcff0-ff02-48ef-b8c0-362f282498f3",
      "name": "SQLite Index Write",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1420,
        -120
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "f90601c4-bb8a-48af-96f0-69e6a071661f",
      "name": "Respond image-upload",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        1660,
        -120
      ]
    }
  ],
  "connections": {
    "Webhook - image-upload": {
      "main": [
        [
          {
            "node": "Validate JPG PNG and hash",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate JPG PNG and hash": {
      "main": [
        [
          {
            "node": "Rejected?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rejected?": {
      "main": [
        [
          {
            "node": "Respond rejected upload",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Store original capture",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Store original capture": {
      "main": [
        [
          {
            "node": "Prepare Document Parse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Document Parse": {
      "main": [
        [
          {
            "node": "Upstage Document Parse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upstage Document Parse": {
      "main": [
        [
          {
            "node": "Normalize Parse Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Parse Result": {
      "main": [
        [
          {
            "node": "Document Classify MVP",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Document Classify MVP": {
      "main": [
        [
          {
            "node": "Schema Router",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schema Router": {
      "main": [
        [
          {
            "node": "Information Extract MVP",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Information Extract MVP": {
      "main": [
        [
          {
            "node": "SQLite Index Write",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SQLite Index Write": {
      "main": [
        [
          {
            "node": "Respond image-upload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "triggerCount": 1,
  "versionId": "aaa994c1-e809-4ea6-9290-53648ee31c89"
}