{
  "name": "PsyCardv2",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "psy-card",
        "options": {}
      },
      "id": "ca220db5-d665-4291-be92-ed75a1a23707",
      "name": "Webhook Trigger",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1,
      "position": [
        -352,
        80
      ]
    },
    {
      "parameters": {
        "jsCode": "// Accept input either from Webhook (body) or from direct Cron-generated JSON\nconst body = $json.body ?? $json;\nif (!body || !body.cards) {\n  throw new Error('Missing cards in input. Expected {cards, series_metadata}');\n}\n\nconst metadata = body.series_metadata || {};\nlet cards = Array.isArray(body.cards) ? body.cards : [];\n\nconst title = (metadata.title || metadata.series_title || 'Psychological Insight').trim();\n\nfunction today_ddmmyyyy() {\n  const d = new Date();\n  const dd = String(d.getDate()).padStart(2, '0');\n  const mm = String(d.getMonth() + 1).padStart(2, '0');\n  const yyyy = String(d.getFullYear());\n  return `${dd}-${mm}-${yyyy}`;\n}\n\nfunction nonEmpty(s) {\n  return typeof s === 'string' && s.trim().length > 0;\n}\n\nfunction defaultPrompt(text) {\n  const t = String(text || title).replace(/\\s+/g, ' ').trim();\n  // English only-ish prompt; no text/logo/watermark\n  return `Cinematic portrait 9:16, symbolic psychological concept, ${t}, moody lighting, realistic, shallow depth of field, no text, no logo, no watermark`;\n}\n\n// Ensure there is a proper cover at index 0\nconst hasCover0 = cards.some(c => (c?.card_index === 0) && (String(c?.type || '').toLowerCase() === 'cover'));\n\nif (!hasCover0) {\n  const first = cards[0] || {};\n  const cover = {\n    type: 'cover',\n    card_index: 0,\n    headline: title,\n    content: (title || 'M\u1ed9t g\u00f3c nh\u00ecn t\u00e2m l\u00fd gi\u00fap b\u1ea1n hi\u1ec3u m\u00ecnh r\u00f5 h\u01a1n m\u1ed7i ng\u00e0y.'),\n    image_prompt: nonEmpty(first.image_prompt) ? first.image_prompt : defaultPrompt(title),\n  };\n\n  // Shift existing cards to start from 1\n  const shifted = cards.map((c, i) => {\n    const idx = (typeof c.card_index === 'number' && Number.isFinite(c.card_index)) ? c.card_index : i;\n    return {\n      ...c,\n      type: 'content',\n      card_index: idx + 1,\n    };\n  });\n\n  cards = [cover, ...shifted];\n}\n\n// Final normalize: sequential card_index, cover first, and ensure image_prompt exists\ncards = cards\n  .slice()\n  .sort((a, b) => (a.card_index ?? 0) - (b.card_index ?? 0))\n  .map((c, i) => {\n    const isCover = i === 0;\n    const headline = (isCover ? title : (c.headline || title));\n    const image_prompt = nonEmpty(c.image_prompt) ? c.image_prompt : defaultPrompt(headline);\n\n    return {\n      ...c,\n      type: isCover ? 'cover' : 'content',\n      card_index: i,\n      headline: headline,\n      content: (c.content ?? ''),\n      image_prompt,\n    };\n  });\n\nreturn {\n  cards_list: cards,\n  total_count: cards.length,\n  series_date: nonEmpty(metadata.date) ? metadata.date : today_ddmmyyyy(),\n  series_title: title,\n};\n"
      },
      "id": "448342ea-f218-45c4-b5e8-2005e4113a38",
      "name": "Parse Psy",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -128,
        -128
      ]
    },
    {
      "parameters": {
        "jsCode": "const data = $node['Build Series Context'].json;\n\nfunction cleanStepHeadline(headline) {\n  let s = (headline || '').trim();\n  // Remove common step prefixes: 'B\u01b0\u1edbc 1', 'B\u01b0\u1edbc 1:', 'Step 1', '1.', '1)', '1 -'\n  s = s.replace(/^(?:b\u01b0\u1edbc|step)\\s*\\d+\\s*[:\\-\\.\\)]*\\s*/i, '');\n  s = s.replace(/^\\d+\\s*[:\\-\\.\\)]\\s*/i, '');\n  // Also remove 'B\u01b0\u1edbc:' without number\n  s = s.replace(/^b\u01b0\u1edbc\\s*[:\\-\\.\\)]\\s*/i, '');\n  return s.trim();\n}\n\nfunction toFolderDate(dateStr) {\n  const s = String(dateStr || '').trim();\n  // Supports: dd-mm-YYYY, dd/mm/YYYY, YYYY-mm-dd, YYYY/mm/dd\n  let dd, mm, yyyy;\n\n  let m = s.match(/^(\\d{2})[\\/-](\\d{2})[\\/-](\\d{4})$/);\n  if (m) {\n    dd=m[1]; mm=m[2]; yyyy=m[3];\n    return `${yyyy}-${mm}-${dd}`;\n  }\n\n  m = s.match(/^(\\d{4})[\\/-](\\d{2})[\\/-](\\d{2})$/);\n  if (m) {\n    yyyy=m[1]; mm=m[2]; dd=m[3];\n    return `${yyyy}-${mm}-${dd}`;\n  }\n\n  // Fallback to today\n  const d = new Date();\n  dd = String(d.getDate()).padStart(2,'0');\n  mm = String(d.getMonth()+1).padStart(2,'0');\n  yyyy = String(d.getFullYear());\n  return `${yyyy}-${mm}-${dd}`;\n}\n\nconst global_date_folder = toFolderDate(data.series_date);\n\nreturn data.cards_list.map((card, idx) => {\n  const isCover = idx === 0 || card.type === 'cover' || card.card_index === 0;\n  const cleaned = (!isCover) ? cleanStepHeadline(card.headline) : (card.headline || data.series_title);\n  return {\n    json: {\n      ...card,\n      headline: cleaned,\n      global_date: data.series_date,\n      global_date_folder,\n      global_title: data.series_title,\n      total_count: data.total_count,\n      folderId: data.folderId,\n    }\n  };\n});\n"
      },
      "id": "03f96ffd-e851-4e26-94b4-08d9b20be4a8",
      "name": "Flatten Items",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1888,
        -128
      ]
    },
    {
      "parameters": {
        "batchSize": 1,
        "options": {}
      },
      "id": "33b1c100-5ffa-4efa-9a62-83767edb185f",
      "name": "Split In Batches",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 1,
      "position": [
        2112,
        -128
      ]
    },
    {
      "parameters": {
        "command": "=mkdir -p /tmp/psy_{{ $execution.id }} && python3 /home/vietnx/WorkSpace/PsychologicalCard/scripts/generate_psy_image.py /tmp/psy_{{ $execution.id }}/raw_{{ $node[\"Split In Batches\"].json.card_index }}.png \"{{ $node[\"Split In Batches\"].json.image_prompt.replace(/\\\"/g, '\\\\\\\"') }}\""
      },
      "id": "6e7df629-cff2-4fe3-9ed3-b9812e0438eb",
      "name": "Gen AI Image",
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        2336,
        -288
      ]
    },
    {
      "parameters": {
        "command": "=mkdir -p /tmp/psy_{{ $execution.id }} && cat <<EOF > /tmp/psy_{{ $execution.id }}/card_{{ $node[\"Split In Batches\"].json.card_index }}.json\n{{ JSON.stringify($node[\"Split In Batches\"].json) }}\nEOF"
      },
      "id": "d152fc81-e18c-4a3b-b99a-7523350bc9db",
      "name": "Write Temp JSON",
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        2560,
        -288
      ]
    },
    {
      "parameters": {
        "command": "=mkdir -p /home/vietnx/WorkSpace/PsychologicalCard/data/{{ $node[\"Split In Batches\"].json.global_date_folder }} && mkdir -p /home/vietnx/WorkSpace/PsychologicalCard/data/{{ $node[\"Split In Batches\"].json.global_date_folder }}/binary-{{ $execution.id }} && mkdir -p /home/vietnx/.n8n-files/PsychologicalCard/data/{{ $node[\"Split In Batches\"].json.global_date_folder }} && mkdir -p /tmp/psy_{{ $execution.id }} && python3 /home/vietnx/WorkSpace/PsychologicalCard/scripts/generate_card_v2.py --template /home/vietnx/WorkSpace/PsychologicalCard/templates/psychological_template.json --image /tmp/psy_{{ $execution.id }}/raw_{{ $node[\"Split In Batches\"].json.card_index }}.png --output /home/vietnx/WorkSpace/PsychologicalCard/data/{{ $node[\"Split In Batches\"].json.global_date_folder }}/card_{{ $node[\"Split In Batches\"].json.card_index }}.png --json_input /tmp/psy_{{ $execution.id }}/card_{{ $node[\"Split In Batches\"].json.card_index }}.json && cp -f /home/vietnx/WorkSpace/PsychologicalCard/data/{{ $node[\"Split In Batches\"].json.global_date_folder }}/card_{{ $node[\"Split In Batches\"].json.card_index }}.png /home/vietnx/WorkSpace/PsychologicalCard/data/{{ $node[\"Split In Batches\"].json.global_date_folder }}/binary-{{ $execution.id }}/card_{{ $node[\"Split In Batches\"].json.card_index }}.png && cp -f /home/vietnx/WorkSpace/PsychologicalCard/data/{{ $node[\"Split In Batches\"].json.global_date_folder }}/card_{{ $node[\"Split In Batches\"].json.card_index }}.png /home/vietnx/.n8n-files/PsychologicalCard/data/{{ $node[\"Split In Batches\"].json.global_date_folder }}/card_{{ $node[\"Split In Batches\"].json.card_index }}.png"
      },
      "id": "4571a16d-7813-4833-aab7-92deda53846a",
      "name": "Render Final Card",
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        2784,
        -288
      ]
    },
    {
      "parameters": {
        "jsCode": "const totalRequired = $node[\"Parse Psy\"].json.total_count;\nconst doneCount = $node[\"Split In Batches\"].context.currentRunIndex + 1;\nconst desc = $node[\"Parse Psy\"].json.cards_list[0].content;\n\nreturn {\n  isFinished: doneCount >= totalRequired,\n  doneCount,\n  totalRequired,\n  series_title: $node[\"Parse Psy\"].json.series_title,\n  desc,\n  series_date: $node[\"Parse Psy\"].json.series_date\n};"
      },
      "id": "fa782e14-fab4-470f-89e9-a133c284a9e0",
      "name": "Check Completion",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3680,
        -192
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.isFinished }}",
              "value2": true
            }
          ]
        }
      },
      "id": "c7dc32bc-af19-4c62-88ca-18a0efc3e267",
      "name": "If Finished",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        3904,
        -128
      ]
    },
    {
      "parameters": {
        "chatId": "1230031341",
        "text": "=\u2705 [Psychological Card] HO\u00c0N T\u1ea4T B\u1ed8 \u1ea2NH!\n\ud83d\udccc Series: `{{ $node[\"Build Series Context\"].json.series_title }}`\n\ud83d\udccb Desc: `{{ $node[\"Build Series Context\"].json.cards_list[0].content }}\n#psychology #better #daily #selfimprovement #trending`\n\ud83d\udcc5 Ng\u00e0y: {{ $node[\"Build Series Context\"].json.series_date }}\n\ud83d\udcf8 T\u1ed5ng s\u1ed1: {{ $node[\"Build Series Context\"].json.total_count }} cards\n\ud83d\ude80 Google Drive: https://drive.google.com/drive/u/0/folders/{{ $node[\"Pick FolderId\"].json.folderId }}",
        "additionalFields": {}
      },
      "id": "284471ba-f0a0-4b74-9f82-c1af4ef2a593",
      "name": "Telegram Notify Final",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.1,
      "position": [
        4352,
        -128
      ],
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "chatId": "1230031341",
        "text": "=\ud83d\ude80 [Psychological Card] B\u1eaeT \u0110\u1ea6U QUY TR\u00ccNH\n\ud83d\udccc Ch\u1ee7 \u0111\u1ec1: `{{ $json.series_title }}`\n\ud83d\udccb M\u00f4 t\u1ea3: `{{ $json.cards_list[0].content }}\n#psychology #better #daily #selfimprovement #trending`\n\ud83d\udcf8 D\u1ef1 ki\u1ebfn: {{ $json.total_count }} cards\n\u23f3 H\u1ec7 th\u1ed1ng \u0111ang ti\u1ebfn h\u00e0nh render, vui l\u00f2ng ch\u1edd...",
        "additionalFields": {}
      },
      "id": "a1528da3-e0a6-4432-ac90-1361c5fe9ab8",
      "name": "Telegram Notify Start",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1,
      "position": [
        1664,
        -128
      ],
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "filePath": "=/home/vietnx/.n8n-files/PsychologicalCard/data/{{ $node[\"Split In Batches\"].json.global_date_folder }}/card_{{ $node[\"Split In Batches\"].json.card_index }}.png"
      },
      "id": "0a87eaa8-c743-428e-8967-e031aed5ec7c",
      "name": "Read Card Image",
      "type": "n8n-nodes-base.readBinaryFile",
      "typeVersion": 1,
      "position": [
        3008,
        -288
      ]
    },
    {
      "parameters": {
        "resource": "fileFolder",
        "queryString": "={{ $node['Parse Psy'].json.series_date }}",
        "limit": 5,
        "filter": {
          "driveId": {
            "mode": "list",
            "value": "My Drive"
          },
          "folderId": {
            "mode": "id",
            "value": "1fXWSPyFjoUEIccdZCkIfS-tDTp4eHVcU"
          },
          "whatToSearch": "folders",
          "includeTrashed": false
        },
        "options": {}
      },
      "id": "e2d92193-8a6e-427a-a903-318f750aea4d",
      "name": "Find Date Folder",
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        96,
        -128
      ],
      "alwaysOutputData": true,
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.hasFolder }}",
              "value2": true
            }
          ]
        }
      },
      "id": "a5fa951c-3458-4f40-bc76-2048fe47ad1f",
      "name": "Has Date Folder?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        544,
        -128
      ]
    },
    {
      "parameters": {
        "resource": "folder",
        "name": "={{ $node[\"Parse Psy\"].json.series_date }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "folderId": {
          "mode": "id",
          "value": "1fXWSPyFjoUEIccdZCkIfS-tDTp4eHVcU"
        },
        "options": {
          "simplifyOutput": true
        }
      },
      "id": "21a6a524-2c67-4661-9917-cb013575154c",
      "name": "Create Date Folder",
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        768,
        -80
      ],
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Folder created by Google Drive create\nreturn [{ json: { folderId: $json.id } }];\n"
      },
      "id": "6e82ee57-17de-49a3-9943-97f269151fd3",
      "name": "FolderId From Create",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        992,
        -80
      ]
    },
    {
      "parameters": {
        "mode": "mergeByIndex",
        "join": "enrichInput1"
      },
      "id": "bc068be9-3711-49ab-9537-d584e63647f3",
      "name": "Attach FolderId",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 1,
      "position": [
        3232,
        -192
      ],
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "name": "={{ 'card_' + $node[\"Split In Batches\"].json.card_index + '.png' }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "folderId": {
          "mode": "id",
          "value": "={{ $json.folderId }}"
        },
        "options": {}
      },
      "id": "4a97b80b-8e9a-4941-b01c-75b621a8d794",
      "name": "Upload to Drive",
      "type": "n8n-nodes-base.googleDrive",
      "typeVersion": 3,
      "position": [
        3456,
        -192
      ],
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "triggerTimes": {
          "item": [
            {
              "mode": "custom",
              "cronExpression": "30 7 * * *"
            }
          ]
        }
      },
      "name": "Cron Trigger (Theory)",
      "type": "n8n-nodes-base.cron",
      "typeVersion": 1,
      "position": [
        -2144,
        -128
      ],
      "id": "f8e87875-8b48-43de-8f7b-80289be056b4"
    },
    {
      "parameters": {
        "command": "cat /home/vietnx/WorkSpace/PsychologicalCard/topics_history_theory.md 2>/dev/null || true"
      },
      "name": "Read Topic History (Theory)",
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        -1920,
        -128
      ],
      "id": "11d187c7-ea9c-44f8-8567-9f1d7499bc46"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://ai-proxy.vietnx.io.vn/v1/chat/completions",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer nguyenviet02"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ ({\n  model: 'gpt-5.2',\n  temperature: 0.7,\n  max_tokens: 2600,\n  messages: [\n    { role: 'system', content: 'You generate Psychological Card THEORY series in natural, high-quality Vietnamese.\\n\\nN\u1ed9i dung: Gi\u1ea3i th\u00edch kh\u00e1i ni\u1ec7m, hi\u1ec7u \u1ee9ng ho\u1eb7c hi\u1ec7n t\u01b0\u1ee3ng t\u00e2m l\u00fd h\u1ecdc chuy\u00ean s\u00e2u.\\n\\nOutput MUST be valid JSON only (no markdown), matching schema:\\n{series_metadata:{title,date,source_url}, cards:[{type,card_index,headline,content,image_prompt}]}\\n\\nStrict rules:\\n- cards[0] MUST be {type: \"cover\", card_index:0, headline:series_metadata.title, content:10-15 word hook}.\\n- Cards 1..N MUST be {type: \"content\", card_index:1..N} explaining the psychological theory/concept.\\n- Each content card: 25-35 Vietnamese words.\\n- Plain text only: no ** __ # ` [ ] ( ) and no bullets - or \u2022. No emojis.\\n- 5-7 cards total.\\n- image_prompt: English cinematic, portrait 9:16, no text/logo/watermark.\\n- Topic must be NEW vs provided history (case-insensitive; ignore (Duplicate) suffix).\\n- Return ONLY JSON.\\n\\nVietnamese phrasing quality (VERY IMPORTANT):\\n- Write like a seasoned Vietnamese psychologist or academic: sophisticated, clear, and engaging.\\n- Cover hook: Must be a thought-provoking question or a powerful statement that makes people want to swipe.\\n- Content card headlines: Short, impactful phrases (2-6 words) that summarize the core point of the card.\\n- Avoid academic jargon without explanation; make complex concepts accessible but remain authoritative.\\n- Do NOT use overly formal Sino-Vietnamese chains where simple, elegant Vietnamese works better.' },\n    { role: 'user', content: 'Topic history table (markdown):\\n\\n' + ($node['Read Topic History (Theory)'].json.stdout || '') + '\\n\\nAttempt: ' + String($node['Attempt Counter (Theory)'].json.attempt) + '\\nGenerate one THEORY series for today date in dd-mm-YYYY format. source_url can be empty string. Return JSON only.' }\n  ]\n}) }}",
        "options": {}
      },
      "name": "LLM Generate Psy JSON (Theory)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4,
      "position": [
        -1472,
        -224
      ],
      "id": "58adb3f0-f57b-4368-8575-ce99d489df08"
    },
    {
      "parameters": {
        "jsCode": "// OpenAI compat response: {choices:[{message:{content:'...'}}]}\nconst content = $json.choices?.[0]?.message?.content;\nif (!content) throw new Error('LLM response missing choices[0].message.content');\n\nlet text = content.trim();\n// Strip common code fences safely (no regex with literal newlines)\nif (text.startsWith('```')) {\n  text = text.replace(/^```json\\s*/i, '').replace(/^```\\s*/i, '');\n  if (text.endsWith('```')) text = text.slice(0, -3);\n  text = text.trim();\n}\n\ntry {\n  return JSON.parse(text);\n} catch (e) {\n  throw new Error('LLM returned non-JSON content. First 200 chars: ' + text.slice(0, 200));\n}\n"
      },
      "name": "Extract Body JSON",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1248,
        -224
      ],
      "id": "391d64e0-ce3e-4769-a6dd-313a673d76ef"
    },
    {
      "parameters": {
        "command": "=python3 /home/vietnx/WorkSpace/PsychologicalCard/scripts/update_topics_history.py --type Theory --topic \"{{ $json.series_metadata.title.replace(/\"/g,'\\\\\"') }}\""
      },
      "name": "Update Topic History (Theory)",
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        -576,
        -192
      ],
      "id": "d225b3c3-9908-4b2a-8f2a-142463bef614"
    },
    {
      "parameters": {
        "jsCode": "const target = $node[\"Parse Psy\"].json.series_date;\nconst items = $input.all().map(i => i.json);\n\nconst exact = items.find(x => x?.name === target) || null;\nreturn [{ json: { hasFolder: !!exact, folderId: exact?.id ?? null, folderName: exact?.name ?? null } }];"
      },
      "name": "Found Folder Info",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        320,
        -128
      ],
      "id": "f0365553-aea6-4acf-8739-8aaa31b80f30"
    },
    {
      "parameters": {
        "jsCode": "return [{ json: { folderId: $json.folderId } }];"
      },
      "name": "FolderId From Found",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        992,
        -272
      ],
      "id": "80d056fd-c75e-4653-9380-e60c14918138"
    },
    {
      "parameters": {
        "mode": "passThrough"
      },
      "name": "Pick FolderId",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 1,
      "position": [
        1216,
        -128
      ],
      "id": "ac4da03e-ce29-4013-b032-ca90572e5aed",
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// Input: { folderId }\nconst folderId = $json.folderId;\nif (!folderId) throw new Error('Missing folderId');\nconst series = $node['Parse Psy'].json;\nreturn {\n  ...series,\n  folderId,\n};\n"
      },
      "name": "Build Series Context",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1440,
        -128
      ],
      "id": "532db6c3-3c25-4cd7-8e03-78510c4e2e7c"
    },
    {
      "parameters": {
        "mode": "passThrough"
      },
      "name": "Gate After History",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 1,
      "position": [
        -352,
        -128
      ],
      "id": "16b05b39-6020-45c4-b5f8-79dce2204042"
    },
    {
      "parameters": {
        "triggerTimes": {
          "item": [
            {
              "mode": "custom",
              "cronExpression": "0 7 * * *"
            }
          ]
        }
      },
      "name": "Cron Trigger (Actionable)",
      "type": "n8n-nodes-base.cron",
      "typeVersion": 1,
      "position": [
        -2144,
        -464
      ],
      "id": "5e48be6e-20e6-4d2d-8105-ad96e1b6bfe5"
    },
    {
      "parameters": {
        "command": "cat /home/vietnx/WorkSpace/PsychologicalCard/topics_history_actionable.md 2>/dev/null || true"
      },
      "name": "Read Topic History (Actionable)",
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        -1920,
        -464
      ],
      "id": "ab3300c1-4fd3-414a-9d13-cbee86d1a91a"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://ai-proxy.vietnx.io.vn/v1/chat/completions",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer nguyenviet02"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ ({\n  model: 'gpt-5.2',\n  temperature: 0.7,\n  max_tokens: 2600,\n  messages: [\n    { role: 'system', content: 'You generate Psychological Card ACTIONABLE series in natural, high-quality Vietnamese.\\n\\nOutput MUST be valid JSON only (no markdown), matching schema:\\n{series_metadata:{title,date,source_url}, cards:[{type,card_index,headline,content,image_prompt}]}\\n\\nStrict rules:\\n- cards[0] MUST be {type:\\\"cover\\\", card_index:0, headline:series_metadata.title, content:10-15 word hook}\\n- Cards 1..N MUST be {type:\\\"content\\\", card_index:1..N} with practical step-by-step guidance\\n- Each content card: 25-35 Vietnamese words\\n- Plain text only: no ** __ # ` [ ] ( ) and no bullets - or \u2022. No emojis.\\n- 5-7 cards total\\n- image_prompt: English cinematic, portrait 9:16, no text/logo/watermark\\n- Topic must be NEW vs provided history (case-insensitive; ignore (Duplicate) suffix)\\n- Return ONLY JSON\\n\\nVietnamese phrasing quality (VERY IMPORTANT):\\n- Write like a seasoned Vietnamese psychologist/coach: smooth, idiomatic, not literal/robotic.\\n- Avoid awkward noun stacks like: \\\"6 Th\u1ef1c H\u00e0nh Ph\u1ee5c H\u1ed3i...\\\". Use natural templates:\\n  \\\"6 b\u01b0\u1edbc ph\u1ee5c h\u1ed3i khi ...\\\", \\\"6 c\u00e1ch ...\\\", \\\"6 vi\u1ec7c b\u1ea1n c\u00f3 th\u1ec3 l\u00e0m khi ...\\\", \\\"6 chi\u1ebfn l\u01b0\u1ee3c ...\\\".\\n- Cover title MUST start with a number + classifier when it is a list (e.g., \\\"6 b\u01b0\u1edbc...\\\", \\\"5 c\u00e1ch...\\\").\\n- For content card headlines: short verb-led phrases (2-6 words), natural Vietnamese, no awkward calques.\\n  Examples: \\\"H\u1ea1 nh\u1ecbp c\u01a1 th\u1ec3\\\", \\\"G\u1ecdi t\u00ean c\u1ea3m x\u00fac\\\", \\\"Gi\u1ea3m k\u1ef3 v\u1ecdng\\\", \\\"L\u00e0m nh\u1ecf vi\u1ec7c\\\".\\n- Do NOT include step numbering or the word \\\"B\u01b0\u1edbc\\\"/\\\"Step\\\" in content card headlines (template will auto-number).\\n- Do NOT use overly formal Sino-Vietnamese chains; prefer simple Vietnamese.\\n' },\n    { role: 'user', content: 'Topic history table (markdown):\\n\\n' + ($node['Read Topic History (Actionable)'].json.stdout || '') + '\\n\\nAttempt: ' + String($node['Attempt Counter (Actionable)'].json.attempt) + '\\nGenerate one ACTIONABLE series for today date in dd-mm-YYYY format. source_url can be empty string. Return JSON only.' }\n  ]\n}) }}",
        "options": {}
      },
      "name": "LLM Generate Psy JSON (Actionable)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4,
      "position": [
        -1472,
        -528
      ],
      "id": "0ead250a-b0da-4a18-bc5f-9cee0b8360f9"
    },
    {
      "parameters": {
        "jsCode": "// same as Extract Body JSON\nconst content = $json.choices?.[0]?.message?.content;\nif (!content) throw new Error('LLM response missing choices[0].message.content');\n\nlet text = content.trim();\nif (text.startsWith('```')) {\n  text = text.replace(/^```json\\s*/i, '').replace(/^```\\s*/i, '');\n  if (text.endsWith('```')) text = text.slice(0, -3);\n  text = text.trim();\n}\n\ntry {\n  return JSON.parse(text);\n} catch (e) {\n  throw new Error('LLM returned non-JSON content. First 200 chars: ' + text.slice(0, 200));\n}\n"
      },
      "name": "Extract Body JSON (Actionable)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1248,
        -528
      ],
      "id": "1e6f2a47-5709-4f4d-ac34-5a1d3831f895"
    },
    {
      "parameters": {
        "command": "=python3 /home/vietnx/WorkSpace/PsychologicalCard/scripts/update_topics_history.py --type Actionable --topic \"{{ $json.series_metadata.title.replace(/\"/g,'\\\\\"') }}\""
      },
      "name": "Update Topic History (Actionable)",
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        -576,
        -384
      ],
      "id": "7c8c551e-74ee-4b89-bd83-e7fa73ecf905"
    },
    {
      "parameters": {
        "mode": "passThrough"
      },
      "name": "Gate After History (Actionable)",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 1,
      "position": [
        -352,
        -464
      ],
      "id": "c70307ff-e049-4711-8040-bf9701bd89fc"
    },
    {
      "parameters": {
        "jsCode": "const prev = $json.attempt || 0;\nreturn { attempt: prev + 1 };"
      },
      "name": "Attempt Counter (Theory)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1696,
        -128
      ],
      "id": "66aac715-5c85-4dd9-8a6b-24d0bec46c01"
    },
    {
      "parameters": {
        "jsCode": "const prev = $json.attempt || 0;\nreturn { attempt: prev + 1 };"
      },
      "name": "Attempt Counter (Actionable)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1696,
        -464
      ],
      "id": "8769e127-ffd5-40c7-8e50-d0a2a3def340"
    },
    {
      "parameters": {
        "jsCode": "// Validate uniqueness by SERIES TITLE only (not per-card)\nconst attempt = $node['Attempt Counter (Theory)'].json.attempt;\nconst payload = $json;\n\nfunction normalizeTopic(topic) {\n  return String(topic || '')\n    .trim()\n    .toLowerCase()\n    .replace(/\\s*\\(duplicate\\)\\s*$/i, '')\n    .replace(/\\s*\\[duplicate\\]\\s*$/i, '')\n    .replace(/\\s*-\\s*duplicate\\s*$/i, '')\n    .replace(/\\s+/g, ' ');\n}\n\nfunction extractTopicsFromMarkdown(md) {\n  const topics = new Set();\n  const lines = String(md || '').split('\\n');\n  for (let line of lines) {\n    if (line.endsWith('\\r')) line = line.slice(0, -1);\n    const s = line.trim();\n    if (!s.startsWith('|')) continue;\n    if (s.startsWith('| Date ') || s.startsWith('|------')) continue;\n    const parts = s.replace(/^\\|/,'').replace(/\\|$/,'').split('|').map(p => p.trim());\n    if (parts.length < 3) continue;\n    const topic = parts[2];\n    const n = normalizeTopic(topic);\n    if (n) topics.add(n);\n  }\n  return topics;\n}\n\nconst title = payload?.series_metadata?.title || payload?.series_metadata?.series_title || '';\nconst titleN = normalizeTopic(title);\nconst historyMd = $node['Read Topic History (Theory)'].json.stdout || '';\nconst existing = extractTopicsFromMarkdown(historyMd);\n\nconst isDuplicate = titleN && existing.has(titleN);\nif (isDuplicate) {\n  if (attempt >= 4) {\n    throw new Error(`Duplicate TITLE detected (Theory) after ${attempt} attempts: ${title}`);\n  }\n  return { __duplicate: true, attempt, title };\n}\n\nreturn { ...payload, __duplicate: false, attempt };\n"
      },
      "name": "Validate Uniqueness (Theory)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1024,
        -224
      ],
      "id": "acb77352-efdd-42da-a23b-80a4daa66061"
    },
    {
      "parameters": {
        "jsCode": "// Validate uniqueness by SERIES TITLE only (not per-card)\nconst attempt = $node['Attempt Counter (Actionable)'].json.attempt;\nconst payload = $json;\n\nfunction normalizeTopic(topic) {\n  return String(topic || '')\n    .trim()\n    .toLowerCase()\n    .replace(/\\s*\\(duplicate\\)\\s*$/i, '')\n    .replace(/\\s*\\[duplicate\\]\\s*$/i, '')\n    .replace(/\\s*-\\s*duplicate\\s*$/i, '')\n    .replace(/\\s+/g, ' ');\n}\n\nfunction extractTopicsFromMarkdown(md) {\n  const topics = new Set();\n  const lines = String(md || '').split('\\n');\n  for (let line of lines) {\n    if (line.endsWith('\\r')) line = line.slice(0, -1);\n    const s = line.trim();\n    if (!s.startsWith('|')) continue;\n    if (s.startsWith('| Date ') || s.startsWith('|------')) continue;\n    const parts = s.replace(/^\\|/,'').replace(/\\|$/,'').split('|').map(p => p.trim());\n    if (parts.length < 3) continue;\n    const topic = parts[2];\n    const n = normalizeTopic(topic);\n    if (n) topics.add(n);\n  }\n  return topics;\n}\n\nconst title = payload?.series_metadata?.title || payload?.series_metadata?.series_title || '';\nconst titleN = normalizeTopic(title);\nconst historyMd = $node['Read Topic History (Actionable)'].json.stdout || '';\nconst existing = extractTopicsFromMarkdown(historyMd);\n\nconst isDuplicate = titleN && existing.has(titleN);\nif (isDuplicate) {\n  if (attempt >= 4) {\n    throw new Error(`Duplicate TITLE detected (Actionable) after ${attempt} attempts: ${title}`);\n  }\n  return { __duplicate: true, attempt, title };\n}\n\nreturn { ...payload, __duplicate: false, attempt };\n"
      },
      "name": "Validate Uniqueness (Actionable)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1024,
        -528
      ],
      "id": "07132542-01f1-477b-8af6-cdf9194b7f83"
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.__duplicate }}",
              "value2": true
            }
          ]
        }
      },
      "name": "If Duplicate? (Theory)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        -800,
        -128
      ],
      "id": "f015e3de-17d7-4579-b159-10993da693e8"
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.__duplicate }}",
              "value2": true
            }
          ]
        }
      },
      "name": "If Duplicate? (Actionable)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        -800,
        -464
      ],
      "id": "260f29a4-8a11-470f-bc2e-659e3b1e67a0"
    },
    {
      "parameters": {
        "command": "=rm -rf -- /tmp/psy_{{ $execution.id }} || true"
      },
      "id": "cleanup-temp-psy-q9oDF42tvdopxJT5",
      "name": "Cleanup Temp",
      "type": "n8n-nodes-base.executeCommand",
      "typeVersion": 1,
      "position": [
        4128,
        -128
      ],
      "continueOnFail": true
    }
  ],
  "connections": {
    "Webhook Trigger": {
      "main": [
        [
          {
            "node": "Parse Psy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Psy": {
      "main": [
        [
          {
            "node": "Find Date Folder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flatten Items": {
      "main": [
        [
          {
            "node": "Split In Batches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split In Batches": {
      "main": [
        [
          {
            "node": "Gen AI Image",
            "type": "main",
            "index": 0
          },
          {
            "node": "Attach FolderId",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Gen AI Image": {
      "main": [
        [
          {
            "node": "Write Temp JSON",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write Temp JSON": {
      "main": [
        [
          {
            "node": "Render Final Card",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Render Final Card": {
      "main": [
        [
          {
            "node": "Read Card Image",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Completion": {
      "main": [
        [
          {
            "node": "If Finished",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Finished": {
      "main": [
        [
          {
            "node": "Cleanup Temp",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Split In Batches",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Telegram Notify Start": {
      "main": [
        [
          {
            "node": "Flatten Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Card Image": {
      "main": [
        [
          {
            "node": "Attach FolderId",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find Date Folder": {
      "main": [
        [
          {
            "node": "Found Folder Info",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Date Folder?": {
      "main": [
        [
          {
            "node": "FolderId From Found",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Create Date Folder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Date Folder": {
      "main": [
        [
          {
            "node": "FolderId From Create",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to Drive": {
      "main": [
        [
          {
            "node": "Check Completion",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cron Trigger (Theory)": {
      "main": [
        [
          {
            "node": "Read Topic History (Theory)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Topic History (Theory)": {
      "main": [
        [
          {
            "node": "Attempt Counter (Theory)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM Generate Psy JSON (Theory)": {
      "main": [
        [
          {
            "node": "Extract Body JSON",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Body JSON": {
      "main": [
        [
          {
            "node": "Validate Uniqueness (Theory)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Topic History (Theory)": {
      "main": [
        [
          {
            "node": "Gate After History",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Ensure FolderId": {
      "main": [
        [
          {
            "node": "Upload to Drive",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Found Folder Info": {
      "main": [
        [
          {
            "node": "Has Date Folder?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FolderId From Found": {
      "main": [
        [
          {
            "node": "Pick FolderId",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FolderId From Create": {
      "main": [
        [
          {
            "node": "Pick FolderId",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick FolderId": {
      "main": [
        [
          {
            "node": "Build Series Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Series Context": {
      "main": [
        [
          {
            "node": "Telegram Notify Start",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attach FolderId": {
      "main": [
        [
          {
            "node": "Upload to Drive",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gate After History": {
      "main": [
        [
          {
            "node": "Parse Psy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cron Trigger (Actionable)": {
      "main": [
        [
          {
            "node": "Read Topic History (Actionable)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Topic History (Actionable)": {
      "main": [
        [
          {
            "node": "Attempt Counter (Actionable)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM Generate Psy JSON (Actionable)": {
      "main": [
        [
          {
            "node": "Extract Body JSON (Actionable)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Body JSON (Actionable)": {
      "main": [
        [
          {
            "node": "Validate Uniqueness (Actionable)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Topic History (Actionable)": {
      "main": [
        [
          {
            "node": "Gate After History (Actionable)",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Gate After History (Actionable)": {
      "main": [
        [
          {
            "node": "Parse Psy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attempt Counter (Theory)": {
      "main": [
        [
          {
            "node": "LLM Generate Psy JSON (Theory)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Uniqueness (Theory)": {
      "main": [
        [
          {
            "node": "If Duplicate? (Theory)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Duplicate? (Theory)": {
      "main": [
        [
          {
            "node": "Attempt Counter (Theory)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Update Topic History (Theory)",
            "type": "main",
            "index": 0
          },
          {
            "node": "Gate After History",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attempt Counter (Actionable)": {
      "main": [
        [
          {
            "node": "LLM Generate Psy JSON (Actionable)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Uniqueness (Actionable)": {
      "main": [
        [
          {
            "node": "If Duplicate? (Actionable)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Duplicate? (Actionable)": {
      "main": [
        [
          {
            "node": "Attempt Counter (Actionable)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Update Topic History (Actionable)",
            "type": "main",
            "index": 0
          },
          {
            "node": "Gate After History (Actionable)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cleanup Temp": {
      "main": [
        [
          {
            "node": "Telegram Notify Final",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": true,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "availableInMCP": false,
    "callerPolicy": "workflowsFromSameOwner"
  },
  "versionId": "f8f01c68-e340-4b01-abfe-9bd7dbf47519",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "id": "q9oDF42tvdopxJT5",
  "tags": []
}