#!/usr/bin/env bash # XKB bookmark batch — turn fetched bookmarks into knowledge cards. # # This is the answer to "shouldn't XKB do this by itself?". It did, at five # items a night, while more arrived every day — so the queue grew instead of # draining and the daily summary reported a backlog that was structurally # guaranteed to keep growing. A worker that cannot keep up is not automation, # it is a slow leak. # # The limit is now sized to clear a normal day and make progress on the # backlog. Each item costs one model call, so the number is a spending decision # as much as a throughput one; it is here, in one place, rather than implied by # a default buried in an argument parser. # # Speaks only when it did something or when the queue is not shrinking. A job # that reports the same backlog every morning teaches you to stop reading it. # # Usage: # bash scripts/run_bookmark_batch.sh [--env-file FILE] [--limit N] set -euo pipefail SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=scripts/_xkb_env.sh source "$SKILL_DIR/scripts/_xkb_env.sh" LOG_FILE="${XKB_BOOKMARK_LOG:-/tmp/xkb-bookmark-batch.log}" LOCK_FILE="/tmp/xkb-bookmark-batch.lock" ENV_FILE="${XKB_ENV_FILE:-}" LIMIT=20 while [[ $# -gt 0 ]]; do case $1 in --env-file) ENV_FILE="$2"; shift 2 ;; --limit) LIMIT="$2"; shift 2 ;; *) echo "Unknown arg: $1" >&2; exit 1 ;; esac done if [[ -n "$ENV_FILE" && ! -f "$ENV_FILE" ]]; then echo "[ERROR] XKB env file not found: $ENV_FILE" >&2 exit 1 fi # An explicit --env-file outranks whatever the scheduler put in the environment. xkb_env_file_wins "$ENV_FILE" export XKB_ENV_FILE="$ENV_FILE" exec 9>"$LOCK_FILE" if ! flock -n 9; then echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] bookmark batch skipped: another run is active." >>"$LOG_FILE" exit 0 fi cd "$SKILL_DIR" log() { echo "$*" >>"$LOG_FILE"; } pending() { python3 - <<'PY' 2>/dev/null || echo "?" import sys sys.path.insert(0, "scripts") import xkb_paths from xkb_pending_work import uncarded_bookmarks print(len(uncarded_bookmarks(xkb_paths.BOOKMARKS_DIR, xkb_paths.CARDS_DIR))) PY } # 待轉的分成「還會被處理的」與「卡住的」。混成一個數字會給出錯的建議:佇列 # 裡只剩 failed/skipped 時,摘要照樣叫人「調高 limit」,而調高 limit 對它們 # 完全沒有作用——它們要的是一個決定,不是更多額度。 queued_now() { python3 - <<'PYQ' 2>/dev/null || echo "? ?" import sys sys.path.insert(0, "scripts") import xkb_paths from xkb_pending_work import pending_breakdown c = pending_breakdown(xkb_paths.BOOKMARKS_DIR, xkb_paths.CARDS_DIR) print(f"{c['actionable']} {c['stuck']}") PYQ } before=$(pending) log "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] bookmark batch start (limit=$LIMIT, pending=$before)" # 先把還值得再試的失敗項目放回佇列。 # # 根因修好不會讓它們自己好:worker 只挑 todo,而 sync_tiege_queue 刻意保留 # failed。2026-09-09 那八天留下的 49 筆,是我手動重設才動起來的。 # # 但重試有上限(見 xkb_requeue_failed.py):剩下那些是被供應商擋掉內容的, # 無限重試等於每晚固定燒一次錢又永遠不會成功。到上限就轉成 abandoned。 requeue_out=$(python3 scripts/xkb_requeue_failed.py 2>>"$LOG_FILE") requeue_status=$? log " requeue: ${requeue_out:-(無輸出)}" if [[ "$requeue_status" -ne 0 ]]; then log " requeue 失敗——這一輪只處理原本就在佇列裡的項目" fi OUT=$(mktemp) trap 'rm -f "$OUT"' EXIT # 帳本:這一次收了多少、產出多少、壞了多少、還剩多少。log 是給人追細節的, # 這一行是給機器問「昨晚這一階段做了什麼」的——那八天 0 產出之所以沒人發現, # 就是因為沒有任何地方能回答那個問題。 ledger() { local produced="$1" failed="$2" pend="$3" reason="$4" okflag="$5" local args=(record --stage bookmark-batch) [[ "$before" =~ ^[0-9]+$ ]] && args+=(--intake "$before") args+=(--produced "$produced" --failed "$failed") [[ "$pend" =~ ^[0-9]+$ ]] && args+=(--pending "$pend") [[ -n "$reason" ]] && args+=(--reason "$reason") [[ "$okflag" == "not-ok" ]] && args+=(--not-ok) python3 scripts/xkb_ledger.py "${args[@]}" >>"$LOG_FILE" 2>&1 || true } # 失敗長什麼樣,一行就夠——分辨「模型設定壞了」和「這幾筆內容抓不到」靠的 # 就是這一行,而它原本只存在於 /tmp 的 log 裡,沒進任何摘要。 first_failure_reason() { sed -n 's/.*✗ failed: //p' "$OUT" | head -1 | cut -c1-200 } set +e python3 scripts/run_bookmark_worker.py --limit "$LIMIT" >"$OUT" 2>&1 status=$? set -e cat "$OUT" >>"$LOG_FILE" # worker 對單筆失敗也回非零,而這裡原本直接結束——於是十九張成功的卡片 # 寫進了磁碟卻沒進語意索引,正是這支腳本註解裡說「最不該有的狀態」。 # 單筆失敗交給下面的 failed_count 報告;只有連 done 都讀不出來才算整批壞掉。 if [[ "$status" -ne 0 ]]; then log "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] worker exited $status — 仍會嘗試嵌入已產出的卡片" fi # 這一次做了什麼,只認 worker 自己那行摘要——見 scripts/xkb_batch_summary.py。 # 原本這裡是 sed 抓「最後一個 done=」,而佇列同步那行也有 done=,於是佇列沒 # 事做的晚上會把佇列的累計總數當成這次的產出。實測報過「產了 1575 張卡」, # 而且因為 done_count > 0,還會多跑一次向量索引。 set +e summary=$(python3 scripts/xkb_batch_summary.py <"$OUT" 2>>"$LOG_FILE") summary_status=$? set -e # 讀不出結果 ≠ 產出 0。前者是壞了,後者是沒事做,而原本的 ${done_count:-0} # 讓兩者長得一模一樣——正是這條管線八天沒被發現的那個毛病。 if [[ "$summary_status" -ne 0 ]]; then log "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] 讀不出 worker 的執行結果(worker exit $status)" ledger 0 0 "$(pending)" "讀不出 worker 的執行結果(worker exit $status):$(first_failure_reason)" not-ok echo "XKB 書籤批次:讀不出 worker 的執行結果,無法確認這次做了什麼(詳見 $LOG_FILE)" >&2 if [[ "$status" -ne 0 ]]; then exit "$status"; fi exit 1 fi # summary 的格式是 `done=N failed=N outcome=X`,用 bash 自己的展開拆就好。 done_count=${summary#done=} done_count=${done_count%% *} failed_count=${summary#*failed=} failed_count=${failed_count%% *} # 新卡片要被嵌入才召回得到。這一步失敗就是整批失敗——寫進 wiki 卻沒進索引, # 等於關鍵字查得到、語意查不到,那是這個系統最不該有的狀態。 if [[ "$done_count" -gt 0 ]]; then EMBED_ARGS=(--incremental) [[ -n "$ENV_FILE" ]] && EMBED_ARGS+=(--env-file "$ENV_FILE") set +e python3 scripts/build_vector_index.py "${EMBED_ARGS[@]}" >>"$LOG_FILE" 2>&1 embed_status=$? set -e if [[ "$embed_status" -ne 0 ]]; then echo "XKB 書籤批次:產了 $done_count 張卡,但語意索引更新失敗(離開碼 $embed_status)。" >&2 echo "這批卡片現在關鍵字查得到、語意查不到。詳見 $LOG_FILE" >&2 ledger "$done_count" "$failed_count" "$(pending)" "語意索引更新失敗 exit $embed_status" not-ok exit "$embed_status" fi fi after=$(pending) log "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] bookmark batch done (done=$done_count failed=$failed_count pending=$after)" ledger "$done_count" "$failed_count" "$after" "$( [[ "$failed_count" -gt 0 ]] && first_failure_reason )" ok read -r queued stuck <<<"$(queued_now)" # 讀不出來時這兩個是 "?",不是空字串。空字串會讓下面印出「還有 筆排隊中」 # ——把一個未知數印成一個數字。 stuck_note="" # 卡住的那些不會自己好,也沒有任何排程會再碰它們——sync_tiege_queue 明文寫著 # 「由操作者自行重設」。所以它們每次都要被說出來,而且要說清楚沒有人會處理。 if [[ "$stuck" =~ ^[0-9]+$ && "$stuck" -gt 0 ]]; then stuck_note="另有 $stuck 筆卡在失敗狀態,不會自動重試——要重跑得手動重設。" fi if [[ "$failed_count" -gt 0 ]]; then echo "XKB 書籤批次:產了 $done_count 張卡,$failed_count 筆失敗,還有 $queued 筆排隊中。$stuck_note" [[ "$queued" == "?" ]] && echo "(排隊數讀不出來,詳見 $LOG_FILE)" exit 0 fi # 追不上是這支腳本存在的理由,所以它要看得見——但只有「還會被處理的」那些 # 算數。原本這裡比的是全部待轉,於是佇列裡只剩卡住的項目時,它每天都會叫人 # 調高 limit,而那對卡住的項目毫無作用。 if [[ "$queued" =~ ^[0-9]+$ && "$before" != "?" && "$after" != "?" \ && "$after" -ge "$before" && "$queued" -gt 0 ]]; then echo "XKB 書籤批次:產了 $done_count 張卡,但排隊中仍有 $queued 筆 —— 進來的比消化的快,limit 需要調高。$stuck_note" elif [[ "$done_count" -gt 0 ]]; then echo "XKB 書籤批次:產了 $done_count 張卡,還有 $queued 筆排隊中。$stuck_note" elif [[ -n "$stuck_note" ]]; then echo "XKB 書籤批次:這次沒有東西要處理。$stuck_note" fi