@jerryjiao/knowflow 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/CONTRIBUTING.md +41 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/README.zh-CN.md +176 -0
- package/SECURITY.md +18 -0
- package/bin/knowflow.js +367 -0
- package/docs/README-CN.md +10 -0
- package/docs/architecture/system-architecture.md +150 -0
- package/docs/assets/knowflow-graph-demo.png +0 -0
- package/docs/assets/knowflow-social-preview.png +0 -0
- package/docs/assets/logo.png +0 -0
- package/docs/contributing.md +131 -0
- package/docs/methodology/llm-wiki-methodology.md +110 -0
- package/docs/reference/data-model.md +200 -0
- package/examples/quickstart.md +60 -0
- package/package.json +57 -0
- package/scripts/batch-ingest.cjs +455 -0
- package/scripts/bookmark_sync.sh +150 -0
- package/scripts/enrich-wiki.js +683 -0
- package/scripts/graph_builder.py +612 -0
- package/scripts/graph_relation_labeler.py +191 -0
- package/scripts/ingest.sh +162 -0
- package/scripts/pipeline.sh +73 -0
- package/scripts/tags-builder.mjs +75 -0
- package/scripts/vector-store.mjs +717 -0
- package/scripts/vector_store.py +225 -0
- package/scripts/wechat_sync.sh +199 -0
- package/scripts/wiki-auto-fix.sh +238 -0
- package/scripts/wiki-health.py +285 -0
- package/scripts/wiki-health.sh +335 -0
- package/templates/comparison.md +31 -0
- package/templates/concept.md +37 -0
- package/templates/entity.md +32 -0
- package/templates/source.md +36 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
KnowFlow Graph Relation Labeler
|
|
4
|
+
Infers semantic relation types from wikilink context and updates graph.json
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import re, json, os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
|
|
12
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
13
|
+
DEFAULT_WIKI_DIR = os.environ.get("KNOWFLOW_WIKI_DIR", str(PROJECT_ROOT / "wiki"))
|
|
14
|
+
DEFAULT_GRAPH_OUTPUT = Path(os.environ.get("KNOWFLOW_GRAPH_OUTPUT", PROJECT_ROOT / "graph" / "graph.html"))
|
|
15
|
+
DEFAULT_GRAPH_JSON = os.environ.get("KNOWFLOW_GRAPH_JSON", str(DEFAULT_GRAPH_OUTPUT.with_suffix(".json")))
|
|
16
|
+
|
|
17
|
+
# ── Relation inference rules ──────────────────────────────
|
|
18
|
+
|
|
19
|
+
# Category-based default relations
|
|
20
|
+
CATEGORY_RELATIONS = {
|
|
21
|
+
# source → entity: "cites" or "describes"
|
|
22
|
+
("sources", "entities"): "描述",
|
|
23
|
+
("sources", "concepts"): "阐述",
|
|
24
|
+
("sources", "comparisons"): "参考",
|
|
25
|
+
("sources", "sources"): "相关",
|
|
26
|
+
# entity → concept: "implements" or "exemplifies"
|
|
27
|
+
("entities", "concepts"): "体现",
|
|
28
|
+
("entities", "entities"): "关联",
|
|
29
|
+
("entities", "sources"): "来源",
|
|
30
|
+
# concept → entity: "applies_to" or "governs"
|
|
31
|
+
("concepts", "entities"): "适用于",
|
|
32
|
+
("concepts", "concepts"): "相关",
|
|
33
|
+
("concepts", "sources"): "引用",
|
|
34
|
+
# comparison → others
|
|
35
|
+
("comparisons", "entities"): "对比",
|
|
36
|
+
("comparisons", "concepts"): "评估",
|
|
37
|
+
("comparisons", "sources"): "依据",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def detect_category(filepath):
|
|
41
|
+
parts = Path(filepath).parts
|
|
42
|
+
if len(parts) >= 2:
|
|
43
|
+
parent = parts[-2]
|
|
44
|
+
if parent in ["sources", "entities", "concepts", "comparisons"]:
|
|
45
|
+
return parent
|
|
46
|
+
stem = Path(filepath).stem.lower()
|
|
47
|
+
if stem in ("index", "log", "overview"):
|
|
48
|
+
return "_special"
|
|
49
|
+
return "sources"
|
|
50
|
+
|
|
51
|
+
# Context-based relation refinement
|
|
52
|
+
CONTEXT_PATTERNS = [
|
|
53
|
+
(r'(来源|来自|基于|引自|参考|参见)', "来源"),
|
|
54
|
+
(r'(相关|关联|类似|同类|同属)', "关联"),
|
|
55
|
+
(r'(对比|比较|vs\.?|versus)', "对比"),
|
|
56
|
+
(r'(实现|使用|采用|应用|工具)', "使用"),
|
|
57
|
+
(r'(属于|归类|分类|类型)', "属于"),
|
|
58
|
+
(r'(替代|取代|替换)', "替代"),
|
|
59
|
+
(r'(衍生|扩展|进化|发展)', "衍生"),
|
|
60
|
+
(r'(姊妹|兄弟|姐妹项目)', "姊妹"),
|
|
61
|
+
(r'(推荐|首选|建议)', "推荐"),
|
|
62
|
+
(r'(创建者|作者|开发者|创始人)', "创建"),
|
|
63
|
+
(r'(支持|兼容|集成|接入)', "支持"),
|
|
64
|
+
(r'(竞争对手|竞品|对手)', "竞争"),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
def infer_relation(from_path, to_path, from_content, all_categories):
|
|
68
|
+
"""Infer the most appropriate relation label for an edge."""
|
|
69
|
+
from_cat = detect_category(from_path)
|
|
70
|
+
to_cat = detect_category(to_path)
|
|
71
|
+
|
|
72
|
+
# 1. Try category-based default
|
|
73
|
+
default = CATEGORY_RELATIONS.get((from_cat, to_cat))
|
|
74
|
+
|
|
75
|
+
# 2. Refine based on context around the wikilink
|
|
76
|
+
link_target = Path(to_path).stem
|
|
77
|
+
|
|
78
|
+
# Find the wikilink in content and check surrounding text
|
|
79
|
+
pattern = r'\[\[' + re.escape(link_target) + r'([^\]]*)\]\]'
|
|
80
|
+
matches = list(re.finditer(pattern, from_content))
|
|
81
|
+
|
|
82
|
+
if matches:
|
|
83
|
+
# Check context around each match (50 chars before/after)
|
|
84
|
+
for m in matches:
|
|
85
|
+
start = max(0, m.start() - 50)
|
|
86
|
+
end = min(len(from_content), m.end() + 50)
|
|
87
|
+
context = from_content[start:end]
|
|
88
|
+
for pat, rel in CONTEXT_PATTERNS:
|
|
89
|
+
if re.search(pat, context):
|
|
90
|
+
return rel
|
|
91
|
+
|
|
92
|
+
# 3. Fall back to category default or generic
|
|
93
|
+
return default or "链接"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main():
|
|
97
|
+
parser = argparse.ArgumentParser(description="Add semantic labels to a KnowFlow graph")
|
|
98
|
+
parser.add_argument("--wiki-dir", default=DEFAULT_WIKI_DIR, help="Wiki directory")
|
|
99
|
+
parser.add_argument("--graph-json", default=DEFAULT_GRAPH_JSON, help="Graph JSON file")
|
|
100
|
+
args = parser.parse_args()
|
|
101
|
+
wiki_dir = Path(args.wiki_dir).resolve()
|
|
102
|
+
graph_json = Path(args.graph_json).resolve()
|
|
103
|
+
|
|
104
|
+
print("🏷️ 开始为图谱边添加关系标签...")
|
|
105
|
+
|
|
106
|
+
# Load existing graph
|
|
107
|
+
with graph_json.open('r', encoding='utf-8') as f:
|
|
108
|
+
graph = json.load(f)
|
|
109
|
+
|
|
110
|
+
# Read all wiki contents for context analysis
|
|
111
|
+
wiki_path = wiki_dir
|
|
112
|
+
contents = {}
|
|
113
|
+
for md_file in wiki_path.rglob('*.md'):
|
|
114
|
+
rel = str(md_file.relative_to(wiki_path))
|
|
115
|
+
try:
|
|
116
|
+
contents[rel] = md_file.read_text(encoding='utf-8')
|
|
117
|
+
except:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
# Build category cache
|
|
121
|
+
categories = {p: detect_category(p) for p in contents}
|
|
122
|
+
|
|
123
|
+
# Process each edge
|
|
124
|
+
updated = 0
|
|
125
|
+
relation_counts = defaultdict(int)
|
|
126
|
+
|
|
127
|
+
for edge in graph.get('edges', []):
|
|
128
|
+
from_id = edge.get('from', '')
|
|
129
|
+
to_id = edge.get('to', '')
|
|
130
|
+
|
|
131
|
+
if not from_id or not to_id:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
from_content = contents.get(from_id, '')
|
|
135
|
+
relation = infer_relation(from_id, to_id, from_content, categories)
|
|
136
|
+
|
|
137
|
+
edge['relation'] = relation
|
|
138
|
+
edge['title'] = f"{relation}" # tooltip
|
|
139
|
+
relation_counts[relation] += 1
|
|
140
|
+
updated += 1
|
|
141
|
+
|
|
142
|
+
# Also add relation info to node titles for better tooltips
|
|
143
|
+
for node in graph.get('nodes', []):
|
|
144
|
+
nid = node.get('id', '')
|
|
145
|
+
cat = categories.get(nid, detect_category(nid))
|
|
146
|
+
# Add Chinese category label
|
|
147
|
+
cat_labels = {
|
|
148
|
+
'sources': '📥 来源', 'entities': '👤 实体',
|
|
149
|
+
'concepts': '💡 概念', 'comparisons': '⚖️ 对比',
|
|
150
|
+
'_special': '⭐ 索引'
|
|
151
|
+
}
|
|
152
|
+
existing_title = node.get('title', '')
|
|
153
|
+
if cat_labels.get(cat) and cat_labels[cat] not in existing_title:
|
|
154
|
+
node['category_label'] = cat_labels[cat]
|
|
155
|
+
|
|
156
|
+
# Save updated graph
|
|
157
|
+
with graph_json.open('w', encoding='utf-8') as f:
|
|
158
|
+
json.dump(graph, f, ensure_ascii=False, indent=2)
|
|
159
|
+
|
|
160
|
+
print(f"✅ 完成!更新了 {updated} 条边的关系标签")
|
|
161
|
+
print(f"\n📊 关系分布:")
|
|
162
|
+
for rel, count in sorted(relation_counts.items(), key=lambda x: -x[1]):
|
|
163
|
+
bar = "█" * min(count, 40)
|
|
164
|
+
print(f" {rel:8s} {count:4d} {bar}")
|
|
165
|
+
|
|
166
|
+
# Stats
|
|
167
|
+
total_edges = len(graph.get('edges', []))
|
|
168
|
+
total_nodes = len(graph.get('nodes', []))
|
|
169
|
+
|
|
170
|
+
# Count isolated nodes (degree 0)
|
|
171
|
+
connected = set()
|
|
172
|
+
for e in graph.get('edges', []):
|
|
173
|
+
connected.add(e.get('from'))
|
|
174
|
+
connected.add(e.get('to'))
|
|
175
|
+
isolated = [n for n in graph.get('nodes', []) if n.get('id') not in connected]
|
|
176
|
+
|
|
177
|
+
print(f"\n📈 图谱统计:")
|
|
178
|
+
print(f" 总节点: {total_nodes}")
|
|
179
|
+
print(f" 总边数: {total_edges}")
|
|
180
|
+
print(f" 连接节点: {len(connected)}")
|
|
181
|
+
print(f" 孤立节点: {len(isolated)} ({len(isolated)*100//max(total_nodes,1)}%)")
|
|
182
|
+
|
|
183
|
+
if isolated:
|
|
184
|
+
print(f"\n⚠️ 孤立节点列表:")
|
|
185
|
+
for n in isolated[:10]:
|
|
186
|
+
print(f" - {n.get('label', n.get('id'))} ({n.get('id')})")
|
|
187
|
+
if len(isolated) > 10:
|
|
188
|
+
print(f" ... 还有 {len(isolated)-10} 个")
|
|
189
|
+
|
|
190
|
+
if __name__ == '__main__':
|
|
191
|
+
main()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# knowflow ingest pipeline — 自动识别来源并提取全文
|
|
3
|
+
# Usage: bash ingest.sh <url_or_content> [source_type]
|
|
4
|
+
# Source type: auto | twitter | xiaohongshu | wechat | web | youtube | text
|
|
5
|
+
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
9
|
+
WIKI_ROOT="${KNOWFLOW_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
|
10
|
+
RAW_DIR="${KNOWFLOW_RAW_DIR:-$WIKI_ROOT/raw}"
|
|
11
|
+
TIMESTAMP=$(date +%Y-%m-%d-%H%M)
|
|
12
|
+
|
|
13
|
+
URL="${1:-}"
|
|
14
|
+
SOURCE_TYPE="${2:-auto}"
|
|
15
|
+
|
|
16
|
+
if [ -z "$URL" ]; then
|
|
17
|
+
echo "❌ Usage: bash ingest.sh <url_or_text> [source_type]"
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
# Auto-detect source type from URL
|
|
22
|
+
detect_source() {
|
|
23
|
+
local url="$1"
|
|
24
|
+
if echo "$url" | grep -qi 'x\.com\|twitter\.com'; then
|
|
25
|
+
echo "twitter"
|
|
26
|
+
elif echo "$url" | grep -qi 'xiaohongshu\|xhslink\|xhscdn'; then
|
|
27
|
+
echo "xiaohongshu"
|
|
28
|
+
elif echo "$url" | grep -qi 'mp\.weixin\|weixin.*article\|wx'; then
|
|
29
|
+
echo "wechat"
|
|
30
|
+
elif echo "$url" | grep -qi 'youtube\|youtu\.be'; then
|
|
31
|
+
echo "youtube"
|
|
32
|
+
elif echo "$url" | grep -qi '^https\?://'; then
|
|
33
|
+
echo "web"
|
|
34
|
+
else
|
|
35
|
+
echo "text"
|
|
36
|
+
fi
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
[ "$SOURCE_TYPE" = "auto" ] && SOURCE_TYPE=$(detect_source "$URL")
|
|
40
|
+
|
|
41
|
+
# ── URL Sanitization ─────────────────────────────────────
|
|
42
|
+
validate_url() {
|
|
43
|
+
local url="$1"
|
|
44
|
+
# Arguments are passed without a shell; keep normal query strings intact.
|
|
45
|
+
if ! echo "$url" | grep -qE '^https?://[^[:space:]]+$'; then
|
|
46
|
+
echo "❌ Invalid URL: $url" >&2
|
|
47
|
+
return 1
|
|
48
|
+
fi
|
|
49
|
+
}
|
|
50
|
+
if [ "$SOURCE_TYPE" != "text" ]; then
|
|
51
|
+
validate_url "$URL" || exit 1
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
mkdir -p "$RAW_DIR"/{twitter,xiaohongshu,wechat,web}
|
|
55
|
+
|
|
56
|
+
OUTPUT_FILE=""
|
|
57
|
+
EXTRACT_METHOD=""
|
|
58
|
+
|
|
59
|
+
case "$SOURCE_TYPE" in
|
|
60
|
+
twitter)
|
|
61
|
+
echo "🐦 检测到 Twitter/X 链接..."
|
|
62
|
+
OUTPUT_FILE="$RAW_DIR/twitter/$TIMESTAMP-tweet.md"
|
|
63
|
+
# Extract tweet via twitter CLI or Jina
|
|
64
|
+
if command -v twitter &>/dev/null; then
|
|
65
|
+
TWEET_ID=$(echo "$URL" | grep -oE '[0-9]{15,}' | head -1)
|
|
66
|
+
if [ -n "$TWEET_ID" ]; then
|
|
67
|
+
twitter get "$TWEET_ID" 2>/dev/null > "$OUTPUT_FILE" || \
|
|
68
|
+
curl -sL "https://r.jina.ai/$URL" > "$OUTPUT_FILE" 2>/dev/null
|
|
69
|
+
else
|
|
70
|
+
curl -sL "https://r.jina.ai/$URL" > "$OUTPUT_FILE" 2>/dev/null
|
|
71
|
+
fi
|
|
72
|
+
else
|
|
73
|
+
curl -sL "https://r.jina.ai/$URL" > "$OUTPUT_FILE" 2>/dev/null
|
|
74
|
+
fi
|
|
75
|
+
EXTRACT_METHOD="jina_reader"
|
|
76
|
+
;;
|
|
77
|
+
|
|
78
|
+
xiaohongshu)
|
|
79
|
+
echo "📕 检测到小红书链接..."
|
|
80
|
+
OUTPUT_FILE="$RAW_DIR/xiaohongshu/$TIMESTAMP-xhs.md"
|
|
81
|
+
# Use agent-browser for xiaohongshu (needs JS rendering)
|
|
82
|
+
if command -v agent-browser &>/dev/null; then
|
|
83
|
+
# Try Jina first, fallback to agent-browser
|
|
84
|
+
HTTP_CODE=$(curl -sL -o "$OUTPUT_FILE" -w "%{http_code}" --max-time 15 "https://r.jina.ai/$URL" 2>/dev/null) || true
|
|
85
|
+
if [ "$HTTP_CODE" != "200" ] || [ ! -s "$OUTPUT_FILE" ]; then
|
|
86
|
+
echo "> ⚠️ Jina 提取失败,小红书可能需要浏览器渲染,已保存原始链接" > "$OUTPUT_FILE"
|
|
87
|
+
echo "" >> "$OUTPUT_FILE"
|
|
88
|
+
echo "**原始链接**: $URL" >> "$OUTPUT_FILE"
|
|
89
|
+
echo "" >> "$OUTPUT_FILE"
|
|
90
|
+
echo "> 💡 提示: 小红书内容需要通过 agent-browser 渲染提取" >> "$OUTPUT_FILE"
|
|
91
|
+
fi
|
|
92
|
+
else
|
|
93
|
+
curl -sL "https://r.jina.ai/$URL" > "$OUTPUT_FILE" 2>/dev/null || echo "**原始链接**: $URL" > "$OUTPUT_FILE"
|
|
94
|
+
fi
|
|
95
|
+
EXTRACT_METHOD="jina_reader"
|
|
96
|
+
;;
|
|
97
|
+
|
|
98
|
+
wechat)
|
|
99
|
+
echo "💬 检测到微信公众号链接..."
|
|
100
|
+
OUTPUT_FILE="$RAW_DIR/wechat/$TIMESTAMP-wechat.md"
|
|
101
|
+
curl -sL --max-time 20 "https://r.jina.ai/$URL" > "$OUTPUT_FILE" 2>/dev/null
|
|
102
|
+
EXTRACT_METHOD="jina_reader"
|
|
103
|
+
;;
|
|
104
|
+
|
|
105
|
+
youtube)
|
|
106
|
+
echo "▶️ 检测到 YouTube 链接..."
|
|
107
|
+
OUTPUT_FILE="$RAW_DIR/web/$TIMESTAMP-youtube.md"
|
|
108
|
+
if command -v yt-dlp &>/dev/null; then
|
|
109
|
+
# Get video info + transcript
|
|
110
|
+
echo "# YouTube Video" > "$OUTPUT_FILE"
|
|
111
|
+
echo "" >> "$OUTPUT_FILE"
|
|
112
|
+
echo "**URL**: $URL" >> "$OUTPUT_FILE"
|
|
113
|
+
echo "" >> "$OUTPUT_FILE"
|
|
114
|
+
echo "## Video Info" >> "$OUTPUT_FILE"
|
|
115
|
+
yt-dlp --print title --print description --print duration_string --no-download "$URL" >> "$OUTPUT_FILE" 2>/dev/null || true
|
|
116
|
+
echo "" >> "$OUTPUT_FILE"
|
|
117
|
+
echo "## Transcript / Subtitles" >> "$OUTPUT_FILE"
|
|
118
|
+
yt-dlp --write-sub --sub-langs "zh,en" --skip-download -o "/tmp/wiki-yt-sub" "$URL" 2>/dev/null && \
|
|
119
|
+
cat /tmp/wiki-yt-sub*.vtt 2>/dev/null | sed 's/<[^>]*>//g' | grep -v '^$' | head -200 >> "$OUTPUT_FILE" || \
|
|
120
|
+
echo "(无字幕可用)" >> "$OUTPUT_FILE"
|
|
121
|
+
rm -f /tmp/wiki-yt-sub* 2>/dev/null || true
|
|
122
|
+
else
|
|
123
|
+
curl -sL "https://r.jina.ai/$URL" > "$OUTPUT_FILE" 2>/dev/null
|
|
124
|
+
fi
|
|
125
|
+
EXTRACT_METHOD="yt-dlp+jina"
|
|
126
|
+
;;
|
|
127
|
+
|
|
128
|
+
web)
|
|
129
|
+
echo "🌐 检测到网页链接..."
|
|
130
|
+
# Determine subdirectory by domain
|
|
131
|
+
DOMAIN=$(echo "$URL" | sed 's|https\?://||' | cut -d'/' -f1)
|
|
132
|
+
OUTPUT_FILE="$RAW_DIR/web/$TIMESTAMP-web-${DOMAIN%%.*}.md"
|
|
133
|
+
curl -sL --max-time 20 "https://r.jina.ai/$URL" > "$OUTPUT_FILE" 2>/dev/null
|
|
134
|
+
EXTRACT_METHOD="jina_reader"
|
|
135
|
+
;;
|
|
136
|
+
|
|
137
|
+
text)
|
|
138
|
+
echo "📝 纯文本内容..."
|
|
139
|
+
OUTPUT_FILE="$RAW_DIR/web/$TIMESTAMP-text.md"
|
|
140
|
+
echo "$URL" > "$OUTPUT_FILE"
|
|
141
|
+
EXTRACT_METHOD="direct"
|
|
142
|
+
;;
|
|
143
|
+
esac
|
|
144
|
+
|
|
145
|
+
# Verify output
|
|
146
|
+
if [ -f "$OUTPUT_FILE" ] && [ -s "$OUTPUT_FILE" ]; then
|
|
147
|
+
SIZE=$(wc -c < "$OUTPUT_FILE" | tr -d ' ')
|
|
148
|
+
LINES=$(wc -l < "$OUTPUT_FILE" | tr -d ' ')
|
|
149
|
+
echo ""
|
|
150
|
+
echo "✅ 提取完成:"
|
|
151
|
+
echo " 📄 文件: $OUTPUT_FILE"
|
|
152
|
+
echo " 📏 大小: ${SIZE} bytes (${LINES} 行)"
|
|
153
|
+
echo " 🔧 方法: $EXTRACT_METHOD"
|
|
154
|
+
echo " 🏷️ 类型: $SOURCE_TYPE"
|
|
155
|
+
echo ""
|
|
156
|
+
echo "→ 下一步: 告诉 Agent 执行 ingest 处理此文件"
|
|
157
|
+
else
|
|
158
|
+
echo ""
|
|
159
|
+
echo "⚠️ 提取可能失败(文件为空或不存在)"
|
|
160
|
+
echo " 原始链接: $URL"
|
|
161
|
+
exit 1
|
|
162
|
+
fi
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# knowflow pipeline — automated 5-step wiki processing pipeline
|
|
3
|
+
# Usage: bash scripts/pipeline.sh [--dry-run] [--step=N]
|
|
4
|
+
#
|
|
5
|
+
# Steps:
|
|
6
|
+
# 1. bookmark_sync.sh — sync X/Twitter bookmarks
|
|
7
|
+
# 2. detect new raw/ files — compare .ingest-state.json vs raw/
|
|
8
|
+
# 3. ingest new files — run ingest.sh for each new file
|
|
9
|
+
# 4. vector-store build — incremental vector index (only if new pages)
|
|
10
|
+
# 5. wiki-health.sh — broken links, empty files, orphan pages
|
|
11
|
+
#
|
|
12
|
+
# Options:
|
|
13
|
+
# --dry-run Print each step without executing
|
|
14
|
+
# --step=N Run only step N (1-5)
|
|
15
|
+
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
# ── Temp file cleanup ─────────────────────────────────────
|
|
19
|
+
_TEMP_FILES=()
|
|
20
|
+
cleanup() { rm -f "${_TEMP_FILES[@]:-}" 2>/dev/null || true; }
|
|
21
|
+
trap cleanup EXIT INT TERM
|
|
22
|
+
|
|
23
|
+
# ── Config ──────────────────────────────────────────────
|
|
24
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
25
|
+
WIKI_ROOT="${KNOWFLOW_ROOT:-${WIKI_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}}"
|
|
26
|
+
STATE_FILE="$WIKI_ROOT/.ingest-state.json"
|
|
27
|
+
RAW_DIR="${KNOWFLOW_RAW_DIR:-$WIKI_ROOT/raw}"
|
|
28
|
+
WIKI_DIR="${KNOWFLOW_WIKI_DIR:-$WIKI_ROOT/wiki}"
|
|
29
|
+
LOCK_FILE="$WIKI_ROOT/.pipeline.lock"
|
|
30
|
+
TIMESTAMP=$(date +%Y-%m-%d-%H%M)
|
|
31
|
+
|
|
32
|
+
DRY_RUN=false
|
|
33
|
+
RUN_STEP=0
|
|
34
|
+
|
|
35
|
+
# ── Parse args ──────────────────────────────────────────
|
|
36
|
+
for arg in "$@"; do
|
|
37
|
+
case "$arg" in
|
|
38
|
+
--dry-run) DRY_RUN=true ;;
|
|
39
|
+
--step=*) RUN_STEP="${arg#--step=}" ;;
|
|
40
|
+
*) echo "Unknown option: $arg" >&2; exit 1 ;;
|
|
41
|
+
esac
|
|
42
|
+
done
|
|
43
|
+
|
|
44
|
+
# ── Helpers ─────────────────────────────────────────────
|
|
45
|
+
step_header() {
|
|
46
|
+
local step_num="$1"
|
|
47
|
+
local step_name="$2"
|
|
48
|
+
echo ""
|
|
49
|
+
echo "━━━ Step $step_num: $step_name ━━━━━━━━━━━━━━━━━━━━━━━"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
run_cmd() {
|
|
53
|
+
if $DRY_RUN; then
|
|
54
|
+
echo "[DRY RUN] $*"
|
|
55
|
+
else
|
|
56
|
+
"$@"
|
|
57
|
+
fi
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
should_run() {
|
|
61
|
+
local step="$1"
|
|
62
|
+
[ "$RUN_STEP" -eq 0 ] || [ "$RUN_STEP" -eq "$step" ]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# ── Summary tracking ────────────────────────────────────
|
|
66
|
+
STEP_RESULTS=()
|
|
67
|
+
HEALTH_EXIT=0
|
|
68
|
+
HEALTH_SCORE="UNKNOWN"
|
|
69
|
+
NEW_FILES_COUNT=0
|
|
70
|
+
|
|
71
|
+
if [ "${HEALTH_EXIT:-0}" -ne 0 ] && ! ${DRY_RUN:-false}; then
|
|
72
|
+
exit 1
|
|
73
|
+
fi
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* knowflow tags — build tag hub pages from [[tag/<name>]] links.
|
|
4
|
+
*
|
|
5
|
+
* Scans every Markdown page in the wiki for `[[tag/<name>]]` links and
|
|
6
|
+
* (re)generates `tag/<name>.md` hub pages that link back to every page
|
|
7
|
+
* carrying the tag. Hub pages older tags that no longer appear anywhere
|
|
8
|
+
* are removed, so re-running the command is fully idempotent.
|
|
9
|
+
*
|
|
10
|
+
* Environment:
|
|
11
|
+
* KNOWFLOW_WIKI_DIR — wiki root directory (set by the CLI)
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
const WIKI_DIR = process.env.KNOWFLOW_WIKI_DIR;
|
|
17
|
+
if (!WIKI_DIR || !existsSync(WIKI_DIR)) {
|
|
18
|
+
console.error('knowflow tags: KNOWFLOW_WIKI_DIR is not set or missing');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TAG_DIR = join(WIKI_DIR, 'tag');
|
|
23
|
+
const TAG_LINK = /\[\[tag\/([^\]|]+)(?:\|[^\]]*)?\]\]/g;
|
|
24
|
+
|
|
25
|
+
function* walkMd(dir) {
|
|
26
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
27
|
+
if (entry.name.startsWith('.')) continue;
|
|
28
|
+
const path = join(dir, entry.name);
|
|
29
|
+
if (entry.isDirectory()) yield* walkMd(path);
|
|
30
|
+
else if (entry.name.endsWith('.md')) yield path;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const tagMap = new Map();
|
|
35
|
+
for (const file of walkMd(WIKI_DIR)) {
|
|
36
|
+
const rel = file.slice(WIKI_DIR.length + 1);
|
|
37
|
+
if (rel.startsWith('tag/')) continue;
|
|
38
|
+
const text = readFileSync(file, 'utf8');
|
|
39
|
+
for (const match of text.matchAll(TAG_LINK)) {
|
|
40
|
+
const tag = match[1].trim();
|
|
41
|
+
if (!tag) continue;
|
|
42
|
+
if (!tagMap.has(tag)) tagMap.set(tag, new Set());
|
|
43
|
+
tagMap.get(tag).add(rel.replace(/\.md$/, ''));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
mkdirSync(TAG_DIR, { recursive: true });
|
|
48
|
+
|
|
49
|
+
let written = 0;
|
|
50
|
+
for (const [tag, pages] of [...tagMap.entries()].sort((a, b) => b[1].size - a[1].size)) {
|
|
51
|
+
const pageList = [...pages].sort();
|
|
52
|
+
const content = [
|
|
53
|
+
`# Tag: ${tag}`,
|
|
54
|
+
'',
|
|
55
|
+
`> Auto-generated by \`knowflow tags\` — index of pages linking to [[tag/${tag}]]. Re-run the command to refresh.`,
|
|
56
|
+
'',
|
|
57
|
+
`## Pages (${pageList.length})`,
|
|
58
|
+
'',
|
|
59
|
+
...pageList.map(page => `- [[${page}]]`),
|
|
60
|
+
'',
|
|
61
|
+
].join('\n');
|
|
62
|
+
writeFileSync(join(TAG_DIR, `${tag}.md`), content, 'utf8');
|
|
63
|
+
written += 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let removed = 0;
|
|
67
|
+
for (const entry of existsSync(TAG_DIR) ? readdirSync(TAG_DIR) : []) {
|
|
68
|
+
if (!entry.endsWith('.md')) continue;
|
|
69
|
+
if (!tagMap.has(entry.replace(/\.md$/, ''))) {
|
|
70
|
+
rmSync(join(TAG_DIR, entry));
|
|
71
|
+
removed += 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(`tags: ${written} hub page(s) under tag/ (${removed} stale removed)`);
|