#!/usr/bin/env bash # XKB candidate governance — absorb what clears the gates, then make it findable. # # Governance itself was never the fragile part. The step after it is: anything # written into the wiki has to be embedded, or it is reachable by keyword and # invisible to semantic recall, which is the whole point of the system. # # On 2026-08-31 that step failed with "GEMINI_API_KEY is required" and the run # still reported success. The key was on the machine the entire time; the job # was an agent prompt with an empty environment, so nothing passed it. Same # shape as the 2026-08-28 ingestion failure and the two wiki-import jobs: a # deterministic step, handed to a model, with no way to tell a real run from a # reported one. # # So the embedding failure is fatal here. A governance batch that wrote to the # wiki and could not embed it has not finished, and must say so. # # Usage: # bash scripts/run_candidate_governance.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_GOVERNANCE_LOG:-/tmp/xkb-governance.log}" LOCK_FILE="/tmp/xkb-governance.lock" ENV_FILE="${XKB_ENV_FILE:-}" # 一晚吸收幾筆。 # # 20 是追不上的:2026-09-12 實測,連續三天都打在 20 這個上限上,而待審候選累積 # 到 180 筆、最舊 42 天。追不上是設定造成的,不是壞掉——跟書籤批次當年一次五筆 # 的那個問題是同一個形狀,而那支腳本的註解早就寫過:追不上的 worker 不是自動化, # 是一條慢慢漏的縫。 # # 每筆候選要一次判斷,所以這個數字同時是花費決定,寫在這裡而不是埋在參數預設值裡。 # 80 能在兩三天內清掉現有堆積,之後穩態下遠低於這個數。 LIMIT=80 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)] governance skipped: another run is active." >>"$LOG_FILE" exit 0 fi cd "$SKILL_DIR" log() { echo "$*" >>"$LOG_FILE"; } log "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] governance start (limit=$LIMIT)" GOV_OUT=$(mktemp) GOV_ERR=$(mktemp) trap 'rm -f "$GOV_OUT" "$GOV_ERR"' EXIT # stdout 是 JSON,stderr 不是。原本合在一起,於是任何一行警告——包括這一天 # 才加上的 xkb_failures 提示——都會讓解析失敗,腳本在嵌入之前就結束, # 留下的正是它存在要防止的那個狀態:進了 wiki、沒進索引。 set +e python3 scripts/xkb_review.py --governance --limit "$LIMIT" --write-governance \ >"$GOV_OUT" 2>"$GOV_ERR" gov_status=$? set -e cat "$GOV_OUT" "$GOV_ERR" >>"$LOG_FILE" if [[ "$gov_status" -ne 0 ]]; then echo "XKB 候選治理失敗:離開碼 $gov_status(詳見 $LOG_FILE)" >&2 exit "$gov_status" fi # The CLI emits JSON; read it as JSON. A regex over the text would also match # "near_duplicates" or a promoted id inside a list and report a number that # looks plausible and is wrong. promoted=$(python3 -c ' import json, sys try: print(json.load(open(sys.argv[1]))["stats"]["promoted"]) except Exception: print("?") ' "$GOV_OUT") if [[ "$promoted" == "?" ]]; then echo "XKB 候選治理:讀不出治理結果,無法確認吸收了幾筆(詳見 $LOG_FILE)" >&2 exit 1 fi # Anything promoted is now in the wiki. Until it is embedded it is findable by # keyword and invisible to recall, so this is not an optional tail step. log "> Embedding what governance wrote..." 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 候選治理:已吸收 $promoted 筆,但語意索引更新失敗(離開碼 $embed_status)。" >&2 echo "這批內容現在關鍵字查得到、語意查不到。詳見 $LOG_FILE" >&2 exit "$embed_status" fi # 整理知識庫本身:同一個來源收了兩次、以及從來沒被充實過的空殼書籤。 # # 這兩支原本沒有任何排程在叫,只能手動——而它們標記的旗標又寫在索引列上, # 每次重建就被洗掉一次。實測正式索引 1680 筆裡帶 excluded 的是 0 筆,也就是 # 它們過去的成果一次都沒留下來。旗標改寫進卡片檔案之後,才值得排進來。 # # 放在治理之後、鏡像之前:標記改的是卡片檔案,索引要重算才會反映。 # 這一步失敗不算整批失敗——沒整理成不會讓任何知識查不到,只是雜訊還在。 log "> Tidying the knowledge base (duplicates, empty shells)..." for tidy in canonicalize_duplicates normalize_index_quality; do set +e python3 "scripts/${tidy}.py" >>"$LOG_FILE" 2>&1 tidy_status=$? set -e if [[ "$tidy_status" -ne 0 ]]; then log " ${tidy} 離開碼 ${tidy_status}——已跳過,詳見 $LOG_FILE" fi done # 標記寫在檔案上,索引要重算才看得到。寫索引的地方只有這一個。 set +e python3 -c " import sys sys.path.insert(0, 'scripts') import xkb_index raise SystemExit(0 if xkb_index.rebuild() else 1) " >>"$LOG_FILE" 2>&1 rebuild_status=$? set -e if [[ "$rebuild_status" -ne 0 ]]; then log " 索引重建失敗——這一輪的標記還沒生效,詳見 $LOG_FILE" fi # 帳本:健檢用它算「吸收速度追不追得上進來的速度」。沒有這一行,那個判斷只能 # 退回看絕對量——而看絕對量會在任何補跑期間紅燈,並且把一個吞吐量問題寫成 # 「請你審核 180 筆」。 python3 scripts/xkb_ledger.py record --stage candidate-governance --produced "$promoted" >>"$LOG_FILE" 2>&1 || true log "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] governance done (promoted=$promoted)" # Silent on a quiet night. The 09:00 summary carries the standing counts; a job # that says "nothing happened" every morning is a job that stops being read. if [[ "$promoted" -gt 0 ]]; then echo "XKB 候選治理:吸收 $promoted 筆進 wiki,語意索引已更新。" fi