name: km_wiki_ingest_drain
description: >
  Phase 0 限速 drain：cron 每 tick 只處理「一張卡」→ 機械解析成 entry + triplet envelope
  → 冪等寫 KBDB（base entry / graph triplet）。反覆跑直到全庫 drain 完。
  來源＝repo 的 system-dev/wiki/cards/**/*.md（人工精耕卡，非裸筆記，無 LLM）。
  解析由通用 code 零件（sandbox inline JS）承載，不再鑄 domain 零件（Arcrun#10 裁定）。
  穩態（Gitea push webhook 只處理 delta）見檔尾 §穩態變體。

# ── 為什麼「一 tick 一卡」＝根治 07_01 的 Too many subrequests ──
# graph worker 處理一次 POST /triplets/ingest 的 subrequest = 7 + 4*N_triplets + M_nodes + D_deprecated。
# 07_01 炸點：單一 envelope 吞整檔 N=11,M=10 → 61 > 50（CF bundled 上限）→ 半殘。
# 對策：① 一卡一 tick（天然小批，notes 卡 ~N4/M5 → est 28~33，穩壓 50 下）
#       ② code 節點的內聯解析會自動把超大卡以 source_uri anchor 分段（每段獨立冪等）。
# ⟹ 任何單一 graph 呼叫都不會再破頂。

flow:
  - "watch_cron >> ON_SUCCESS >> pick_next_card"
  - "pick_next_card >> ON_SUCCESS >> fetch_card"
  - "fetch_card >> ON_SUCCESS >> parse_card"
  - "parse_card >> ON_SUCCESS >> upsert_entry"                  # 卡片 → base entry（embed=true），冪等
  - "upsert_entry >> ON_SUCCESS >> post_envelopes"             # wikilink/typed-edge → graph triplet
  - "post_envelopes >> 對每個 envelope >> post_one_envelope"   # 分段時多段，各段獨立冪等

config:
  # 1) 排程 tick：慢推。每 2 分鐘一張卡＝限速（Phase 0 唯一需要 rate-limit 之處）。
  watch_cron:
    component: cron
    cron_expr: "*/2 * * * *"
    description: "每 2 分鐘 drain 一張卡（限速慢推，避免 CF 額度與 subrequest 壓力）"

  # 2) 取下一張待處理卡（cursor drain）。用 Gitea contents API 列 cards 目錄 + 一個游標 block
  #    記「處理到哪」。回傳單一 { rel_path, download_url, content_hash?(git blob sha) }。
  #    註：list + cursor 的細節可用 http_request(Gitea API) + set/string_ops 組；此處給語意佔位。
  pick_next_card:
    component: http_request
    method: GET
    url: "https://git.uncle6.me/api/v1/repos/{{repo}}/contents/system-dev/wiki/cards?ref={{ref}}"
    headers:
      Authorization: "token {{gitea_token}}"
      Accept: "application/json"
    # 下游用 filter/set 取「游標之後第一張、且 .md、且非 00-INDEX」的一張。

  # 3) 抓卡片全文（Gitea raw）。
  fetch_card:
    component: http_request
    method: GET
    url: "{{pick_next_card.next.download_url}}"
    headers:
      Authorization: "token {{gitea_token}}"

  # 4) ★ 機械解析 —— 通用 code 零件（sandbox inline JS，無 LLM、無 fs/網路，stdin→stdout JSON）。
  #    Arcrun#10 裁定：一次性解析邏輯走通用逃生口，不再鑄 domain 零件 km_wiki_card_parse。
  #    下面 code: 內聯的即 lib/card-to-envelope.mjs 的 planCard 邏輯（去 import/export、
  #    raw NUL 分隔符改 \u0000 escape、改用 code 沙箱注入的 curated builtin sha256；
  #    已單測證明與原模組輸出逐欄全等）。
  #    input：卡片全文 md + 相對路徑 relPath + repo + opts.budget（subrequest 目標上限）。
  #    output：{ success:true, data:{ entry, envelopes[], meta, nodeCount, tripletCount } }
  #            —— envelope 已分段、已估 subrequest。故下游改引用 parse_card.data.*。
  parse_card:
    component: code
    code: |
      // km-wiki-ingest — 機械式卡片→(entry + triplet envelope) 轉換核心（無 LLM、純函式）
      // ---------------------------------------------------------------------------
      // 取代舊 `kbdb-ingest-plugin/scripts/ingest-cli.mjs` 的 raw→Haiku 路：
      //   舊路 = 讀裸筆記 → 呼叫 Haiku 萃 (s,p,o) → envelope（有 LLM、非決定性、耗 token）。
      //   新路 = 讀「已精耕卡片」（`system-dev/wiki/cards/**/*.md`）→ 直接解析卡片內既有的
      //          `## 實體`（節點）、`## 關聯` 的 typed-edge（`A >> 關係 >> B`）與 `[[wikilink]]`
      //          → entry + triplet envelope。純機械、決定性、零 token。
      //
      // 這支＝通用 `code` 零件（Arcrun#10，sandbox inline JS）承載的解析邏輯本體。
      // workflow.yaml 的 parse_card 節點把本檔的 planCard 邏輯內聯進 code 零件的 config
      // （去 import/export、raw NUL 分隔符改 u0000 escape、改用 code 沙箱注入的 sha256）；
      // 不再鑄 domain 零件 km_wiki_card_parse（Arcrun#10 裁定：一次性解析走通用逃生口）。
      // 本檔續留作「該內聯 JS 的權威來源 + 可單元測試的參考實作」（純函式、stdin→stdout JSON、無 fs/網路）。
      //
      // 對齊契約：kbdb-ingest-plugin/contracts/ingest-candidate.json（envelope 形狀 / 禁止欄位）。
      // 對齊頂層 SDD：卡片→entry（metadata.embed=true，走 base API）、wikilink→triplet（走 graph）。
      //
      // 鐵律：不碰儲存、不算向量、不建表。這支只「產出將寫入什麼」，實際 HTTP 由 workflow 打。

      // (import 移除：code 沙箱提供注入的 sha256 builtin)
      // --- CF subrequest 預算（防「Too many subrequests by single Worker invocation」，07_01 根因）---
      //
      // graph worker 處理一次 POST /triplets/ingest 時，對 base 的每次 fetch = 1 subrequest。
      // 精確拆帳（讀 kbdb-graph-plugin/src/actions/triplet-ingest.ts + triplet-crud.ts + templates.ts）：
      //   ingestEnvelope = ensurePluginTemplates(3) + listRecordsByTemplate(1)
      //                    + Σ triplet [ createTriplet → ensurePluginTemplates(3) + createRecord(1) = 4 ]
      //                    + persistNodes [ ensurePluginTemplates(3) + Σ node createRecord(1) ]
      //                    + Σ deprecated updateRecord(1)
      //   ⟹ subreq(envelope) = 7 + 4*N_triplets + M_nodes + D_deprecated
      //
      //   07_01 實測炸點：N=11, M=10, D=0 → 7+44+10 = 61 > 50（CF 免費/bundled 上限）→ 炸半殘。
      //
      // 對策 = 「一卡一 tick、每 envelope 壓在預算下、超大檔以 source_uri anchor 分段」。
      const SUBREQ_CEILING = 50;   // CF 單次 Worker invocation subrequest 硬上限（bundled）
      const SUBREQ_BUDGET = 40;    // 我們的目標上限（留 10 給 D_deprecated 等變動）

      /** 精確估算「一個 envelope 打進 graph /triplets/ingest」會在 graph worker 內產生幾個 subrequest。 */
      function estimateEnvelopeSubrequests(nTriplets, mNodes, dDeprecated = 0) {
        return 7 + 4 * nTriplets + mNodes + dDeprecated;
      }

      // --- sha256（content_hash 冪等鍵）---
      // (sha256 移除：使用 code 沙箱注入的 curated builtin sha256)

      // --- frontmatter 解析（極簡 YAML：只吃我們卡片用到的 tags / gloss / pipeline_candidate）---
      function parseFrontmatter(md) {
        const m = md.match(/^---\n([\s\S]*?)\n---\n?/);
        if (!m) return { data: {}, body: md };
        const body = md.slice(m[0].length);
        const data = {};
        for (const line of m[1].split('\n')) {
          const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
          if (!kv) continue;
          const key = kv[1];
          let val = kv[2].trim();
          if (val.startsWith('[') && val.endsWith(']')) {
            // inline list: [a, b, c]
            data[key] = val.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean);
          } else if (val === 'true' || val === 'false') {
            data[key] = val === 'true';
          } else {
            data[key] = val;
          }
        }
        return { data, body };
      }

      // --- 取某個 `## 標題` / `### 標題` 區塊的內文（到下一個同級或更高級標題為止）---
      function sectionBody(md, heading) {
        // heading 例：'## 實體'、'### 內文知識關係'
        const level = heading.match(/^#+/)[0].length;
        const lines = md.split('\n');
        const out = [];
        let inSec = false;
        for (const line of lines) {
          const h = line.match(/^(#+)\s+(.*)$/);
          if (h) {
            const thisLevel = h[1].length;
            if (inSec) {
              // 遇到同級或更高級標題 → 區塊結束
              if (thisLevel <= level) break;
            }
            // 標題文字「開頭相符」即算命中（容忍標題後帶括號補述）
            if (!inSec && thisLevel === level && line.replace(/^#+\s+/, '').startsWith(heading.replace(/^#+\s+/, ''))) {
              inSec = true;
              continue;
            }
          }
          if (inSec) out.push(line);
        }
        return out.join('\n');
      }

      // --- 實體行解析：`- **正規名**（別名1／別名2）— 描述`（別名、描述皆選填）---
      function parseEntities(md) {
        const sec = sectionBody(md, '## 實體');
        const entities = [];
        for (const raw of sec.split('\n')) {
          const line = raw.trim();
          if (!line.startsWith('- ')) continue;
          if (line.startsWith('- >') || line.startsWith('> ')) continue; // 跳過引言說明行
          const m = line.match(/^- \*\*(.+?)\*\*(?:（(.+?)）)?\s*(?:[—–\-]\s*(.*))?$/);
          if (!m) continue;
          const name = m[1].trim();
          if (!name) continue;
          const aliases = m[2]
            ? m[2].split(/[／/、,]/).map((s) => s.trim()).filter((s) => s && s !== name)
            : [];
          const gloss = (m[3] || '').trim();
          entities.push({ name, aliases, gloss });
        }
        return entities;
      }

      // --- typed-edge 行解析：`A >> 謂詞 >> B`（端點可為裸實體名或 [[wikilink]]）---
      function parseTypedEdges(sectionText) {
        const edges = [];
        for (const raw of (sectionText || '').split('\n')) {
          const line = raw.trim();
          if (!line.startsWith('- ')) continue;
          const body = line.slice(2).trim();
          if (body.startsWith('（') || body.startsWith('(')) continue; // 「（暫無…）」占位行
          const parts = body.split('>>');
          if (parts.length !== 3) continue;
          const subject = stripWikilink(parts[0].trim());
          const predicate = parts[1].trim();
          const object = stripWikilink(parts[2].trim());
          if (!subject || !predicate || !object) continue;
          edges.push({ subject, predicate, object });
        }
        return edges;
      }

      // [[notes/00-INDEX]] → notes/00-INDEX ；純字串則原樣回。
      function stripWikilink(s) {
        const m = s.match(/^\[\[(.+?)\]\]$/);
        return m ? m[1].trim() : s;
      }

      // --- 抽所有 inline [[wikilink]]（含 header 的 ← [[notes/00-INDEX]] 與內文）---
      function extractInlineWikilinks(md) {
        const out = [];
        const re = /\[\[(.+?)\]\]/g;
        let m;
        while ((m = re.exec(md)) !== null) out.push(m[1].trim());
        return out;
      }

      // --- 卡片 canonical id：以檔名（去副檔名）為準，對齊 `## 卡片關係` 用的 [[基名]] 慣例 ---
      function cardCanonical(relPath) {
        const base = relPath.split('/').pop().replace(/\.md$/, '');
        return base;
      }

      /**
       * 解析一張卡片 → { entry, nodes, triplets, meta }（尚未分段的原始產物）。
       * relPath：卡片相對 repo 根路徑（如 system-dev/wiki/cards/notes/Xxx.md）。
       * repo：如 'Leo/notes'。
       */
      function parseCard(md, relPath, repo = 'Leo/notes') {
        const { data: fm } = parseFrontmatter(md);
        const canonical = cardCanonical(relPath);
        const titleMatch = md.match(/^#\s+(.+)$/m);
        const title = titleMatch ? titleMatch[1].trim() : canonical;

        // 1) 節點：## 實體 的正規名 + 別名 + gloss。
        const entities = parseEntities(md);

        // 2) 邊：內文知識關係（實體↔實體）+ 卡片關係（卡↔卡）+ inline wikilink（卡→卡 導覽/引用）。
        const intraEdges = parseTypedEdges(sectionBody(md, '### 內文知識關係'))
          .map((e) => ({ ...e, confidence: 1.0 }));
        const cardEdges = parseTypedEdges(sectionBody(md, '### 卡片關係'))
          .map((e) => ({ ...e, confidence: 1.0 }));

        // inline wikilink（← [[notes/00-INDEX]] 等）→ 卡→卡「連結至」邊，去重、排除自環與已被 typed 邊覆蓋者。
        const typedPairs = new Set(
          [...cardEdges].map((e) => `${e.subject}\u0000${e.object}`),
        );
        const seenRef = new Set();
        const refEdges = [];
        for (const target of extractInlineWikilinks(md)) {
          const t = stripWikilink(target);
          if (t === canonical || t === title) continue;              // 自環
          if (typedPairs.has(`${canonical}\u0000${t}`)) continue;     // 已有明確謂詞邊
          const key = `${canonical}\u0000${t}`;
          if (seenRef.has(key)) continue;
          seenRef.add(key);
          refEdges.push({ subject: canonical, predicate: '連結至', object: t, confidence: 0.5 });
        }

        const triplets = [...intraEdges, ...cardEdges, ...refEdges];

        // 3) 節點清單：卡片本身（canonical，帶 frontmatter gloss）+ 內文實體。
        //    卡對卡邊指到的「別張卡」不在此補 node —— 那張卡自己被 ingest 時會補自己的 node。
        const nodes = [];
        const seenNode = new Set();
        const pushNode = (n) => {
          const k = n.name.toLowerCase();
          if (!n.name || seenNode.has(k)) return;
          seenNode.add(k);
          nodes.push(n);
        };
        pushNode({ name: canonical, gloss: fm.gloss || '', aliases: title && title !== canonical ? [title] : [] });
        for (const e of entities) pushNode({ name: e.name, gloss: e.gloss, aliases: e.aliases });

        return {
          entry: {
            // base POST /entries（或 kbdb_upsert_block）用。metadata.embed=true → 語意可搜。
            page_name: `wikicard:${repo}/${canonical}`,     // idempotency key（穩定）
            entry_type: 'wiki_card',
            content: md,                                     // 卡片全文逐字（embed 對象）
            tags: Array.isArray(fm.tags) ? fm.tags : [],
            metadata: {
              embed: true,                                   // ★ base embed 模組讀此旗標
              source: `gitea:${repo}@${relPath}`,
              content_hash: sha256(md),
              kind: 'wiki_card',
              repo,
              canonical,
              pipeline_candidate: fm.pipeline_candidate === true,
            },
          },
          nodes,
          triplets,
          meta: { canonical, title, relPath, repo, contentHash: sha256(md) },
        };
      }

      /**
       * 把一張卡片的 (nodes, triplets) 打包成「一個或多個」ingest envelope，
       * 使每個 envelope 打進 graph 後的 subrequest 都 ≤ SUBREQ_BUDGET。
       *
       * 分段規則（對應頂層 SDD R4 / issue #8 第4點）：
       *   - 單 envelope 夠塞（7+4N+M ≤ budget）→ 不分段，uri = 基 uri（無 anchor）。
       *   - 需分段 → 每段 uri = `<基uri>#seg{NN}`、anchor = `seg{NN}`。
       *     每段是「獨立 source_uri」→ 各自獨立冪等，繞開 graph 的 per-source content_hash 整包 skip
       *     （否則同 uri 第 2 段起會被 line 65 的 content_hash 命中而整包跳過）。
       *   - 節點只放進「第一個引用到它的段」，跨段不重送（避免 graph persistNodes 重建 entity record）。
       */
      function planEnvelopes(parsed, opts = {}) {
        const budget = opts.budget ?? SUBREQ_BUDGET;
        const repo = parsed.meta.repo;
        const relPath = parsed.meta.relPath;
        const baseUri = `gitea:${repo}@${relPath}`;
        const contentHash = parsed.meta.contentHash;
        const commit = opts.commit;

        const extractor = {
          model: opts.extractorModel ?? 'mechanical/km-wiki-card-parse@1',
          tier: 'deep',                                   // 人工精耕卡＝deep（決定性、非淺萃）
          extracted_at: Math.floor(Date.now() / 1000),
        };

        const nodeByName = new Map(parsed.nodes.map((n) => [n.name, n]));

        // 貪婪打包：逐條 triplet 累進，段成本 = 7 + 4*(段內邊數) + (段內首見節點數)。
        const segments = [];
        let cur = null;
        const startSeg = () => {
          cur = { triplets: [], nodeNames: new Set() };
          segments.push(cur);
        };
        const segCost = (seg, extraEdges = 0, extraNodes = 0) =>
          estimateEnvelopeSubrequests(seg.triplets.length + extraEdges, seg.nodeNames.size + extraNodes);

        startSeg();
        for (const t of parsed.triplets) {
          // 這條邊會新引入哪些節點（subject/object 命中 nodeByName 且本段尚未收）
          const cand = [t.subject, t.object].filter(
            (nm) => nodeByName.has(nm) && !cur.nodeNames.has(nm) && !anySegHas(segments, cur, nm),
          );
          // 放得下？（含新增這條邊 + 新引入節點）
          if (cur.triplets.length > 0 && segCost(cur, 1, cand.length) > budget) {
            startSeg();
          }
          cur.triplets.push(t);
          for (const nm of [t.subject, t.object]) {
            if (nodeByName.has(nm) && !anySegHas(segments, null, nm)) cur.nodeNames.add(nm);
          }
        }

        const multi = segments.length > 1;
        const envelopes = segments.map((seg, i) => {
          const anchor = multi ? `seg${String(i + 1).padStart(2, '0')}` : undefined;
          const uri = multi ? `${baseUri}#${anchor}` : baseUri;
          const nodes = [...seg.nodeNames].map((nm) => {
            const n = nodeByName.get(nm);
            const out = { name: n.name };
            if (n.gloss) out.gloss = n.gloss;
            if (n.aliases && n.aliases.length) out.aliases = n.aliases;
            out.embed = true;
            return out;
          });
          const source = { uri, content_hash: contentHash };
          if (anchor) source.anchor = anchor;
          if (commit) source.commit = commit;
          return {
            source,
            extractor,
            nodes,
            triplets: seg.triplets.map((t) => ({
              subject: t.subject,
              predicate: t.predicate,
              object: t.object,
              confidence: t.confidence ?? 1.0,
            })),
            _estSubrequests: estimateEnvelopeSubrequests(seg.triplets.length, seg.nodeNames.size),
          };
        });

        // triplets≥1 是契約硬性；無邊的卡不產 envelope（仍會建 entry）。
        return envelopes.filter((e) => e.triplets.length >= 1);
      }

      function anySegHas(segments, exclude, name) {
        for (const s of segments) {
          if (s === exclude) continue;
          if (s.nodeNames.has(name)) return true;
        }
        return false;
      }

      /** 一張卡片 → 完整 ingest 計畫（entry + envelopes）。planCard = parseCard + planEnvelopes。 */
      function planCard(md, relPath, repo = 'Leo/notes', opts = {}) {
        const parsed = parseCard(md, relPath, repo);
        const envelopes = planEnvelopes(parsed, opts);
        return { entry: parsed.entry, envelopes, meta: parsed.meta, nodeCount: parsed.nodes.length, tripletCount: parsed.triplets.length };
      }

      // graph /triplets/ingest 是 strict schema，拒絕未知鍵。planEnvelopes 於 envelope 上掛的
      // 診斷鍵 _estSubrequests 不在 ingest-candidate 契約內 → post 前剝除所有 _-前綴鍵，
      // 讓 post_one_envelope 的 body_json={{envelope}} 為契約乾淨 payload。
      // （leo21c 實測：不剝除會回 422 unrecognized_keys "_estSubrequests"。）
      const __plan = planCard(input.md, input.relPath, input.repo, input.opts || {});
      __plan.envelopes = __plan.envelopes.map(function (e) {
        const clean = {};
        for (const k in e) { if (k.charAt(0) !== '_') clean[k] = e[k]; }
        return clean;
      });
      return __plan;

    input:
      md: "{{fetch_card.data.body}}"
      relPath: "{{pick_next_card.next.rel_path}}"
      repo: "{{repo}}"
      opts:
        budget: 40                 # subrequest 目標上限（留 10 給 D_deprecated），超過自動 anchor 分段
    limits:
      timeout_ms: 3000             # 純 CPU 解析；大卡也充裕
      max_output_bytes: 4194304    # envelope 陣列可能較大（4 MiB）

  # 5) 卡片 → base entry，冪等 upsert（page_name 當鍵；找到 PATCH、沒有 POST）。
  #    metadata.embed=true → base embed 模組會補嵌 → 語意可搜。
  upsert_entry:
    component: kbdb_upsert_block
    api_key: "{{kbdb_api_key}}"
    kbdb_url: "{{kbdb_url}}"
    page_name: "{{parse_card.data.entry.page_name}}"
    type: "{{parse_card.data.entry.entry_type}}"
    content: "{{parse_card.data.entry.content}}"
    source: "{{parse_card.data.entry.metadata.source}}"
    tags_json: "{{parse_card.data.entry.tags_json}}"
    # ⚠️ metadata.embed=true / content_hash 需經 base /entries 帶 metadata_json 落地；
    #    若 kbdb_upsert_block 尚未透傳 metadata_json，改用 http_request 直打 base POST/PATCH /entries
    #    帶 body_json.metadata_json（見 description.md §entry 冪等）。

  # 6) triplet envelope（可能多段）→ 逐段 POST graph /triplets/ingest。
  #    graph 端 per-source(uri+content_hash) 冪等：同 hash 整包 no-op；分段各段 uri 不同 → 各自獨立冪等。
  post_envelopes:
    component: foreach_control
    items: "{{parse_card.data.envelopes}}"
    item_key: envelope

  post_one_envelope:
    component: http_request
    method: POST
    url: "{{graph_url}}/triplets/ingest"
    headers:
      Content-Type: "application/json"
      X-Arcrun-API-Key: "{{graph_api_key}}"
    body_json: "{{envelope}}"      # envelope 已符合 ingest-candidate.json 契約（禁止欄位已排除）

# ── 執行環境變數（部署時注入；此檔不放密鑰）──
# repo=Leo/notes  ref=main  gitea_token=<GITEA_TOKEN>
# kbdb_url=https://arcrun-kbdb.leo21c.workers.dev  kbdb_api_key=<partner key>
# graph_url=<graph plugin base url on leo21c>      graph_api_key=leo
#
# ═══════════════════════════════════════════════════════════════════════════
# §穩態變體（km_wiki_ingest_delta）：Gitea push webhook → 只處理 delta 檔
# ═══════════════════════════════════════════════════════════════════════════
# 觸發 = Gitea repo Settings → Webhooks → 指向 arcrun(cypher-executor) 的 workflow webhook URL。
#   ⚠️ 這是 Gitea → Cloudflare(arcrun)，非 GitHub Actions → 不觸 GitHub flag 紅線（D4/D20）。
# 只把上面 flow 的 watch_cron/pick_next_card 換成：
#   input（webhook payload）>> ON_SUCCESS >> collect_changed
#   collect_changed = 從 payload.commits[].{added,modified} 濾出 system-dev/wiki/cards/**/*.md
#                     >> foreach 檔 >> fetch_card >> parse_card >> upsert_entry >> post_envelopes（同上）
# 量小、天生不撞頂、不需限速；靠 graph/entry 冪等自動 skip 未變檔。
