{
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "upload-exam-api",
        "options": {}
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -1472,
        208
      ],
      "id": "95728df6-0c12-4a85-b0d7-3549ffe8a23d",
      "name": "On API submission",
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "const item = $input.first();\nconst body = item.json.body || item.json || {};\nconst binary = item.binary || {};\n\nconst questionFile = binary.question_file;\nconst answerFile = binary.answer_file;\n\nif (!questionFile || !answerFile) {\n  throw new Error('Thieu file question_file hoac answer_file. Gui multipart/form-data tu API backend.');\n}\n\nconst examId = Number(body.exam_id);\nif (!Number.isInteger(examId) || examId <= 0) {\n  throw new Error('exam_id khong hop le.');\n}\n\nconst examCode = String(body.exam_code || '').trim();\nif (!examCode) {\n  throw new Error('Thieu exam_code.');\n}\n\nconst title = String(body.title || '').trim();\nif (!title) {\n  throw new Error('Thieu title de thi.');\n}\n\nconst examType = String(body.exam_type || '').trim();\nif (!examType) {\n  throw new Error('Thieu exam_type.');\n}\n\nconst teacherId = Number(body.teacher_id);\nif (!Number.isInteger(teacherId) || teacherId <= 0) {\n  throw new Error('teacher_id khong hop le.');\n}\n\nconst examRound = String(body.exam_round || '').trim();\n\nconst questionFilename = String(questionFile.fileName || questionFile.filename || '').trim();\nconst answerFilename = String(answerFile.fileName || answerFile.filename || '').trim();\nconst answerName = answerFilename.toLowerCase();\nconst answerMime = String(answerFile.mimeType || answerFile.mimetype || '').toLowerCase();\nconst supportedExtensions = ['.pdf', '.png', '.jpg', '.jpeg', '.webp', '.doc', '.docx'];\nconst hasSupportedExtension = supportedExtensions.some((extension) => answerName.endsWith(extension));\nconst supportedMimeTypes = [\n  'application/pdf',\n  'image/png',\n  'image/jpeg',\n  'image/webp',\n  'application/msword',\n  'application/vnd.openxmlformats-officedocument.wordprocessingml.document'\n];\n\nif (!hasSupportedExtension && !supportedMimeTypes.includes(answerMime)) {\n  throw new Error('answer_file khong thuoc dinh dang OCR duoc ho tro.');\n}\n\nreturn [{\n  json: {\n    exam_id: examId,\n    exam_code: examCode,\n    title,\n    description: String(body.description || '').trim(),\n    class_code: String(body.class_code || '').trim(),\n    subject_code: String(body.subject_code || '').trim(),\n    subject_name: String(body.subject_name || '').trim(),\n    exam_type: examType,\n    exam_round: examRound,\n    teacher_id: teacherId,\n    question_filename: questionFilename,\n    answer_filename: answerFilename,\n    question_file_path: questionFilename || null,\n    answer_file_path: answerFilename || null\n  },\n  binary: {\n    question_file: questionFile,\n    answer_file: answerFile\n  }\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1232,
        224
      ],
      "id": "dd79d149-98a8-4253-bbea-21648d7af81b",
      "name": "Validate API payload"
    },
    {
      "parameters": {
        "jsCode": "const source = $('Validate API payload').first();\nreturn [{ json: { ...source.json, }, binary: { ...source.binary } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -960,
        224
      ],
      "id": "58019132-28d3-4fd9-9527-be4694fcb7d1",
      "name": "Restore validated payload"
    },
    {
      "parameters": {
        "inputDataFieldName": "question_file",
        "name": "={{ $binary.question_file.fileName || $binary.question_file.filename || 'question-file' }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "folderId": {
          "__rl": true,
          "value": "1wOZ8pKLYH-mzXpKavQsH8Y7HuMGjkECP",
          "mode": "list",
          "cachedResultName": "n8n",
          "cachedResultUrl": "https://drive.google.com/drive/folders/1wOZ8pKLYH-mzXpKavQsH8Y7HuMGjkECP"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        -736,
        224
      ],
      "id": "1667b240-f539-4b26-bbb7-072e7a8b1282",
      "name": "Upload question file",
      "alwaysOutputData": true,
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const meta = $('Validate API payload').first();\nconst upload = $input.first();\nconst questionFilePath = upload.json.webViewLink || upload.json.webContentLink || upload.json.id || '';\nif (!questionFilePath) {\n  throw new Error('Khong lay duoc URL Google Drive cho question_file');\n}\nreturn [{ json: { ...meta.json, question_file_path: questionFilePath, question_drive_file_id: upload.json.id || null }, binary: { ...meta.binary } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -512,
        224
      ],
      "id": "3454c324-10cc-4ccf-9ec9-4d91fdf8606d",
      "name": "Restore payload after question upload"
    },
    {
      "parameters": {
        "inputDataFieldName": "answer_file",
        "name": "={{ $binary.answer_file.fileName || $binary.answer_file.filename || 'answer-file' }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "folderId": {
          "__rl": true,
          "value": "1wOZ8pKLYH-mzXpKavQsH8Y7HuMGjkECP",
          "mode": "list",
          "cachedResultName": "n8n",
          "cachedResultUrl": "https://drive.google.com/drive/folders/1wOZ8pKLYH-mzXpKavQsH8Y7HuMGjkECP"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        -288,
        224
      ],
      "id": "842a6554-76c7-42d7-951c-4b7b87ace125",
      "name": "Upload answer file",
      "alwaysOutputData": true,
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const meta = $('Restore payload after question upload').first().json;\nconst binary = $('Validate API payload').first().binary || {};\nconst answerUpload = $input.first();\nconst answerFilePath = answerUpload.json.webViewLink || answerUpload.json.webContentLink || answerUpload.json.id || '';\nif (!answerFilePath) {\n  throw new Error('Khong lay duoc URL Google Drive cho answer_file');\n}\nreturn [{ json: { ...meta, answer_file_path: answerFilePath, answer_drive_file_id: answerUpload.json.id || null }, binary: { ...binary } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -160,
        -48
      ],
      "id": "877cafad-9011-49a6-8887-ec3d150d51a4",
      "name": "Attach Drive uploads"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.mistral.ai/v1/files",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "mistralCloudApi",
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "name": "purpose",
              "value": "ocr"
            },
            {
              "parameterType": "formBinaryData",
              "name": "file",
              "inputDataFieldName": "answer_file"
            }
          ]
        },
        "options": {}
      },
      "id": "a953fe4a-f8c2-416f-bb1b-043beea33ac7",
      "name": "Mistral Upload",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -64,
        224
      ],
      "typeVersion": 4.2,
      "credentials": {
        "mistralCloudApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "url": "=https://api.mistral.ai/v1/files/{{ $json.id }}/url",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "mistralCloudApi",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "expiry",
              "value": "24"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "options": {}
      },
      "id": "612b35b4-baab-45ad-9ec5-af8a6239a08b",
      "name": "Mistral Signed URL",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        160,
        224
      ],
      "typeVersion": 4.2,
      "credentials": {
        "mistralCloudApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.mistral.ai/v1/ocr",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "mistralCloudApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"model\": \"mistral-ocr-latest\",\n  \"document\": {\n    \"type\": \"document_url\",\n    \"document_url\": \"{{ $json.url }}\"\n  },\n  \"include_image_base64\": false\n}",
        "options": {}
      },
      "id": "223ea895-ae88-4f8b-88ee-c4b00edebc6f",
      "name": "Mistral DOC OCR",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        384,
        224
      ],
      "typeVersion": 4.2,
      "credentials": {
        "mistralCloudApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "8db56b6d-6f85-4dd4-b1b4-780bcc65f53d",
              "name": "markdown",
              "value": "={{ ($json.pages || []).map(page => page.markdown || '').join('\\n\\n').trim() }}",
              "type": "string"
            },
            {
              "id": "ef803507-f63b-48b6-adc6-fcf0de48805a",
              "name": "page_count",
              "value": "={{ ($json.pages || []).length }}",
              "type": "number"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        608,
        224
      ],
      "id": "bf293835-5dd0-4b7d-9fa4-dcad2a234656",
      "name": "Edit Fields"
    },
    {
      "parameters": {
        "jsCode": "const meta = $('Attach Drive uploads').first().json;\nconst ocr = $('Edit Fields').first().json;\nreturn [{ json: { ...meta, markdown: ocr.markdown || '', page_count: Number(ocr.page_count || 0) } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        832,
        224
      ],
      "id": "0d66a219-5636-49e1-a572-1290fcc6e8da",
      "name": "Restore OCR context"
    },
    {
      "parameters": {
        "jsCode": "const source = $('Restore OCR context').first();\nreturn [{ json: { ...source.json } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1280,
        224
      ],
      "id": "427576ae-5575-4b05-8ccf-1309f0430d89",
      "name": "Restore extracted markdown"
    },
    {
      "parameters": {
        "modelId": {
          "__rl": true,
          "value": "gpt-4.1-mini",
          "mode": "list",
          "cachedResultName": "GPT-4.1-MINI"
        },
        "responses": {
          "values": [
            {
              "content": "=Ban la AI trich xuat dap an va rubric tu OCR markdown cua dap an mau.\n\nMuc tieu:\nTra ve DUY NHAT mot JSON object hop le theo dung contract ben duoi, khong markdown, khong giai thich them.\n\nContract output:\n{\n  \"questions\": [\n    {\n      \"question_no\": \"1\",\n      \"question_text\": \"...\",\n      \"question_type\": \"essay\" | \"multiple_choice\" | \"unknown\",\n      \"options\": [\"A\", \"B\", \"C\", \"D\"],\n      \"expected_answer\": \"...\",\n      \"rubrics\": [\n        {\n          \"key\": \"...\",\n          \"score\": 0.5\n        }\n      ],\n      \"max_score\": 2\n    }\n  ],\n  \"total_max_score\": 10\n}\n\nQuy tac:\n1. Neu la tu luan, options de [] va question_type = \"essay\".\n2. Neu la trac nghiem, tach phuong an vao options va expected_answer la dap an dung neu OCR nhin thay.\n3. Neu khong xac dinh duoc so cau, dat question_no theo thu tu 1, 2, 3...\n4. max_score la so, neu khong ro thi dung 1 cho trac nghiem va 2 cho tu luan.\n5. rubrics la mang cac y cham chinh; neu khong co thi tra ve [].\n6. Bo phan dau mo rong khong lien quan nhu ten truong, ten mon, thoi gian thi, chi dan hanh chinh.\n7. total_max_score la tong max_score cua cac cau neu co the tinh duoc, neu khong thi tra ve null.\n8. Neu OCR rat kem va khong tach duoc cau hoi, tra ve {\"questions\": [], \"total_max_score\": null}.\n\nInput OCR markdown:\n{{ $json.markdown }}"
            }
          ]
        },
        "builtInTools": {},
        "options": {}
      },
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 2.1,
      "position": [
        1424,
        112
      ],
      "id": "a754341a-e34c-4eb6-a56a-64620f753eec",
      "name": "Message a model",
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "8c670303-b61c-4d31-923f-cb5ac2fdcbcc",
              "name": "text",
              "value": "={{ $json.output[0].content[0].text }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1728,
        224
      ],
      "id": "b89bf976-c50e-47b8-8a2f-e8cd3433fcab",
      "name": "Edit Fields1"
    },
    {
      "parameters": {
        "jsCode": "const rawText = String($input.first().json.text || '').trim();\nif (!rawText) {\n  throw new Error('Model khong tra ve noi dung extract JSON.');\n}\nlet parsed;\ntry {\n  parsed = JSON.parse(rawText);\n} catch (error) {\n  throw new Error(`Khong parse duoc JSON extract tu model: ${error.message}`);\n}\nif (Array.isArray(parsed)) {\n  parsed = { questions: parsed };\n}\nif (!parsed || typeof parsed !== 'object') {\n  throw new Error('Output extract khong dung dinh dang object.');\n}\nconst rawQuestions = Array.isArray(parsed.questions) ? parsed.questions : [];\nconst normalizedQuestions = rawQuestions.map((question, index) => {\n  const questionText = String(question.question_text || '').trim();\n  const expectedAnswer = String(question.expected_answer || '').trim();\n  const questionNo = String(question.question_no || index + 1).trim();\n  const options = Array.isArray(question.options) ? question.options.map(option => String(option || '').trim()).filter(Boolean) : [];\n  const rubrics = Array.isArray(question.rubrics) ? question.rubrics.map((rubric) => {\n    const key = String(rubric?.key || rubric?.content || '').trim();\n    const score = Number(rubric?.score);\n    return { key, score: Number.isFinite(score) && score >= 0 ? score : null };\n  }).filter((rubric) => rubric.key) : [];\n  const rawMaxScore = Number(question.max_score);\n  const questionType = String(question.question_type || (options.length ? 'multiple_choice' : 'essay')).trim() || 'unknown';\n  const defaultScore = questionType === 'multiple_choice' ? 1 : 2;\n  return {\n    question_no: questionNo || String(index + 1),\n    question_text: questionText,\n    question_type: questionType,\n    options,\n    expected_answer: expectedAnswer,\n    rubrics,\n    max_score: Number.isFinite(rawMaxScore) && rawMaxScore > 0 ? rawMaxScore : defaultScore\n  };\n}).filter((question) => question.question_text || question.expected_answer || question.options.length || question.rubrics.length);\nconst computedTotalMaxScore = normalizedQuestions.reduce((sum, question) => sum + Number(question.max_score || 0), 0);\nconst rawTotalMaxScore = Number(parsed.total_max_score);\nreturn [{ json: { extract_json: { questions: normalizedQuestions, total_max_score: Number.isFinite(rawTotalMaxScore) && rawTotalMaxScore > 0 ? rawTotalMaxScore : normalizedQuestions.length ? Number(computedTotalMaxScore.toFixed(2)) : null }, questions_count: normalizedQuestions.length } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1952,
        224
      ],
      "id": "86304e7e-be7f-424b-a44e-9d38dcad2b81",
      "name": "exam_detail"
    },
    {
      "parameters": {
        "jsCode": "const meta = $('Restore extracted markdown').first().json;\nconst extract = $('exam_detail').first().json.extract_json;\nfunction escapeLiteral(value) { return String(value ?? '').replace(/'/g, \"''\"); }\nfunction sqlNumber(value) { const number = Number(value); return Number.isFinite(number) ? String(number) : 'NULL'; }\nfunction sqlJson(value) { if (value === undefined || value === null) { return 'NULL'; } return `'${escapeLiteral(JSON.stringify(value))}'::jsonb`; }\nconst status = 'active';\nconst sql = `\nUPDATE exams\nSET\n  answer_extract = ${sqlJson(extract)},\n  status = '${status}',\n  updated_at = NOW(),\n  updated_by = ${sqlNumber(meta.teacher_id)}\nWHERE id = ${sqlNumber(meta.exam_id)};`;\nreturn [{ json: { exam_id: meta.exam_id, exam_code: meta.exam_code, answer_extract: extract, status, sql } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2176,
        224
      ],
      "id": "72f724b6-20f0-4773-b6d8-7296a37dc746",
      "name": "Build final SQL"
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "{{ $json.sql }}",
        "options": {}
      },
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        2400,
        224
      ],
      "id": "1f90e179-5973-4c54-9a2e-113239a473d4",
      "name": "Execute a SQL query",
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    }
  ],
  "connections": {
    "On API submission": {
      "main": [
        [
          {
            "node": "Validate API payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate API payload": {
      "main": [
        [
          {
            "node": "Restore validated payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Restore validated payload": {
      "main": [
        [
          {
            "node": "Upload question file",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload question file": {
      "main": [
        [
          {
            "node": "Restore payload after question upload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Restore payload after question upload": {
      "main": [
        [
          {
            "node": "Upload answer file",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload answer file": {
      "main": [
        [
          {
            "node": "Attach Drive uploads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attach Drive uploads": {
      "main": [
        [
          {
            "node": "Mistral Upload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mistral Upload": {
      "main": [
        [
          {
            "node": "Mistral Signed URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mistral Signed URL": {
      "main": [
        [
          {
            "node": "Mistral DOC OCR",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mistral DOC OCR": {
      "main": [
        [
          {
            "node": "Edit Fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Edit Fields": {
      "main": [
        [
          {
            "node": "Restore OCR context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Restore OCR context": {
      "main": [
        [
          {
            "node": "Restore extracted markdown",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Restore extracted markdown": {
      "main": [
        [
          {
            "node": "Message a model",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Message a model": {
      "main": [
        [
          {
            "node": "Edit Fields1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Edit Fields1": {
      "main": [
        [
          {
            "node": "exam_detail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "exam_detail": {
      "main": [
        [
          {
            "node": "Build final SQL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build final SQL": {
      "main": [
        [
          {
            "node": "Execute a SQL query",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Execute a SQL query": {
      "main": [
        []
      ]
    }
  }
}