#!/usr/bin/env python3 """Report sources that have arrived but not yet become knowledge. Runs at the end of the daily ingestion batch, so a source that stops producing shows up in the delivered output instead of being noticed weeks later. It replaced an agent instruction that asked for the same judgement and could report success without making it. The first version counted any bookmark with no file in ``cards/``, which was wrong in a way worth remembering: the YouTube path writes its nine-section card in place, in the bookmarks tree, so 23 finished cards were reported as unprocessed work every single day. They were in the search index and had vectors the whole time. A number that is red when nothing is wrong stops being read, which is the same reason the empty-queue case in this batch was fixed yesterday. A file that declares ``type: knowledge-card`` has already been through the step this is looking for, wherever it happens to live. """ from __future__ import annotations import json import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import xkb_paths SELF_DECLARED_CARD = re.compile(r"^type:\s*knowledge-card", re.M) FRONTMATTER_BYTES = 400 def is_knowledge_card(path: Path) -> bool: try: return bool(SELF_DECLARED_CARD.search(path.read_text(encoding="utf-8", errors="ignore")[:FRONTMATTER_BYTES])) except OSError: return False def uncarded_bookmarks(bookmarks_dir: Path, cards_dir: Path) -> list[Path]: carded = {path.stem for path in cards_dir.glob("*.md")} return [ path for path in sorted(bookmarks_dir.rglob("*.md")) if path.stem not in carded and not is_knowledge_card(path) ] # worker 只會處理 todo/processing。failed 與 skipped 是終點: # sync_tiege_queue 明文「保留 failed/skipped 狀態,由操作者自行重設」, # 也就是沒有任何排程會再碰它們。 # 原本 processing 也算 retryable,但 worker 只挑 status == "todo",而 # sync_tiege_queue 刻意不碰 processing(「避免覆蓋進行中的工作」)。所以一個被 # 中斷的 worker 留下的 processing 項目,沒有任何東西會再處理它——它跟 failed # 一樣是終點,只是看起來像在進行中。 # # 算成「排隊中」的後果:那種孤兒會永遠被報成待處理,card_production 也把它算進 # pending,48 小時後升級成「管線停擺」,而調任何設定都清不掉。跟這個函式本來要 # 修的是同一種錯,只是換了一個狀態。 RETRYABLE_STATUSES = {"todo"} TERMINAL_STATUSES = {"failed", "skipped", "processing", "abandoned"} def pending_breakdown(bookmarks_dir: Path, cards_dir: Path, queue_path: Path | None = None) -> dict[str, int]: """待轉的書籤分成「還會被處理的」和「卡住的」。 這兩種混在同一個數字裡會產生錯的建議。2026-09-10 佇列裡剩 17 筆,全部 是 failed(9) + skipped(6) + 不在佇列(2)——沒有任何一筆在排隊,而批次的 摘要照樣說「進來的比消化的快,limit 需要調高」。調高 limit 對這 17 筆 完全沒有作用,而且它會每天這樣說一次。 卡住的那些也不會自己好:那 9 筆是模型設定壞掉那八天留下的,根因修好之 後它們仍然停在 failed,要人手動重設才會重跑。所以它們需要的不是一個數 字,是一個決定。 """ queue_path = queue_path or xkb_paths.QUEUE_PATH uncarded = uncarded_bookmarks(bookmarks_dir, cards_dir) status_by_id: dict[str, str] = {} try: data = json.loads(queue_path.read_text(encoding="utf-8")) for item in (data.get("items", []) if isinstance(data, dict) else data): key = str(item.get("id") or item.get("tweet_id") or "").strip() if key: status_by_id[key] = str(item.get("status") or "") except (OSError, ValueError): # 佇列讀不到時,全部算「不在佇列」而不是假裝它們在排隊。 status_by_id = {} counts = {"total": len(uncarded), "queued": 0, "stuck": 0, "skipped": 0, "unqueued": 0, "abandoned": 0} for path in uncarded: status = status_by_id.get(path.stem) if status in RETRYABLE_STATUSES: counts["queued"] += 1 elif status in {"failed", "processing"}: # processing 也算卡住:沒有任何排程會再碰被中斷的那些。 # failed 還會被 xkb_requeue_failed 放回去,所以它是「還在試」。 counts["stuck"] += 1 elif status == "abandoned": # 重試用盡,明確不再試了。跟 failed 分開,否則「還會再試」和 # 「已經不試了」在報表上長得一樣。 counts["abandoned"] += 1 elif status == "skipped": counts["skipped"] += 1 else: counts["unqueued"] += 1 # 還沒進佇列的也算會被處理:每次批次開頭都會跑一次佇列同步,把新的書籤 # 檔加進去。把它們算成「不會被處理」,等於讓剛抓下來的書籤在報表上消失。 counts["actionable"] = counts["queued"] + counts["unqueued"] return counts def unprocessed_transcripts(raw_dir: Path) -> list[str]: if not raw_dir.exists(): return [] pending = [] for path in sorted(raw_dir.glob("*.json")): try: status = json.loads(path.read_text(encoding="utf-8")).get("status") except (OSError, ValueError): status = "unreadable" if status != "completed": pending.append(f"{path.name} ({status})") return pending def main() -> int: pending = uncarded_bookmarks(xkb_paths.BOOKMARKS_DIR, xkb_paths.CARDS_DIR) print(f" bookmarks not yet turned into knowledge: {len(pending)}") by_folder: dict[str, int] = {} for path in pending: by_folder[path.parent.name] = by_folder.get(path.parent.name, 0) + 1 for folder, count in sorted(by_folder.items(), key=lambda kv: -kv[1])[:5]: print(f" {folder}: {count}") transcripts = unprocessed_transcripts(xkb_paths.XKB_DATA_DIR / "youtube-raw") print(f" youtube transcripts awaiting a card: {len(transcripts)}") for item in transcripts[:5]: print(f" {item}") return 0 import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py if __name__ == "__main__": xkb_usage.record(__file__) raise SystemExit(main())