{
  "name": "Thai Q&A Chatbot — Excel/PDF/DOCX/Text + OpenAI",
  "nodes": [
    {
      "parameters": {
        "path": "upload-knowledge",
        "formTitle": "เพิ่มความรู้ให้ Chatbot",
        "formDescription": "รองรับไฟล์ตาราง Q&A (.xlsx .xls) และเอกสารข้อความ (.pdf .docx .txt .md .html)",
        "formFields": {
          "values": [
            {
              "fieldLabel": "QA_File",
              "fieldType": "file",
              "requiredField": true,
              "acceptFileTypes": ".xlsx,.xls,.pdf,.docx,.txt,.md,.html,.htm",
              "multipleFiles": false
            },
            {
              "fieldLabel": "โหมดนำเข้า",
              "fieldType": "dropdown",
              "fieldOptions": {
                "values": [
                  {
                    "option": "เพิ่มต่อจากของเดิม (append)"
                  },
                  {
                    "option": "ล้างของเดิมแล้วนำเข้าใหม่ (replace)"
                  }
                ]
              },
              "requiredField": true
            }
          ]
        },
        "options": {
          "buttonLabel": "นำเข้าความรู้"
        }
      },
      "id": "42063be7-b11b-5188-9921-4d5c29a21241",
      "name": "Upload_Knowledge",
      "type": "n8n-nodes-base.formTrigger",
      "typeVersion": 2.2,
      "position": [
        -1120,
        -420
      ],
      "webhookId": "af165752-af5e-4e65-a2d5-fffb37a58891"
    },
    {
      "parameters": {
        "operation": "xlsx",
        "binaryPropertyName": "QA_File",
        "options": {}
      },
      "id": "2c0ef9bb-b272-5dae-890b-e027396c3328",
      "name": "Extract_Excel",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        -620,
        -660
      ]
    },
    {
      "parameters": {
        "jsCode": "// ===== รวมข้อมูลที่อ่านได้เข้าฐานความรู้ =====\nconst sd = $getWorkflowStaticData('global');\n\nconst incoming = [];\nlet skipped = 0;\nlet source = '';\nlet kind = '';\nlet warning = '';\nfor (const it of $input.all()) {\n  const j = it.json || {};\n  if (Array.isArray(j.items)) incoming.push(...j.items);\n  skipped += Number(j.skipped || 0);\n  source = j.source || source;\n  kind = j.kind || kind;\n  warning = j.warning || warning;\n}\n\n// โหมดนำเข้าที่เลือกจากฟอร์ม\nlet mode = '';\ntry {\n  const form = $('Upload_Knowledge').first().json || {};\n  for (const [k, v] of Object.entries(form)) {\n    if (/โหมด|mode/i.test(k) && v) { mode = String(v); break; }\n  }\n} catch (e) {}\nconst replaceAll = /ล้าง|replace/i.test(mode);\n\nfunction normKey(s) {\n  return String(s || '').toLowerCase().replace(/\\s+/g, '').replace(/[!-\\/:-@\\[-`{-~]/g, '');\n}\n\nlet base = replaceAll ? [] : (Array.isArray(sd.qa) ? sd.qa : []);\n\n// อัปโหลดไฟล์เอกสารชื่อเดิมซ้ำ = แทนที่เนื้อหาเก่าของไฟล์นั้นทั้งหมด\nif (!replaceAll && source && incoming.some(i => i.type === 'doc')) {\n  base = base.filter(r => !(r.type === 'doc' && r.source === source));\n}\n\n// รวมกัน: Q&A ตัดซ้ำด้วยคำถาม / เอกสารตัดซ้ำด้วยชื่อไฟล์+ลำดับท่อน\nconst map = new Map();\nfor (const r of [...base, ...incoming]) {\n  const key = r.type === 'doc' ? `doc:${r.source}:${r.part}` : `qa:${normKey(r.question)}`;\n  map.set(key, r);\n}\n\nsd.qa = [...map.values()];\nsd.updatedAt = new Date().toISOString();\nif (replaceAll) sd.misses = [];\n\nconst qaCount  = sd.qa.filter(r => r.type !== 'doc').length;\nconst docCount = sd.qa.filter(r => r.type === 'doc').length;\nconst sources = [...new Set(sd.qa.filter(r => r.source).map(r => r.source))];\n\nconst added = kind === 'excel'\n  ? `นำเข้า Q&A ${incoming.length} รายการ`\n  : `อ่านเอกสาร \"${source}\" ได้ ${incoming.length} ท่อน`;\n\nreturn [{\n  json: {\n    success: true,\n    imported: incoming.length,\n    skipped,\n    total: sd.qa.length,\n    message: added +\n      (skipped ? ` (ข้าม ${skipped} แถวที่ข้อมูลไม่ครบ)` : '') +\n      (warning ? `\\n⚠️ ${warning}` : '') +\n      `\\n\\nฐานความรู้ตอนนี้: Q&A ${qaCount} รายการ + เนื้อหาเอกสาร ${docCount} ท่อน` +\n      `\\nไฟล์ที่นำเข้าแล้ว: ${sources.join(', ') || '-'}`\n  }\n}];"
      },
      "id": "daa93764-ad4a-5b16-a674-a620862b3ddc",
      "name": "Store_QA",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -120,
        -420
      ]
    },
    {
      "parameters": {
        "operation": "completion",
        "respondWith": "text",
        "completionTitle": "นำเข้า Q&A สำเร็จ ✅",
        "completionMessage": "={{ $json.message }}",
        "options": {}
      },
      "id": "288f7d53-1929-5aa0-8d45-75c0ca21e4d6",
      "name": "Import_Result",
      "type": "n8n-nodes-base.form",
      "typeVersion": 1,
      "position": [
        120,
        -420
      ],
      "webhookId": "c2247a0b-f244-482f-9d87-f9ee75efac20"
    },
    {
      "parameters": {
        "public": false,
        "options": {
          "responseMode": "lastNode"
        }
      },
      "id": "ef67ea84-1b2c-5e33-902e-8e935a62efdc",
      "name": "Chatbot",
      "type": "@n8n/n8n-nodes-langchain.chatTrigger",
      "typeVersion": 1.3,
      "position": [
        -760,
        380
      ],
      "webhookId": "88e7aca4-b599-427c-b0d9-59821a0d2be5"
    },
    {
      "parameters": {
        "jsCode": "// ===== ค้นหาข้อมูลที่เกี่ยวข้อง แล้วส่งเป็น context ให้ AI =====\nconst sd = $getWorkflowStaticData('global');\nconst kb = Array.isArray(sd.qa) ? sd.qa : [];\n\nconst MIN_QA  = 0.22;  // เกณฑ์สำหรับรายการ Q&A จากไฟล์ Excel\nconst MIN_DOC = 0.28;  // เกณฑ์สำหรับเนื้อหาจากเอกสาร (ใช้วิธีวัดคนละแบบกับ Q&A)\nconst TOP_K   = 3;\n\nconst raw = $json.chatInput ?? $json.text ?? $json.message ?? '';\nconst q = String(raw).trim();\nconst sessionId = $json.sessionId ?? $json.session_id ?? 'default';\n\nfunction norm(s) {\n  return String(s ?? '').toLowerCase()\n    .replace(/[\\u200B-\\u200D\\uFEFF]/g, '')\n    .replace(/[!-\\/:-@\\[-`{-~\\u201C\\u201D\\u2018\\u2019]/g, ' ')\n    .replace(/\\s+/g, ' ').trim();\n}\nfunction compact(s) { return norm(s).replace(/\\s+/g, ''); }\nfunction grams(s, n) {\n  const t = compact(s); const out = new Set();\n  if (!t) return out;\n  if (t.length <= n) { out.add(t); return out; }\n  for (let i = 0; i <= t.length - n; i++) out.add(t.slice(i, i + n));\n  return out;\n}\nfunction dice(A, B) {\n  if (!A.size || !B.size) return 0;\n  let hit = 0;\n  for (const g of A) if (B.has(g)) hit++;\n  return (2 * hit) / (A.size + B.size);\n}\n// สัดส่วนของ gram ในคำถาม ที่ปรากฏในข้อความเป้าหมาย — เหมาะกับข้อความยาว\nfunction coverage(A, B) {\n  if (!A.size || !B.size) return 0;\n  let hit = 0;\n  for (const g of A) if (B.has(g)) hit++;\n  return hit / A.size;\n}\n// เลื่อนหน้าต่างไปตามเนื้อหา แล้วเอาช่วงที่ตรงที่สุด\n// ป้องกันไม่ให้ท่อนยาว ๆ ได้เปรียบเพียงเพราะมีตัวอักษรเยอะ\nfunction windowCoverage(query, body) {\n  const t = compact(body);\n  const cq = compact(query);\n  if (!t || !cq) return 0;\n  const W = Math.max(120, cq.length * 6);\n  const step = Math.max(40, Math.floor(W / 2));\n  const A2 = grams(query, 2), A3 = grams(query, 3);\n  let best = 0;\n  for (let i = 0; i < Math.max(1, t.length - W + step); i += step) {\n    const w = t.slice(i, i + W);\n    const s = 0.6 * coverage(A2, grams(w, 2)) + 0.4 * coverage(A3, grams(w, 3));\n    if (s > best) best = s;\n  }\n  return best;\n}\nfunction simText(a, b) {\n  let s = 0.5 * dice(grams(a, 2), grams(b, 2)) + 0.5 * dice(grams(a, 3), grams(b, 3));\n  const ca = compact(a), cb = compact(b);\n  if (ca.length >= 2 && cb.length >= 2 && (cb.includes(ca) || ca.includes(cb))) {\n    s = Math.max(s, 0.72 + 0.28 * (Math.min(ca.length, cb.length) / Math.max(ca.length, cb.length)));\n  }\n  return Math.min(1, s);\n}\nfunction simKeywords(text, kw) {\n  if (!kw) return 0;\n  const cq = compact(text);\n  let best = 0;\n  for (const k of String(kw).split(/[,|;\\/]+/).map(x => x.trim()).filter(Boolean)) {\n    const ck = compact(k);\n    if (ck.length >= 2 && cq.includes(ck)) best = Math.max(best, 0.95);\n    best = Math.max(best, simText(text, k) * 0.9);\n  }\n  return best;\n}\n\nconst direct = (output, extra = {}) => [{\n  json: { useAi: false, output, question: q, sessionId, contextText: '', ...extra }\n}];\n\nif (!q) return direct('พิมพ์คำถามได้เลยครับ (พิมพ์ /help เพื่อดูคำสั่งพิเศษ)');\n\n// ---- คำสั่งพิเศษ ----\nconst cmd = q.toLowerCase();\nif (cmd === '/help' || cmd === 'help' || cmd === 'ช่วยเหลือ') {\n  return direct([\n    'คำสั่งที่ใช้ได้:',\n    '• /stats — ดูจำนวนข้อมูลในฐานความรู้',\n    '• /sources — ดูไฟล์ที่นำเข้าแล้ว',\n    '• /list — ดูรายการคำถามจากไฟล์ Excel',\n    '• /misses — ดูคำถามที่ระบบตอบไม่ได้',\n    '',\n    'นอกนั้นพิมพ์คำถามได้ตามปกติครับ'\n  ].join('\\n'));\n}\nif (cmd === '/stats' || cmd === 'สถิติ') {\n  const qaN = kb.filter(r => r.type !== 'doc').length;\n  const docN = kb.filter(r => r.type === 'doc').length;\n  const byCat = {};\n  for (const r of kb.filter(x => x.type !== 'doc')) byCat[r.category] = (byCat[r.category] || 0) + 1;\n  return direct(\n    `ฐานความรู้: Q&A ${qaN} รายการ + เนื้อหาเอกสาร ${docN} ท่อน\\n` +\n    Object.entries(byCat).map(([k, v]) => `• ${k}: ${v}`).join('\\n') +\n    (sd.updatedAt ? `\\nอัปเดตล่าสุด: ${sd.updatedAt}` : '')\n  );\n}\nif (cmd === '/sources' || cmd === 'ไฟล์') {\n  const src = {};\n  for (const r of kb) { const s = r.source || '-'; src[s] = (src[s] || 0) + 1; }\n  const list = Object.entries(src).map(([k, v]) => `• ${k} (${v})`).join('\\n');\n  return direct(list ? 'ไฟล์ที่นำเข้าแล้ว:\\n' + list : 'ยังไม่มีไฟล์ในฐานความรู้ครับ');\n}\nif (cmd === '/list' || cmd === 'รายการ') {\n  const only = kb.filter(r => r.type !== 'doc');\n  if (!only.length) return direct('ยังไม่มีรายการ Q&A จากไฟล์ Excel ครับ');\n  return direct(only.slice(0, 50).map((r, i) => `${i + 1}. [${r.category}] ${r.question}`).join('\\n'));\n}\nif (cmd === '/misses') {\n  const m = Array.isArray(sd.misses) ? sd.misses : [];\n  if (!m.length) return direct('ยังไม่มีคำถามที่ตอบไม่ได้ครับ');\n  return direct('คำถามที่ยังไม่มีคำตอบ:\\n' + m.slice(-30).map((x, i) => `${i + 1}. ${x.question}`).join('\\n'));\n}\n\nif (!kb.length) {\n  return direct('ยังไม่มีข้อมูลในฐานความรู้ครับ กรุณาอัปโหลดไฟล์ผ่านฟอร์มก่อน (รองรับ Excel, PDF, Word, TXT, Markdown, HTML)');\n}\n\n// ---- ให้คะแนน ----\nconst ranked = kb.map(r => {\n  let score;\n  if (r.type === 'doc') {\n    // เอกสารยาว: วัดว่าคำถามถูกครอบคลุมอยู่ในเนื้อหาช่วงไหนมากที่สุด\n    score = windowCoverage(q, `${r.question}\\n${r.answer}`) * 0.95;\n    return { ...r, score, pass: score >= MIN_DOC };\n  }\n  score = Math.max(\n    simText(q, r.question),\n    simKeywords(q, r.keywords),\n    simText(q, r.category) * 0.6\n  );\n  return { ...r, score, pass: score >= MIN_QA };\n}).sort((a, b) => b.score - a.score);\n\nconst hits = ranked.filter(r => r.pass).slice(0, TOP_K);\n\nif (!hits.length) {\n  if (!Array.isArray(sd.misses)) sd.misses = [];\n  sd.misses.push({ question: q, at: new Date().toISOString() });\n  if (sd.misses.length > 200) sd.misses = sd.misses.slice(-200);\n\n  const near = ranked.filter(r => r.type !== 'doc' && r.score >= MIN_QA * 0.5).slice(0, 3);\n  const suggest = near.length\n    ? '\\n\\nคำถามใกล้เคียงที่มีในระบบ:\\n' + near.map(r => `• ${r.question}`).join('\\n')\n    : '';\n  return direct('ขออภัยครับ ยังไม่พบข้อมูลเรื่องนี้ในฐานความรู้' + suggest, {\n    matched: false,\n    score: ranked[0] ? Number(ranked[0].score.toFixed(3)) : 0\n  });\n}\n\nconst contextText = hits.map((r, i) => r.type === 'doc'\n  ? `[${i + 1}] จากเอกสาร: ${r.source} (ท่อนที่ ${r.part})\\n${r.answer}`\n  : `[${i + 1}] หมวด: ${r.category}\\nคำถาม: ${r.question}\\nคำตอบ: ${r.answer}`\n).join('\\n\\n');\n\nreturn [{\n  json: {\n    useAi: true,\n    question: q,\n    sessionId,\n    contextText,\n    matched: true,\n    topScore: Number(hits[0].score.toFixed(3)),\n    sourceType: hits[0].type,\n    matchedSource: hits[0].source || hits[0].question,\n    usedItems: hits.length\n  }\n}];"
      },
      "id": "318782bb-5857-5e1e-b419-cf906f27eabe",
      "name": "Find_Answer",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -480,
        380
      ]
    },
    {
      "parameters": {
        "content": "## 1) ฝั่งนำเข้าความรู้ (รองรับหลายสกุลไฟล์)\n\n`Route_File_Type` ดูนามสกุลไฟล์แล้วแยกทาง\n\n| สกุลไฟล์ | เส้นทาง | ผลลัพธ์ |\n|---|---|---|\n| .xlsx .xls | Extract_Excel → Rows_To_QA | 1 แถว = 1 Q&A |\n| .pdf | Extract_PDF → Text_To_Chunks | ตัดเป็นท่อนละ ~700 ตัวอักษร |\n| .docx | Docx_To_Text → Text_To_Chunks | เหมือนกัน |\n| .txt .md .html | Extract_Text → Text_To_Chunks | เหมือนกัน |\n\n**วิธีใช้:** Save → Activate → เปิด Production URL ของ `Upload_Knowledge` → อัปโหลดไฟล์\n\n- อัปโหลดเอกสารชื่อเดิมซ้ำ = แทนที่เนื้อหาเก่าของไฟล์นั้น (ใช้อัปเดตเอกสารได้เลย)\n- ตรวจสอบด้วยคำสั่ง `/stats` และ `/sources` ในแชท\n- **DOCX ต้องตั้งค่าเพิ่ม:** environment variable `NODE_FUNCTION_ALLOW_BUILTIN=zlib` แล้วรีสตาร์ท n8n (ถ้าไม่ตั้ง ให้แปลงเป็น PDF ก่อนอัปโหลด)\n- PDF ที่เป็นภาพสแกนจะอ่านไม่ได้ ต้องทำ OCR มาก่อน",
        "height": 520,
        "width": 520,
        "color": 4
      },
      "id": "e9af06ee-028f-5ef4-9b85-eedc2e480f1e",
      "name": "Note_Upload",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1140,
        -1000
      ]
    },
    {
      "parameters": {
        "content": "## 2) ฝั่งแชท (ค้นหา + ให้ AI เรียบเรียง)\n\n`Find_Answer` ให้คะแนนข้อมูล 2 แบบด้วยวิธีต่างกัน\n\n- **Q&A จาก Excel** — วัดความคล้ายด้วย Dice + n-gram (เกณฑ์ `MIN_QA` = 0.22)\n- **เนื้อหาจากเอกสาร** — วัดด้วย coverage คือดูว่าคำถามถูกครอบคลุมในเนื้อหาแค่ไหน (เกณฑ์ `MIN_DOC` = 0.28) โดยเลื่อนหน้าต่างหาช่วงที่ตรงที่สุดในท่อนนั้น ไม่ให้ท่อนยาวได้เปรียบเพราะมีตัวอักษรเยอะ\n\nเลือก Top 3 ส่งให้ `AI_Answer` เรียบเรียง — AI ตอบได้เฉพาะจากข้อมูลที่ส่งให้เท่านั้น\n\n**ก่อนใช้งาน:** เปิด `OpenAI_Model` แล้วใส่ OpenAI API key\n\nคำสั่งพิเศษ: `/stats` `/sources` `/list` `/misses` `/help`",
        "height": 400,
        "width": 520,
        "color": 5
      },
      "id": "e0aadd63-937d-5724-b548-86d97f3e4bf4",
      "name": "Note_Chat",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -780,
        760
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "41a4cbee-9f5a-5c84-8145-b3eeab8af0d5",
              "leftValue": "={{ $json.useAi }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "a54dfac0-ff97-5430-afe4-c7a97d38ba64",
      "name": "IF_Use_AI",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -200,
        380
      ]
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "={{ $json.question }}",
        "options": {
          "systemMessage": "=คุณคือผู้ช่วยตอบคำถามขององค์กร ตอบเป็นภาษาไทย สุภาพ กระชับ 1–3 ประโยค\n\nกติกาสำคัญ (ห้ามฝ่าฝืน):\n1. ตอบโดยอ้างอิงจาก \"ข้อมูลอ้างอิง\" ด้านล่างเท่านั้น ห้ามเดา ห้ามเติมข้อมูลจากความรู้ทั่วไป แม้จะมั่นใจว่าถูกก็ตาม\n2. ถ้าข้อมูลอ้างอิงไม่พอจะตอบคำถามนี้ ให้บอกตรง ๆ ว่ายังไม่มีข้อมูลเรื่องนี้ และแนะนำให้ติดต่อผู้ดูแลระบบ ห้ามแต่งคำตอบขึ้นเอง\n3. ห้ามพูดถึงคำว่า \"ข้อมูลอ้างอิง\" \"เอกสาร\" หรือ \"ฐานข้อมูล\" ในคำตอบ ให้ตอบเหมือนเจ้าหน้าที่ที่รู้เรื่องนี้อยู่แล้ว\n4. ถ้าผู้ใช้ถามต่อเนื่องจากคำถามก่อนหน้า ให้ใช้บริบทการสนทนาประกอบได้ แต่เนื้อหาคำตอบยังต้องมาจากข้อมูลอ้างอิงเท่านั้น\n5. อย่าเปลี่ยนตัวเลข วันเวลา หรือชื่อหน่วยงานที่ปรากฏในข้อมูลอ้างอิง ให้คงไว้ตามเดิมเป๊ะ ๆ\n\nข้อมูลอ้างอิงสำหรับคำถามนี้:\n{{ $json.contextText }}"
        }
      },
      "id": "46a0411f-46d3-5667-9c66-53ab7b4fd93d",
      "name": "AI_Answer",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "typeVersion": 1.7,
      "position": [
        80,
        260
      ]
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4o-mini"
        },
        "options": {
          "temperature": 0.2
        }
      },
      "id": "0fa14906-257f-5250-8c24-acc171cc1c03",
      "name": "OpenAI_Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "typeVersion": 1.2,
      "position": [
        20,
        480
      ]
    },
    {
      "parameters": {
        "sessionIdType": "customKey",
        "sessionKey": "={{ $json.sessionId }}",
        "contextWindowLength": 10
      },
      "id": "84f7deab-f594-5adb-a6ed-f4e7a525f434",
      "name": "Chat_Memory",
      "type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
      "typeVersion": 1.3,
      "position": [
        220,
        480
      ]
    },
    {
      "parameters": {
        "jsCode": "// ส่งคำตอบที่ระบบเตรียมไว้เอง (คำสั่งพิเศษ / ไม่พบข้อมูล) กลับไปที่แชท\nreturn [{ json: { output: $json.output, matched: $json.matched ?? false, ai: false } }];"
      },
      "id": "8ce15e57-9821-5891-8a2d-873631a6e45a",
      "name": "Direct_Reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        80,
        660
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "8b2b81ac-20bd-5907-b2d2-5197c2dcf5fc",
                    "leftValue": "={{ $binary.QA_File.fileExtension.toLowerCase() }}",
                    "rightValue": "^xlsx?$",
                    "operator": {
                      "type": "string",
                      "operation": "regex"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "excel"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "2fbcc149-1d14-59bf-b080-9feae62e4332",
                    "leftValue": "={{ $binary.QA_File.fileExtension.toLowerCase() }}",
                    "rightValue": "^pdf$",
                    "operator": {
                      "type": "string",
                      "operation": "regex"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "pdf"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "b71f4e08-aa90-5942-964d-f922a2f902aa",
                    "leftValue": "={{ $binary.QA_File.fileExtension.toLowerCase() }}",
                    "rightValue": "^docx$",
                    "operator": {
                      "type": "string",
                      "operation": "regex"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "docx"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "6283a70e-3544-5ea3-b5fa-b0773bcd0994",
                    "leftValue": "={{ $binary.QA_File.fileExtension.toLowerCase() }}",
                    "rightValue": "^(txt|md|markdown|html?|htm)$",
                    "operator": {
                      "type": "string",
                      "operation": "regex"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "text"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "990df507-43c5-572a-bbaa-26c273638909",
      "name": "Route_File_Type",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        -880,
        -420
      ]
    },
    {
      "parameters": {
        "operation": "pdf",
        "binaryPropertyName": "QA_File",
        "options": {}
      },
      "id": "b163e1d2-2f8f-5282-a6cd-04264aa52685",
      "name": "Extract_PDF",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        -620,
        -500
      ]
    },
    {
      "parameters": {
        "jsCode": "// ===== แปลง DOCX เป็นข้อความ (DOCX คือไฟล์ ZIP ที่ข้างในมี word/document.xml) =====\nlet zlib;\ntry {\n  zlib = require('zlib');\n} catch (e) {\n  throw new Error('อ่านไฟล์ DOCX ไม่ได้ เพราะ n8n ยังไม่อนุญาตให้ใช้โมดูล zlib — ตั้งค่า environment variable NODE_FUNCTION_ALLOW_BUILTIN=zlib แล้วรีสตาร์ท n8n หรือแปลงไฟล์เป็น PDF ก่อนอัปโหลด');\n}\n\nconst buf = await this.helpers.getBinaryDataBuffer(0, 'QA_File');\n\nfunction unzipEntry(b, wantName) {\n  let eocd = -1;\n  for (let i = b.length - 22; i >= 0 && i > b.length - 66000; i--) {\n    if (b.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }\n  }\n  if (eocd < 0) throw new Error('ไฟล์นี้ไม่ใช่ DOCX ที่ถูกต้อง');\n\n  const count = b.readUInt16LE(eocd + 10);\n  let ptr = b.readUInt32LE(eocd + 16);\n\n  for (let i = 0; i < count; i++) {\n    if (b.readUInt32LE(ptr) !== 0x02014b50) break;\n    const method   = b.readUInt16LE(ptr + 10);\n    const compSize = b.readUInt32LE(ptr + 20);\n    const nameLen  = b.readUInt16LE(ptr + 28);\n    const extraLen = b.readUInt16LE(ptr + 30);\n    const cmtLen   = b.readUInt16LE(ptr + 32);\n    const localOff = b.readUInt32LE(ptr + 42);\n    const name = b.slice(ptr + 46, ptr + 46 + nameLen).toString('utf8');\n\n    if (name === wantName) {\n      const lNameLen  = b.readUInt16LE(localOff + 26);\n      const lExtraLen = b.readUInt16LE(localOff + 28);\n      const start = localOff + 30 + lNameLen + lExtraLen;\n      const data = b.slice(start, start + compSize);\n      return method === 0 ? data : zlib.inflateRawSync(data);\n    }\n    ptr += 46 + nameLen + extraLen + cmtLen;\n  }\n  throw new Error('ไม่พบเนื้อหาเอกสารในไฟล์ DOCX');\n}\n\nconst xml = unzipEntry(buf, 'word/document.xml').toString('utf8');\n\nconst text = xml\n  .replace(/<w:tab[^>]*\\/>/g, '\\t')\n  .replace(/<w:br[^>]*\\/>/g, '\\n')\n  .replace(/<\\/w:p>/g, '\\n')\n  .replace(/<\\/w:tr>/g, '\\n')\n  .replace(/<\\/w:tc>/g, ' | ')\n  .replace(/<[^>]+>/g, '')\n  .replace(/&lt;/g, '<').replace(/&gt;/g, '>')\n  .replace(/&amp;/g, '&').replace(/&quot;/g, '\"').replace(/&apos;/g, \"'\")\n  .replace(/\\n\\s*\\|/g, ' |')\n  .replace(/[ \\t]+\\n/g, '\\n')\n  .replace(/\\n{3,}/g, '\\n\\n')\n  .trim();\n\nlet fileName = 'เอกสาร Word';\ntry { fileName = $('Upload_Knowledge').first().binary.QA_File.fileName || fileName; } catch (e) {}\n\nreturn [{ json: { text, fileName } }];"
      },
      "id": "9ab41d17-5d66-53e4-ab74-01d2b95bddf3",
      "name": "Docx_To_Text",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -620,
        -340
      ]
    },
    {
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "QA_File",
        "destinationKey": "text",
        "options": {}
      },
      "id": "83ab206e-2ad1-5b00-9ce1-35dcc852c025",
      "name": "Extract_Text",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        -620,
        -180
      ]
    },
    {
      "parameters": {
        "jsCode": "// ===== แปลงแถวจากไฟล์ Excel/CSV เป็นรายการ Q&A =====\nconst rows = $input.all().map(i => i.json);\n\nlet source = 'ไฟล์ Excel';\ntry { source = $('Upload_Knowledge').first().binary.QA_File.fileName || source; } catch (e) {}\n\nfunction pick(row, names) {\n  for (const key of Object.keys(row)) {\n    const k = String(key).replace(/\\uFEFF/g, '').trim().toLowerCase();\n    if (names.includes(k)) {\n      const v = row[key];\n      if (v !== null && v !== undefined && String(v).trim() !== '') return String(v).trim();\n    }\n  }\n  return '';\n}\n\nconst items = [];\nlet skipped = 0;\nfor (const r of rows) {\n  const q = pick(r, ['question', 'คำถาม', 'q']);\n  const a = pick(r, ['answer', 'คำตอบ', 'a']);\n  if (!q || !a) { skipped++; continue; }\n  items.push({\n    type: 'qa',\n    question: q,\n    answer: a,\n    category: pick(r, ['category', 'หมวดหมู่', 'หมวด']) || 'ทั่วไป',\n    keywords: pick(r, ['keywords', 'keyword', 'คีย์เวิร์ด', 'คำค้น', 'tags']),\n    source\n  });\n}\n\nreturn [{ json: { items, skipped, source, kind: 'excel' } }];"
      },
      "id": "44d27c74-3000-5061-a76e-aaac30b4c3e0",
      "name": "Rows_To_QA",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -380,
        -660
      ]
    },
    {
      "parameters": {
        "jsCode": "// ===== ตัดข้อความยาวเป็นท่อน (chunk) แล้วเก็บในรูปแบบเดียวกับ Q&A =====\nconst MAX_CHARS = 700;   // ความยาวสูงสุดต่อท่อน\nconst OVERLAP   = 120;   // ตัวอักษรที่ให้ซ้อนทับกันระหว่างท่อน กันเนื้อหาขาดตอน\n\nlet raw = $json.text ?? $json.data ?? $json.content ?? '';\nraw = String(raw);\n\nlet source = $json.fileName || 'เอกสาร';\ntry {\n  source = $('Upload_Knowledge').first().binary.QA_File.fileName || source;\n} catch (e) {}\n\n// ถ้าเป็น HTML ให้ถอดแท็กออกก่อน\nif (/<\\s*(html|body|div|p|h[1-6]|table)\\b/i.test(raw)) {\n  raw = raw\n    .replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')\n    .replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')\n    .replace(/<\\/(p|div|li|tr|h[1-6])>/gi, '\\n')\n    .replace(/<br[^>]*>/gi, '\\n')\n    .replace(/<[^>]+>/g, ' ')\n    .replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>')\n    .replace(/&amp;/g, '&').replace(/&quot;/g, '\"').replace(/&#39;/g, \"'\");\n}\n\n// ถ้าเป็น Markdown ให้ถอดสัญลักษณ์ที่ไม่ใช่เนื้อหาออก\nraw = raw\n  .replace(/```[\\s\\S]*?```/g, ' ')\n  .replace(/^\\s{0,3}#{1,6}\\s*/gm, '')\n  .replace(/\\*\\*|__|\\*|`/g, '')\n  .replace(/^\\s*[-*+]\\s+/gm, '• ')\n  .replace(/\\r\\n?/g, '\\n')\n  .replace(/[ \\t]+/g, ' ')\n  .replace(/\\n{3,}/g, '\\n\\n')\n  .trim();\n\nif (!raw) {\n  return [{ json: { items: [], skipped: 0, source, kind: 'document',\n    warning: 'อ่านไฟล์ได้แต่ไม่พบข้อความ (อาจเป็น PDF ที่เป็นภาพสแกน ต้องทำ OCR ก่อน)' } }];\n}\n\n// แยกเป็นย่อหน้า แล้วประกอบกลับเป็นท่อนขนาดพอดี\nconst blocks = raw.split(/\\n{2,}|\\n(?=[•\\-\\d])/).map(b => b.trim()).filter(Boolean);\n\nconst chunks = [];\nlet cur = '';\nconst push = () => { if (cur.trim()) chunks.push(cur.trim()); cur = ''; };\n\nfor (let b of blocks) {\n  // ย่อหน้ายาวเกินไป ตัดย่อยด้วยความยาวคงที่\n  while (b.length > MAX_CHARS) {\n    push();\n    chunks.push(b.slice(0, MAX_CHARS));\n    b = b.slice(MAX_CHARS - OVERLAP);\n  }\n  if ((cur + '\\n' + b).length > MAX_CHARS) push();\n  cur = cur ? cur + '\\n' + b : b;\n}\npush();\n\n// บรรทัดสั้น ๆ ที่ขึ้นต้นท่อน ใช้เป็นหัวข้อของท่อนนั้น\nfunction titleOf(chunk, i) {\n  const first = chunk.split('\\n')[0].trim();\n  if (first.length <= 80) return first;\n  return first.slice(0, 60) + '…';\n}\n\nconst items = chunks.map((c, i) => ({\n  type: 'doc',\n  question: titleOf(c, i),\n  answer: c,\n  category: source,\n  keywords: '',\n  source,\n  part: `${i + 1}/${chunks.length}`\n}));\n\nreturn [{ json: { items, skipped: 0, source, kind: 'document', chars: raw.length } }];"
      },
      "id": "09c06ceb-7db4-5703-bd54-a40208e47db2",
      "name": "Text_To_Chunks",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -380,
        -340
      ]
    },
    {
      "parameters": {
        "jsCode": "let name = 'ไฟล์นี้';\ntry { name = $('Upload_Knowledge').first().binary.QA_File.fileName || name; } catch (e) {}\nreturn [{ json: { message:\n  `ไม่รองรับสกุลไฟล์ของ \"${name}\"\\n\\nรองรับเฉพาะ: .xlsx .xls (ตาราง Q&A) และ .pdf .docx .txt .md .html (เอกสารข้อความ)\\n\\nถ้าเป็นไฟล์อื่น แนะนำให้บันทึกเป็น PDF หรือ .txt ก่อนอัปโหลด`\n} }];"
      },
      "id": "63132f11-6d74-5361-b72d-2e93f8e1d8ca",
      "name": "Unsupported_File",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -380,
        -20
      ]
    }
  ],
  "connections": {
    "Store_QA": {
      "main": [
        [
          {
            "node": "Import_Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chatbot": {
      "main": [
        [
          {
            "node": "Find_Answer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find_Answer": {
      "main": [
        [
          {
            "node": "IF_Use_AI",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF_Use_AI": {
      "main": [
        [
          {
            "node": "AI_Answer",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Direct_Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI_Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI_Answer",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Chat_Memory": {
      "ai_memory": [
        [
          {
            "node": "AI_Answer",
            "type": "ai_memory",
            "index": 0
          }
        ]
      ]
    },
    "Upload_Knowledge": {
      "main": [
        [
          {
            "node": "Route_File_Type",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route_File_Type": {
      "main": [
        [
          {
            "node": "Extract_Excel",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Extract_PDF",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Docx_To_Text",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Extract_Text",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Unsupported_File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract_Excel": {
      "main": [
        [
          {
            "node": "Rows_To_QA",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract_PDF": {
      "main": [
        [
          {
            "node": "Text_To_Chunks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Docx_To_Text": {
      "main": [
        [
          {
            "node": "Text_To_Chunks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract_Text": {
      "main": [
        [
          {
            "node": "Text_To_Chunks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rows_To_QA": {
      "main": [
        [
          {
            "node": "Store_QA",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Text_To_Chunks": {
      "main": [
        [
          {
            "node": "Store_QA",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Unsupported_File": {
      "main": [
        [
          {
            "node": "Import_Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "timezone": "Asia/Bangkok"
  },
  "versionId": "c7fcb392-2c3b-4165-abac-11669bffcb86",
  "meta": {
    "instanceId": "",
    "templateCredsSetupCompleted": true
  },
  "tags": []
}