353 lines
8.9 KiB
Bash
Executable File
353 lines
8.9 KiB
Bash
Executable File
#!/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 <file>
|
||
bash scripts/cache.sh update <file> <source_page>
|
||
bash scripts/cache.sh invalidate <file>
|
||
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
|