ops:維運與安全

cache / dedup / ingest / watch / serve / security / validate——圍繞主線的服務層
檔案:cache.py · dedup.py · ingest.py · watch.py · serve.py · security.py · validate.py

大方向

主線 pipeline(detect→export)是「一次跑完」,而這七支模組是「反覆使用 / 對外服務」時需要的:快取讓重跑便宜、dedup 讓圖乾淨、ingest 進料、watch 盯變化、serve 對外查詢、security/validate 把關。


1. cache.py — 重跑不重做

load_cached · save_cached · file_hash cache.py 各處SHA256 內容快取的三本柱。

快取是「圖越大越省」的來源之一:

  • file_hash(path, root, cache_root) — 算檔案的內容雜湊(_body_content 只算內容、不含路徑——所以改名也能沿用快取;用 _stat_index 以 stat 簽名快取雜湊本身,避免大檔每次重算)。
  • load_cached(path, root, kind) — 依內容雜湊找快取;載入時會把快取內的絕對路徑/ID重新對齊到目前的 root(_relativize_* / _absolutize_*),所以換機器、搬資料夾都能用。
  • save_cached(path, result, root, kind) — 寫快取,同樣做路徑可攜化。
  • cached_files / clear_cache — 列出快取檔 / 清空。

除此之外還有 prompt_fingerprint(對 prompt 內容算指紋,語意抽取的快取 key 之一)與 cached_word_count(detect 的字數統計快取)。

check_semantic_cache · save_semantic_cache cache.py語意抽取專用快取(增量設計 的一等公民)。

段落式說明:與 AST 快取同構,但存的是 LLM 抽取的結果。文件語料每次只把 uncached_files 送 LLM,其餘從快取載入——這是「每天更新的 1000 檔語料」能維持低成本的核心。


2. dedup.py — 實體去重

deduplicate_entities(nodes, edges, *, communities, …) → (nodes, edges) dedup.py:320完整去重管線:ID 預合併 → label 模糊合併 → 邊重接。

這是 設計文件 的落地實作,比設計更周全:

  1. 跨專案防護:節點帶 repo 欄位且超過一個 repo → 直接 raise。不同 repo 的 label 撞名是巧合,絕不能靠字串相似度合併。
  2. ID 預合併:每個 ID 只留一個節點,生還者是「定義這個 ID 的節點」(其 source_file 就是 ID 編碼的那個檔),不是「第一個看到的」——這樣 chunk 順序不決定誰生還(issue #1851)。同源重複的缺失屬性補進生還者(AST 結構與語意補充共存)。
  3. 接著是 label 層的模糊合併(entropy gate → MinHash/LSH → Jaro-Winkler → community boost → union-find → 可選 LLM 仲裁),每步都有對應的 _* helper。
  4. 最後把邊重接到生還者。
設計重點_collision_rank 提供「總序」,讓「誰是生還者」與節點到達順序無關——這是去重正確性的關鍵,也是被反覆強調的 issue #1851。
_entropy · _shingles · _make_minhash · _is_variant_pair dedup.py 各處去重管線的零件。

段落式說明:

  • _entropy(label) — 算 label 的資訊熵(bits/char),短歧義名(AIx)太低就不做模糊比對。
  • _shingles(text, k) / _make_minhash(text) — 3-gram shingles + MinHash(128 排列),供 LSH blocking 用 O(n) 產生候選對。
  • _is_variant_pair(a, b) — 判斷是否為「變體對」(錯字、複數、空格等)。
  • _crossfile_fileanchored_blocked — 跨檔 + 檔錨定的節點不作模糊合併(避免 Account 型別誤併)。

3. ingest.py — 進料

ingest(url, target_dir, author, contributor) → Path ingest.py:218抓 URL 並存進 corpus(graphify add 的實作)。

段落式說明:_detect_url_type 先判斷 URL 類型(網頁 / arxiv / tweet / 影音 / 一般二進位),再分派到 _fetch_webpage(HTML→Markdown)、_fetch_arxiv(論文)、_fetch_tweet_download_binary。所有抓取都走 security 的 safe_fetch


4. watch.py — 盯變化

watch · _reconcile_existing_graph · _check_shrink … watch.py(1845 行)監看資料夾並在程式碼變更時重建圖。

段落式說明(這支很大,挑重點):

  • 用 pending 佇列(_queue_pending / _drain_pending)把變更先落地、再重建成圖,避免熱循環。
  • _rebuild_lock — 防止併發重建。
  • _reconcile_existing_graph — 把既有圖與新抽取結果調和(增量更新)。
  • _check_shrink — 與 to_json 的 shrink guard 對應,重建結果更小就不覆寫。
  • 文件頂部有一串 resource limits / git_head / 路徑處理的 helper。

5. serve.py — 對外查詢

start_server / MCP 工具 · _score_nodes · _query_terms … serve.py把圖暴露成 MCP server;query 走加權評分而非暴力搜索。

段落式說明:python -m graphify.serve graphify-out/graph.json 啟動 MCP stdio server,提供 query_graphget_nodeget_neighborsshortest_path 等工具。

query 不是簡單關鍵字比對,而是一套加權評分:_query_terms 切詞(含中文分詞,jieba)→ _compute_idf 算 IDF → _trigram_index 建三字母索引加速候選 → _score_nodes / _score_query 評分。還有 _GraphContextCache 做多專案圖的快取、_load_graph 對損壞圖的復原訊息(見 SECURITY.md)。


6. security.py — 安全把關

validate_url(url) str → str security.py:103SSRF 防護第一關:只准 http/https、擋私有 IP 與雲端 metadata。

SSRF 防護的解析:

  1. urlparse 檢查 scheme 是否在 _ALLOWED_SCHEMES(http/https)——file://ftp://data: 全部擋掉。
  2. 擋已知雲端 metadata hostname(169.254.169.254 等,_BLOCKED_HOSTS)。
  3. DNS 解析後驗證 IPsocket.getaddrinfo 把 hostname 解析成 IP,逐一檢查是否落在私有/保留網段(_ip_is_blocked,含 127.x、10.x、169.254.x…)。

光這樣還不夠——validate_url 只是入口。真正的連線用 _SSRFGuardedHTTP(S)Connection 子類別(下一個 fn-block)。

112 if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
113 raise ValueError(f"Blocked URL scheme '{parsed.scheme}' …")
121 if hostname.lower() in _BLOCKED_HOSTS: raise ValueError(…)
129 infos = socket.getaddrinfo(hostname, None, …)
133 if _ip_is_blocked(ip): raise ValueError(…)
_SSRFGuardedHTTPConnection 家族 security.py:180-243防 DNS-rebind:連線時驗證「真的連到的那個 IP」。

這是一段很漂亮的進階防禦。註解說得很清楚:不 monkey-patch 全域 socket.getaddrinfo(那在並發下是 thread-safe 的 TOCTOU 隱患),而是子類別化 HTTP(S) connection——每個連線只解析一次 DNS、驗證結果 IP、然後連到那個精確 IP。沒有第二次解析,所以 DNS-rebind 攻擊無法在驗證與連線之間換成私有位址。

教學價值:這說明「驗證 URL」與「安全連線」是兩回事——前者是入口檢查,後者是對抗 TOCTOU 的防禦。

safe_fetch · safe_fetch_text · validate_graph_path · sanitize_label security.py 各處抓取上限、路徑驗證、label 消毒。

段落式說明:

  • safe_fetch(url, max_bytes=50MB, timeout=30) — 串流抓取,超過 50MB 中止,非 2xx 拋 HTTPError(錯誤頁不當內容)。
  • safe_fetch_text(url, max_bytes=10MB) — 文字版。
  • validate_graph_path(path, base) — 解析路徑並要求落在 graphify-out/ 內(防 MCP 路徑穿越)。
  • sanitize_label(text) — 去控制字元、上限 256 字元、HTML-escape(防 XSS 與 prompt injection)。
  • check_graph_file_size_cap — graph.json 的 512 MiB 上限。

7. validate.py — 輸入把關

validate_extraction(data) · assert_valid(data) dict → list[str] validate.py:10在 build 前檢查抽取結果的 schema。

段落式說明:只有 95 行的模組。檢查每個節點/邊的必要欄位(idlabelsource_filesourcetargetrelation),回傳錯誤訊息清單;assert_valid 在錯誤時直接 raise。build_graph() 消費前會先跑它——schema 錯誤就不組圖。

看完這頁你應該能說出:快取的 key 是什麼、dedup 為什麼拒絕跨專案、validate_url 的三道檢查、DNS-rebind 是怎麼被擋的。這裡幾乎每一支模組都是「設計文件(incrementalsecurity)→ 實作」的對照。

教學解說

ops 層是圍繞主線 pipeline 的服務:cache(快取)/ dedup(去重)/ ingest(進料)/ watch(監控)/ serve(服務)/ security(安全)/ validate(驗證)。這些模組讓 Graphify 從「能跑」變成「好用」。

cache.py 實作 semantic cache,用內容雜湊為 key,避免重複呼叫 LLM。dedup.py 實作七步去重管線。ingest.py 負責從 URL 下載內容。watch.py 監控目錄變更。serve.py 提供 MCP stdio server。security.py 與 validate.py 負責所有驗證。

安全相關的函數值得特別關注:validate_url() 擋 SSRF、validate_graph_path() 擋路徑穿越、sanitize_label() 擋 XSS 與 prompt injection。

Worked Example:測試 cache 與 dedup

  1. 首次執行graphify extract my-project/,觀察 token 消耗。
  2. 再次執行:不修改任何檔案,再次執行 graphify extract my-project/。觀察 token 消耗應該為 0(全部從快取讀取)。
  3. 修改檔案:修改一個檔案,再次執行。觀察只有修改的檔案被重新處理。
  4. 檢查 dedup:如果有重複的 label(例如同名類別在不同檔案),觀察 dedup 如何合併。
關鍵觀察:cache 用 SHA256 內容雜湊,改名檔案仍會命中快取(因為內容沒變)。

常見錯誤與診斷

錯誤訊息原因解決方式
cache: corruption detected快取檔案損壞刪除 graphify-out/cache/ 重新執行
dedup: MinHash errordatasketch 套件問題執行 pip install datasketch
ingest: fetch failedURL 無法存取檢查 URL 是否正確,或是否有網路問題
serve: JSON decode errorgraph.json 損壞重新執行 graphify extract 產生新的 graph.json
security: SSRF blocked嘗試存取私有 IP這是正常的,安全驗證在運作

練習與驗收清單

① 進階真實情境 Worked Example:為「每日同步的內部 wiki」架設快取 + 增量 + 監控

前面是「測試 cache 與 dedup」;這次是把它們串成一個維運系統:你的內部 wiki 有 1000 份 markdown + 幾個 SQLite 資料 dump,每晚從上游同步,你要保證「圖每天新鮮、成本受控、壞了能自癒」。

  1. 啟用增量graphify extract wiki/ 讓 manifest 建起來;之後 nightly 重跑自動走 detect_incremental——只抽 new_files,其餘 semantic cache 命中。
  2. SQLite 資料的處理:把資料 dump 轉成可分析的 markdown/CSV 進語料(SQLite 本身不是抽取目標)——或用 docker-mcp-sqlite 那套把結果倒進 SQLite 供即席查詢。
  3. 監控健康:檢查 nightly 輸出行的三個數字——changed / cached / deleted;deleted 突增代表上游砍了大量文件,build_merge(prune_sources=deleted) 會自動剪圖。
  4. 成本治理:設 --max-spend;觀察每週 token 統計,若「cached」比例長期偏低,檢查 prompt_fingerprint 是否意外失效。
  5. 自癒流程:快取損壞(corruption)時清 graphify-out/cache/ 重抽;serve 的 _load_graph 對壞 graph.json 印復原訊息而不是當機。
為什麼選這條路徑:這是 ops 層七支模組的「合體技」——cache 省錢、dedup 保圖乾淨、ingest 進料、watch 可換成排程、serve 對外、security/validate 把關。單獨測每個函數只能證明「能動」,把它們串成 nightly job 才證明「能維運」。

② 深入原理擴充:dedup 的「生還者選擇」為什麼是 issue #1851

dedup 最容易被誤解的細節是「合併時誰活下來」:

大家以為建圖正確、但其實有誤的案例:以為「dedup 只做字串相似度,所以同名的一定會合併」。實際上同名不一定合併、異名也可能誤併:熵門檻擋掉 AI/DB 這種短歧義名(太危險不自動併);而 Jaro-Winkler 只看字元相似,UserRepositoryUserRepo 可能因「同社群加分」跨過門檻被誤併——尤其當兩者真的在同一個社群(經常 co-occur)時,+0.05 可能就是壓垮駱駝的那根稻草。判讀去重結果,要看「生還者 label 是否涵蓋原意」而不是「看起來像不像」。

③ 診斷式疑難排解表

症狀可能原因解決方案
cache 命中率長期偏低prompt_fingerprint 隨每次 prompt 變動(未版本化 extraction-spec)或內容 hash 含非內容欄位版本化 prompt;確認 file_hash_body_content 不含路徑
dedup 把明顯不同的概念合併Jaro-Winkler 太鬆 + 同社群加分疊加跨過門檻調高閾值或檢查社群加分是否該納入;考慮 --dedup-llm 仲裁
跨 repo 合併圖時 raisededup 的跨專案防護啟動(不同 repo 撞名)這是紅線防護——改用 merge-graphs 工具而非靠 dedup 併
serve 讀到「損壞的 graph.json」寫入中斷(沒走原子寫)或外部工具改壞格式重新 graphify extract;確認 export 走 write_json_atomic
watch 熱循環(重建一直觸發)重建過程自己寫入 watched 目錄,或 pending 佇列沒 drain確認 _queue_pending/_drain_pending 落地;檢查 _rebuild_lock 是否生效

④ 進階挑戰題

  1. 「生還者 = 定義該 ID 的節點」確保與到達順序無關。請設計一個反例:什麼情況下「定義者」也會有歧義,導致生還者仍不唯一?_collision_rank 需要補什麼 tiebreak?
  2. 跨專案防護直接 raise。如果產品需求變成「同 repo 內允許跨子專案去重、跨 repo 禁止」,你會把這條規則參數化還是拆成兩個函數?為什麼?
  3. prompt_fingerprint 的取捨是「prompt 改 → 快取全失效」。若你改了 extraction-spec 一個字,1000 份文件全部要重抽。請設計一個「部分失效」策略:怎麼讓快取只對「語意相關的欄位」失效?