#!/bin/bash # build-graph-data.sh — 扫描 wiki/ 生成交互式图谱所需的 graph-data.json # # 用法:bash scripts/build-graph-data.sh [output_path] # wiki_root 包含 wiki/ 子目录的知识库根路径 # output_path 可选,默认 /wiki/graph-data.json # # 环境变量: # LLM_WIKI_TEST_MODE=1 启用稳定输出(nodes/edges 按 id 字典序 + 时间戳固定) # # 退出码:0 成功;1 路径/依赖错误;2 wiki 结构不完整 set -eu shopt -s nullglob SCRIPT_DIR="${BASH_SOURCE[0]%/*}" [ "$SCRIPT_DIR" = "${BASH_SOURCE[0]}" ] && SCRIPT_DIR="." SCRIPT_DIR="$(cd "$SCRIPT_DIR" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/shared-config.sh" WIKI_ROOT="${1:-.}" DEFAULT_OUTPUT="$WIKI_ROOT/wiki/graph-data.json" OUTPUT="${2:-$DEFAULT_OUTPUT}" SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" HELPER="$SKILL_DIR/scripts/graph-analysis.js" MAX_CONTENT_BYTES=$((2 * 1024 * 1024)) MAX_CONTENT_LINES=500 MAX_INSIGHT_NODES=250 MAX_INSIGHT_EDGES=1000 command -v jq >/dev/null 2>&1 || { echo "ERROR: jq is not installed. Install it via:" >&2 print_install_hint jq exit 1 } command -v node >/dev/null 2>&1 || { echo "ERROR: node is not installed. Install it via:" >&2 print_install_hint node exit 1 } [ -f "$HELPER" ] || { echo "ERROR: 找不到图谱分析 helper:$HELPER" >&2 echo " 重装 skill 可修复(bash install.sh --platform claude)" >&2 exit 1 } WIKI_DIR="$WIKI_ROOT/wiki" [ -d "$WIKI_DIR" ] || { echo "ERROR: wiki 目录不存在:$WIKI_DIR" >&2 echo " 请先运行 init-wiki.sh 初始化知识库。" >&2 exit 2 } WIKI_ROOT_ABS="$(cd "$WIKI_ROOT" && pwd)" TMPDIR=$(mktemp -d -t llm-wiki-graph.XXXXXX) trap 'rm -rf "$TMPDIR"' EXIT if [ "${LLM_WIKI_TEST_MODE:-0}" = "1" ]; then BUILD_DATE="2026-01-01T00:00:00Z" else BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" fi WIKI_TITLE="" if [ -f "$WIKI_ROOT/purpose.md" ]; then WIKI_TITLE=$(awk '/^# / { sub(/^# +/, ""); print; exit }' "$WIKI_ROOT/purpose.md") fi [ -n "$WIKI_TITLE" ] || WIKI_TITLE=$(basename "$(cd "$WIKI_ROOT" && pwd)") NODES_TSV="$TMPDIR/nodes.tsv" : > "$NODES_TSV" scan_kind() { local subdir="$1" type="$2" local dir="$WIKI_DIR/$subdir" [ -d "$dir" ] || return 0 local f id label while IFS= read -r f; do [ -f "$f" ] || continue id=$(basename "$f" .md) case "$id" in index|log|purpose|.wiki-schema|README) continue ;; esac label=$(awk '/^# / { sub(/^# +/, ""); gsub(/[[:space:]]+$/, ""); print; exit }' "$f") [ -n "$label" ] || label="$id" printf '%s\t%s\t%s\t%s\n' "$id" "$label" "$type" "$f" >> "$NODES_TSV" done < <(find "$dir" -type f -name '*.md' | LC_ALL=C sort) } scan_kind entities entity scan_kind topics topic scan_kind sources source scan_kind comparisons comparison scan_kind synthesis synthesis scan_kind queries query if [ ! -s "$NODES_TSV" ]; then mkdir -p "$(dirname "$OUTPUT")" OUTPUT_TMP="$TMPDIR/graph-data.empty.json" jq -n \ --arg build_date "$BUILD_DATE" \ --arg wiki_title "$WIKI_TITLE" \ '{ meta: { build_date: $build_date, wiki_title: $wiki_title, total_nodes: 0, total_edges: 0, initial_view: [], degraded: false, insights_degraded: false }, nodes: [], edges: [], insights: { surprising_connections: [], isolated_nodes: [], bridge_nodes: [], sparse_communities: [], meta: { degraded: false, node_count: 0, edge_count: 0, max_insight_nodes: 250, max_insight_edges: 1000 } }, learning: { version: 1, entry: { recommended_start_node_id: null, recommended_start_reason: null, default_mode: "global" }, views: { path: { enabled: false, start_node_id: null, node_ids: [], degraded: true }, community: { enabled: false, community_id: null, label: null, node_ids: [], is_weak: false, degraded: true }, global: { enabled: true, node_ids: [], degraded: false } }, communities: [], degraded: { path_to_community: true, community_to_global: true } } }' > "$OUTPUT_TMP" mv "$OUTPUT_TMP" "$OUTPUT" echo "空图谱已写入:${OUTPUT}(wiki/ 下无可纳入节点)" exit 0 fi EDGES_RAW="$TMPDIR/edges_raw.tsv" : > "$EDGES_RAW" while IFS=$'\t' read -r id label type path; do awk -v src="$id" ' { line = $0 conf = "" rel = "" if (match(line, //)) { kind_str = substr(line, RSTART, RLENGTH) if (match(kind_str, /[A-Z]+/)) { conf = substr(kind_str, RSTART, RLENGTH) } } if (match(line, /") if (rel_end > 0) { rel = substr(rel_str, 1, rel_end - 1) gsub(/^[[:space:]]+|[[:space:]]+$/, "", rel) } } rest = line while (match(rest, /\[\[[^]]+\]\]/)) { inner = substr(rest, RSTART + 2, RLENGTH - 4) rest = substr(rest, RSTART + RLENGTH) n = index(inner, "|") if (n > 0) inner = substr(inner, 1, n - 1) gsub(/^[[:space:]]+|[[:space:]]+$/, "", inner) if (inner == "" || inner == src) continue print src "\t" NR "\t" inner "\t" conf "\t" rel } } ' "$path" >> "$EDGES_RAW" done < "$NODES_TSV" VALID_IDS="$TMPDIR/valid_ids.txt" cut -f1 "$NODES_TSV" | sort -u > "$VALID_IDS" EDGES_TSV="$TMPDIR/edges.tsv" # 合并同一 from+to 的多条 raw edges: # - 第一次遇到时记录(有 conf 就用 conf,无 conf 就留空 → 最终默认 EXTRACTED) # - 后续遇到带显式 conf 的条目时 **升级**(覆盖之前的空值或 EXTRACTED 默认) # - 若后续遇到多条不同的非空 conf,保留首个非空(按首次显式标注优先) # # 这解决了"同一对节点被多次 [[]] 引用(正文 + 相关页面列表)时, # 首次出现的空 conf 会永久锁定 edge type 为 EXTRACTED"的问题。 awk -F'\t' -v valids="$VALID_IDS" ' BEGIN { while ((getline line < valids) > 0) valid[line] = 1 close(valids) } { from = $1; to = $3; conf = $4; rel = $5 if (!(to in valid)) next if (from == to) next key = from "\t" to if (!(key in seen)) { seen[key] = 1 saved_conf[key] = conf # 可能为空,在 END 中兜底为 EXTRACTED saved_rel[key] = rel # 可能为空,在 END 中兜底为依赖 order[++count] = key } else if (conf != "" && saved_conf[key] == "") { # 升级:之前未见显式 conf(留空),现在有,采用 saved_conf[key] = conf } if (rel != "" && saved_rel[key] == "") { # 升级:之前未见显式 relation type,现在有,采用 saved_rel[key] = rel } } END { for (i = 1; i <= count; i++) { split(order[i], parts, "\t") t = saved_conf[order[i]] if (t != "EXTRACTED" && t != "INFERRED" && t != "AMBIGUOUS") t = "EXTRACTED" r = saved_rel[order[i]] if (r == "") r = "依赖" print parts[1] "\t" parts[2] "\t" t "\t" r } } ' "$EDGES_RAW" > "$EDGES_TSV" TOTAL_SIZE=0 while IFS=$'\t' read -r id label type path; do sz=$(wc -c < "$path" 2>/dev/null || echo 0) TOTAL_SIZE=$((TOTAL_SIZE + sz)) done < "$NODES_TSV" DEGRADE=0 if [ "$TOTAL_SIZE" -gt "$MAX_CONTENT_BYTES" ]; then DEGRADE=1 fi NODES_JSONL="$TMPDIR/nodes.jsonl" : > "$NODES_JSONL" while IFS=$'\t' read -r id label type path; do abs_path=$(cd "$(dirname "$path")" && pwd)/$(basename "$path") rel_path="${abs_path#"$WIKI_ROOT_ABS"/}" jq -n \ --arg id "$id" \ --arg label "$label" \ --arg type "$type" \ --arg source_path "$abs_path" \ --arg rel_path "$rel_path" \ '{ id: $id, label: $label, type: $type, source_path: $rel_path, _file_path: $source_path }' >> "$NODES_JSONL" done < "$NODES_TSV" EDGES_JSONL="$TMPDIR/edges.jsonl" : > "$EDGES_JSONL" idx=0 while IFS=$'\t' read -r from to etype relation_type; do idx=$((idx + 1)) jq -n \ --arg id "e$idx" \ --arg from "$from" \ --arg to "$to" \ --arg etype "$etype" \ --arg relation_type "$relation_type" \ '{id: $id, from: $from, to: $to, type: $etype, confidence: $etype, relation_type: $relation_type}' >> "$EDGES_JSONL" done < "$EDGES_TSV" if [ "${LLM_WIKI_TEST_MODE:-0}" = "1" ]; then jq -s 'sort_by(.id)' "$NODES_JSONL" > "$TMPDIR/nodes.raw.json" jq -s 'sort_by(.from, .to, .type) | to_entries | map(.value + {id: ("e" + ((.key + 1) | tostring))})' \ "$EDGES_JSONL" > "$TMPDIR/edges.raw.json" else jq -s '.' "$NODES_JSONL" > "$TMPDIR/nodes.raw.json" jq -s '.' "$EDGES_JSONL" > "$TMPDIR/edges.raw.json" fi ANALYSIS_JSON="$TMPDIR/analysis.json" if ! node "$HELPER" \ "$TMPDIR/nodes.raw.json" \ "$TMPDIR/edges.raw.json" \ "$ANALYSIS_JSON" \ "$DEGRADE" \ "$MAX_CONTENT_LINES" \ "$MAX_INSIGHT_NODES" \ "$MAX_INSIGHT_EDGES"; then echo "ERROR: 图谱分析 helper 执行失败:$HELPER" >&2 exit 1 fi jq -e ' (.nodes | type) == "array" and (.edges | type) == "array" and (.insights | type) == "object" and (.insights.surprising_connections | type) == "array" and (.insights.isolated_nodes | type) == "array" and (.insights.bridge_nodes | type) == "array" and (.insights.sparse_communities | type) == "array" and (.learning | type) == "object" ' "$ANALYSIS_JSON" > /dev/null 2>&1 || { echo "ERROR: 图谱分析 helper 返回坏 JSON:$ANALYSIS_JSON" >&2 exit 1 } if [ "${LLM_WIKI_TEST_MODE:-0}" = "1" ]; then jq '.nodes | sort_by(.id)' "$ANALYSIS_JSON" > "$TMPDIR/nodes.sorted.json" jq '.edges | sort_by(.from, .to, .type) | to_entries | map(.value + {id: ("e" + ((.key + 1) | tostring))})' "$ANALYSIS_JSON" > "$TMPDIR/edges.sorted.json" else jq '.nodes' "$ANALYSIS_JSON" > "$TMPDIR/nodes.sorted.json" jq '.edges' "$ANALYSIS_JSON" > "$TMPDIR/edges.sorted.json" fi INITIAL_VIEW=$(jq \ --argjson nodes "$(cat "$TMPDIR/nodes.sorted.json")" \ ' . as $edges | ( reduce $edges[] as $e ( {}; .[$e.from] = (.[$e.from] // 0) + 1 | .[$e.to] = (.[$e.to] // 0) + 1 ) ) as $deg | ($nodes | group_by(.community // "_")) as $groups | ([ $groups[] | max_by(($deg[.id] // 0)) | .id ]) as $reps | ( $nodes | sort_by(- ($deg[.id] // 0)) | map(.id) | map(select(. as $x | $reps | index($x) | not)) ) as $rest | ($reps + $rest)[0:30] ' \ "$TMPDIR/edges.sorted.json") NODE_COUNT=$(jq 'length' "$TMPDIR/nodes.sorted.json") EDGE_COUNT=$(jq 'length' "$TMPDIR/edges.sorted.json") INSIGHTS_DEGRADED=$(jq '.insights.meta.degraded == true' "$ANALYSIS_JSON") mkdir -p "$(dirname "$OUTPUT")" OUTPUT_TMP="$TMPDIR/graph-data.final.json" jq -n \ --arg build_date "$BUILD_DATE" \ --arg wiki_title "$WIKI_TITLE" \ --argjson total_nodes "$NODE_COUNT" \ --argjson total_edges "$EDGE_COUNT" \ --argjson initial_view "$INITIAL_VIEW" \ --argjson nodes "$(cat "$TMPDIR/nodes.sorted.json")" \ --argjson edges "$(cat "$TMPDIR/edges.sorted.json")" \ --argjson insights "$(jq '.insights' "$ANALYSIS_JSON")" \ --argjson learning "$(jq '.learning' "$ANALYSIS_JSON")" \ --argjson degraded "$DEGRADE" \ --argjson insights_degraded "$INSIGHTS_DEGRADED" \ '{ meta: { build_date: $build_date, wiki_title: $wiki_title, total_nodes: $total_nodes, total_edges: $total_edges, initial_view: $initial_view, degraded: ($degraded == 1), insights_degraded: $insights_degraded }, nodes: $nodes, edges: $edges, insights: $insights, learning: $learning }' > "$OUTPUT_TMP" mv "$OUTPUT_TMP" "$OUTPUT" echo "图谱数据已生成:$OUTPUT" echo " 节点:$NODE_COUNT" echo " 关联:$EDGE_COUNT" echo " 初始视图:$(echo "$INITIAL_VIEW" | jq 'length') 个节点" [ "$DEGRADE" = "1" ] && echo " ⚠ 降级模式:内嵌内容 > 2MB,每节点仅保留前 ${MAX_CONTENT_LINES} 行" [ "$INSIGHTS_DEGRADED" = "true" ] && echo " ⚠ 洞察降级:图规模超出预算,仅保留基础权重与社区" exit 0