#!/bin/bash # llm-wiki 缓存脚本 set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/shared-config.sh" usage() { cat <<'EOF' 用法: bash scripts/cache.sh check bash scripts/cache.sh update bash scripts/cache.sh invalidate EOF } require_file() { local file_path="$1" [ -n "$file_path" ] || { usage exit 1 } [ -f "$file_path" ] || { echo "文件不存在:$file_path" >&2 exit 1 } } find_wiki_root() { local file_path="$1" local dir parent dir="$(cd "$(dirname "$file_path")" && pwd)" while true; do if [ -f "$dir/.wiki-cache.json" ] || [ -f "$dir/.wiki-schema.md" ]; then printf '%s\n' "$dir" return 0 fi parent="$(dirname "$dir")" [ "$parent" = "$dir" ] && return 1 dir="$parent" done } cache_file_path() { printf '%s/.wiki-cache.json\n' "$1" } ensure_cache_file() { local cache_file="$1" if [ ! -f "$cache_file" ]; then cat > "$cache_file" <<'EOF' { "version": 1, "entries": {} } EOF fi } relative_path() { require_python_cmd "$PYTHON_CMD" - "$1" "$2" <<'PY' import os import sys print(os.path.relpath(os.path.realpath(sys.argv[2]), os.path.realpath(sys.argv[1]))) PY } normalized_source_page() { local wiki_root="$1" local source_page="$2" if [ -z "$source_page" ]; then printf '%s\n' "" return 0 fi case "$source_page" in /*) require_python_cmd "$PYTHON_CMD" - "$wiki_root" "$source_page" <<'PY' import os import sys wiki_root = os.path.realpath(sys.argv[1]) source_page = os.path.realpath(sys.argv[2]) try: common = os.path.commonpath([wiki_root, source_page]) except ValueError: common = "" if common == wiki_root: print(os.path.relpath(source_page, wiki_root)) else: print(sys.argv[2]) PY ;; *) printf '%s\n' "$source_page" ;; esac } file_hash() { require_python_cmd "$PYTHON_CMD" - "$1" "$2" <<'PY' import hashlib import pathlib import sys relative_path = sys.argv[1].encode("utf-8") file_path = pathlib.Path(sys.argv[2]) content = file_path.read_bytes() digest = hashlib.sha256(relative_path + b"\0" + content).hexdigest() print(f"sha256:{digest}") PY } cache_check() { local file_path="$1" local wiki_root cache_file relative_path_value current_hash result require_file "$file_path" wiki_root="$(find_wiki_root "$file_path")" || { echo "未找到知识库根目录:$file_path" >&2 exit 1 } cache_file="$(cache_file_path "$wiki_root")" if [ ! -f "$cache_file" ]; then printf 'MISS\n' return 0 fi require_python_cmd relative_path_value="$(relative_path "$wiki_root" "$file_path")" current_hash="$(file_hash "$relative_path_value" "$file_path")" result="$( "$PYTHON_CMD" - "$cache_file" "$wiki_root" "$relative_path_value" "$current_hash" <<'PY' import hashlib import json import os import pathlib import sys cache_file, wiki_root, relative_path, current_hash = sys.argv[1:5] with open(cache_file, "r", encoding="utf-8") as fh: data = json.load(fh) entry = data.get("entries", {}).get(relative_path) # 无 cache entry → 尝试自愈(exact filename stem match + source_path 验证) if not entry: raw_stem = pathlib.Path(relative_path).stem sources_dir = os.path.join(wiki_root, "wiki", "sources") if os.path.isdir(sources_dir): for f in os.listdir(sources_dir): if pathlib.Path(f).stem == raw_stem and f.endswith(".md"): source_page = os.path.join("wiki", "sources", f) source_abs = os.path.join(wiki_root, source_page) # 验证 source 页面的 source_path frontmatter 是否指向当前 raw 文件 source_path_match = False try: with open(source_abs, "r", encoding="utf-8") as sf: in_frontmatter = False for line in sf: stripped = line.strip() if stripped == "---": if in_frontmatter: break # end of frontmatter in_frontmatter = True continue if in_frontmatter and stripped.startswith("source_path:"): fm_value = stripped.split(":", 1)[1].strip() # 匹配相对路径的末尾部分 if relative_path.endswith(fm_value) or fm_value.endswith(relative_path) or fm_value == relative_path: source_path_match = True break except (OSError, UnicodeDecodeError): pass if not source_path_match: # stem 匹配但 source_path 不一致 → 不信任,需要验证 print("MISS:repaired_needs_verify") raise SystemExit(0) # stem + source_path 都匹配 → 安全自愈 timestamp = __import__("datetime").datetime.now(__import__("datetime").timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") entries = data.setdefault("entries", {}) entries[relative_path] = { "hash": current_hash, "ingested_at": timestamp, "source_page": source_page, } tmp_file = cache_file + ".tmp" with open(tmp_file, "w", encoding="utf-8") as fh2: json.dump(data, fh2, ensure_ascii=False, indent=2) fh2.write("\n") os.replace(tmp_file, cache_file) print("HIT(repaired)") raise SystemExit(0) print("MISS:no_entry") raise SystemExit(0) if entry.get("hash") != current_hash: print("MISS:hash_changed") raise SystemExit(0) source_page = entry.get("source_page") if not source_page: print("MISS:no_entry") raise SystemExit(0) source_path = source_page if not os.path.isabs(source_path): source_path = os.path.join(wiki_root, source_path) if not os.path.isfile(source_path): print("MISS:no_source") else: print("HIT") PY )" printf '%s\n' "$result" } cache_update() { local file_path="$1" local source_page="$2" local wiki_root cache_file relative_path_value current_hash normalized_source timestamp require_file "$file_path" wiki_root="$(find_wiki_root "$file_path")" || { echo "未找到知识库根目录:$file_path" >&2 exit 1 } cache_file="$(cache_file_path "$wiki_root")" ensure_cache_file "$cache_file" require_python_cmd relative_path_value="$(relative_path "$wiki_root" "$file_path")" current_hash="$(file_hash "$relative_path_value" "$file_path")" normalized_source="$(normalized_source_page "$wiki_root" "$source_page")" timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$PYTHON_CMD" - "$cache_file" "$relative_path_value" "$current_hash" "$timestamp" "$normalized_source" <<'PY' import json import os import sys cache_file, relative_path, file_hash_value, timestamp, source_page = sys.argv[1:6] with open(cache_file, "r", encoding="utf-8") as fh: data = json.load(fh) entries = data.setdefault("entries", {}) entries[relative_path] = { "hash": file_hash_value, "ingested_at": timestamp, "source_page": source_page, } tmp_file = cache_file + ".tmp" with open(tmp_file, "w", encoding="utf-8") as fh: json.dump(data, fh, ensure_ascii=False, indent=2) fh.write("\n") os.replace(tmp_file, cache_file) PY printf 'UPDATED\n' } cache_invalidate() { local file_path="$1" local wiki_root cache_file relative_path_value # 不调用 require_file:文件可能已被删除(级联删除场景) # 直接通过路径查找缓存条目 wiki_root="$(find_wiki_root "$file_path")" || { echo "未找到知识库根目录:$file_path" >&2 exit 1 } cache_file="$(cache_file_path "$wiki_root")" if [ ! -f "$cache_file" ]; then printf 'INVALIDATED\n' return 0 fi require_python_cmd relative_path_value="$(relative_path "$wiki_root" "$file_path")" "$PYTHON_CMD" - "$cache_file" "$relative_path_value" <<'PY' import json import os import sys cache_file, relative_path = sys.argv[1:3] with open(cache_file, "r", encoding="utf-8") as fh: data = json.load(fh) data.setdefault("entries", {}).pop(relative_path, None) tmp_file = cache_file + ".tmp" with open(tmp_file, "w", encoding="utf-8") as fh: json.dump(data, fh, ensure_ascii=False, indent=2) fh.write("\n") os.replace(tmp_file, cache_file) PY printf 'INVALIDATED\n' } command_name="${1:-}" case "$command_name" in check) [ "$#" -eq 2 ] || { usage; exit 1; } cache_check "$2" ;; update) [ "$#" -eq 3 ] || { usage; exit 1; } cache_update "$2" "$3" ;; invalidate) [ "$#" -eq 2 ] || { usage; exit 1; } cache_invalidate "$2" ;; *) usage exit 1 ;; esac