feat(vault): Phase 3 tools + batch citation/relation enrichment
This commit is contained in:
+143
-43
@@ -12,14 +12,23 @@ sync_home_wiki.py — home-wiki 知识页面 → kepano LLM Wiki 同步脚本
|
||||
concepts/ + entities/ + syntheses/ (排除 index.md)
|
||||
Daily/ MyNotes/ reports/ 不在知识同步范围。
|
||||
|
||||
转换规则(home-wiki frontmatter → kepano Wiki 规范):
|
||||
categories: 加 [[LLM Wiki]];entity/person 额外加 [[People]]
|
||||
tags: 前缀 [wiki, {people|concept}],subtype→concept/{subtype}(仅 concept),追加原 tags
|
||||
created: 保留
|
||||
source: sources[0] → 文件路径取 stem 做 wikilink;URL/标识符原样;空则留空
|
||||
type: 保留(concept/entity/synthesis)
|
||||
aliases: title 或 name
|
||||
正文: 保留,移除 home-wiki 特有的 openclaw 自动段落(## Related、注释标记行)
|
||||
转换规则(home-wiki frontmatter → kepano Wiki 规范,Phase 1 v2 升级版):
|
||||
categories: 加 [[LLM Wiki]];entity/person 额外加 [[People]]
|
||||
tags: 前缀 [wiki, {people|concept}],subtype→concept/{subtype}(仅 concept),追加原 tags
|
||||
created: 保留
|
||||
source: sources[0] → 文件路径取 stem 做 wikilink;URL/标识符原样;空则留空
|
||||
type: 保留(concept/entity/synthesis)
|
||||
aliases: title 或 name
|
||||
confidence: home-wiki 已有则沿用,否则默认 3
|
||||
status: 默认 active
|
||||
last_reviewed: 同步日期(YYYY-MM-DD)
|
||||
review_interval_days: 180(技术类 90)
|
||||
relations: 从 home-wiki `related: [[wikilink]]` 字段提取 → `[{type: extends, target: [[x]]}]`
|
||||
正文: 保留,移除 home-wiki 特有的 openclaw 自动段落(## Related、注释标记行)
|
||||
|
||||
YAML 输出格式(Obsidian 兼容):
|
||||
使用 IndentedDumper(子类)确保序列正确缩进(列表项 2 空格缩进),
|
||||
wikilink 字符串加双引号,避免 Obsidian 解析异常。
|
||||
|
||||
index 自动维护(--index):
|
||||
用标记块 <!-- BEGIN/END home-wiki-sync --> 界定 index.md 的「概念页」「实体页」
|
||||
@@ -32,7 +41,7 @@ index 自动维护(--index):
|
||||
python tools/scripts/sync_home_wiki.py --dry-run # 仅诊断差异
|
||||
python tools/scripts/sync_home_wiki.py # 同步(写入变化的页面)
|
||||
python tools/scripts/sync_home_wiki.py --index --log # 一站式:同步+更新index+记日志
|
||||
python tools/scripts/sync_home_wiki.py --lint # 体检(孤儿/断链/source空值)
|
||||
python tools/scripts/sync_home_wiki.py --lint # 体检(孤儿/断链/source空值/缺失lifecycle字段)
|
||||
|
||||
退出码:0 成功;1 源目录缺失;2 有错误。
|
||||
"""
|
||||
@@ -50,6 +59,24 @@ except ImportError:
|
||||
sys.stderr.write("ERROR: PyYAML 未安装,请运行 pip install pyyaml\n")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
class IndentedDumper(yaml.Dumper):
|
||||
"""Custom dumper that indents sequences under their key (Obsidian-friendly)"""
|
||||
def increase_indent(self, flow=False, indentless=False):
|
||||
return super().increase_indent(flow, False)
|
||||
|
||||
|
||||
def yaml_dump(data) -> str:
|
||||
"""Dump data with Obsidian-compatible formatting (indented sequences, double quotes)"""
|
||||
return yaml.dump(
|
||||
data,
|
||||
Dumper=IndentedDumper,
|
||||
allow_unicode=True,
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
width=1000,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 路径配置(两库位于不同位置,必须使用绝对路径)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -103,7 +130,7 @@ def _robust_parse_fm(fm_text):
|
||||
m = re.search(rf"^[ \t]*{re.escape(key)}[ \t]*:[ \t]*(.+?)[ \t]*$", fm_text, re.M)
|
||||
if m and m.group(1).strip() not in ("", "[]"):
|
||||
fm[key] = m.group(1).strip().strip("\"'")
|
||||
for key in ("tags", "sources"):
|
||||
for key in ("tags", "sources", "related"):
|
||||
vals = _extract_list_field(fm_text, key)
|
||||
if vals:
|
||||
fm[key] = vals
|
||||
@@ -157,8 +184,38 @@ def _as_list(v):
|
||||
return [v]
|
||||
|
||||
|
||||
def _extract_related(fm_text):
|
||||
"""提取 home-wiki 的 `related: [[x]]` 字段为 relations 列表。
|
||||
|
||||
home-wiki 用 `related: [[wikilink]]` 记录关联页面(YAML 解析时 [[ 会失败),
|
||||
转为 kepano Wiki 的 `relations: [{type: extends, target: "[[x]]"}]` 格式。
|
||||
"""
|
||||
rels = []
|
||||
# 匹配 related: 后跟 [[wikilink]],支持多个
|
||||
for m in re.finditer(r"^[ \t]*related[ \t]*:[ \t]*\[?\[([^\]|#]+)", fm_text, re.M):
|
||||
tgt = m.group(1).strip()
|
||||
if tgt:
|
||||
rels.append({"type": "extends", "target": f"[[{tgt}]]", "confidence": 2})
|
||||
# 也支持 related: 换行后列表
|
||||
block = re.search(r"^[ \t]*related[ \t]*:[ \t]*\n((?:[ \t]+-\s*[^\n]+\n?)+)", fm_text, re.M)
|
||||
if block:
|
||||
for line in block.group(1).splitlines():
|
||||
m = re.search(r"\[\[([^\]|#]+)", line)
|
||||
if m:
|
||||
rels.append({"type": "extends", "target": f"[[{m.group(1).strip()}]]", "confidence": 2})
|
||||
return rels
|
||||
|
||||
|
||||
def convert_frontmatter(fm, default_name):
|
||||
"""home-wiki frontmatter → kepano Wiki frontmatter(有序)。"""
|
||||
"""home-wiki frontmatter → kepano Wiki frontmatter(有序 dict)。
|
||||
|
||||
Phase 1 lifecycle 字段(2026-07-01 新增):
|
||||
- confidence: 1-5(默认 3,home-wiki 有则沿用)
|
||||
- status: active(默认)
|
||||
- last_reviewed: 同步日期
|
||||
- review_interval_days: 180(技术类 90)
|
||||
- relations: 从 home-wiki related: 字段映射
|
||||
"""
|
||||
fm_type = str(fm.get("type", "concept")).strip()
|
||||
subtype = fm.get("subtype")
|
||||
if isinstance(subtype, list):
|
||||
@@ -178,8 +235,6 @@ def convert_frontmatter(fm, default_name):
|
||||
type_tag = "concept"
|
||||
|
||||
tags = ["wiki", type_tag]
|
||||
# 仅 concept 类型把 subtype 转为 concept/{subtype} 标签
|
||||
# (entity/person 用 people 标签 + [[People]] category,不加 concept/person)
|
||||
if subtype and fm_type == "concept":
|
||||
tags.append(f"concept/{subtype}")
|
||||
for t in _as_list(fm.get("tags")):
|
||||
@@ -196,37 +251,41 @@ def convert_frontmatter(fm, default_name):
|
||||
else:
|
||||
src_val = ""
|
||||
|
||||
return [
|
||||
("categories", categories),
|
||||
("tags", tags),
|
||||
("created", str(fm.get("created", ""))),
|
||||
("source", src_val),
|
||||
("type", fm_type),
|
||||
("aliases", [str(title)]),
|
||||
]
|
||||
today = date.today().isoformat()
|
||||
confidence = fm.get("confidence", 3)
|
||||
try:
|
||||
confidence = int(confidence)
|
||||
except (ValueError, TypeError):
|
||||
confidence = 3
|
||||
# 技术类(type=tool)默认 90 天审查周期
|
||||
review_interval = 90 if fm_type == "tool" else 180
|
||||
|
||||
# 用普通 dict(Python 3.7+ 保持插入顺序),避免 OrderedDict YAML 序列化问题
|
||||
pairs = {
|
||||
"categories": categories,
|
||||
"tags": tags,
|
||||
"created": str(fm.get("created", "")),
|
||||
"source": src_val,
|
||||
"type": fm_type,
|
||||
"aliases": [str(title)],
|
||||
"confidence": confidence,
|
||||
"status": "active",
|
||||
"last_reviewed": today,
|
||||
"review_interval_days": review_interval,
|
||||
}
|
||||
|
||||
def _yaml_quote(val):
|
||||
"""如果值包含 YAML 特殊字符(如 [[ wikilink 的方括号),加双引号。"""
|
||||
s = str(val)
|
||||
if not s:
|
||||
return '""'
|
||||
if '[' in s or ']' in s or '{' in s or '}' in s or ':' in s or '#' in s:
|
||||
return f'"{s}"'
|
||||
return s
|
||||
return pairs
|
||||
|
||||
|
||||
def dump_frontmatter(pairs):
|
||||
out = ["---"]
|
||||
for k, v in pairs:
|
||||
if isinstance(v, list):
|
||||
out.append(f"{k}:")
|
||||
for item in v:
|
||||
out.append(f" - {_yaml_quote(item)}")
|
||||
else:
|
||||
out.append(f"{k}: {_yaml_quote(v)}")
|
||||
out.append("---")
|
||||
return "\n".join(out) + "\n"
|
||||
"""输出 Obsidian 兼容的 frontmatter(使用 IndentedDumper 缩进序列)。"""
|
||||
if isinstance(pairs, list):
|
||||
from collections import OrderedDict
|
||||
pairs = OrderedDict(pairs)
|
||||
# 转为普通 dict(避免 OrderedDict 触发 python/object 标签)
|
||||
data = dict(pairs) if not isinstance(pairs, dict) else pairs
|
||||
yaml_str = yaml_dump(data)
|
||||
return f"---\n{yaml_str}---\n\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -256,9 +315,23 @@ def build_page(src_path, stem):
|
||||
text = src_path.read_text(encoding="utf-8")
|
||||
fm, body = parse_doc(text)
|
||||
pairs = convert_frontmatter(fm, stem)
|
||||
# 提取 relations(从原始 frontmatter 文本中)
|
||||
m = re.match(r"^---\s*\n(.*?)\n---\s*\n?", text, re.S)
|
||||
if m:
|
||||
rels = _extract_related(m.group(1))
|
||||
if rels:
|
||||
# 去重:同 (type, target) 只保留第一个
|
||||
seen = set()
|
||||
unique_rels = []
|
||||
for r in rels:
|
||||
key = (r["type"], r["target"])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_rels.append(r)
|
||||
pairs["relations"] = unique_rels[:5] # 限制最多 5 条
|
||||
body_clean = strip_openclaw(body)
|
||||
# 确保正文与 frontmatter 间有空行
|
||||
return dump_frontmatter(pairs) + "\n" + body_clean.lstrip("\n")
|
||||
return dump_frontmatter(pairs) + body_clean.lstrip("\n")
|
||||
|
||||
|
||||
def _norm_full(text):
|
||||
@@ -507,6 +580,27 @@ def lint():
|
||||
if m and not m.group(1).strip():
|
||||
empty_source.append(s)
|
||||
|
||||
# Phase 1 lifecycle 字段缺失检查(2026-07-01 新增)
|
||||
LIFECYCLE_FIELDS = ("confidence", "status", "last_reviewed", "review_interval_days")
|
||||
missing_lifecycle = []
|
||||
for s in synced:
|
||||
t = (DST_WIKI / f"{s}.md").read_text(encoding="utf-8")
|
||||
for field in LIFECYCLE_FIELDS:
|
||||
if not re.search(rf"^[ \t]*{re.escape(field)}[ \t]*:", t, re.M):
|
||||
missing_lifecycle.append(f"{s} (缺 {field})")
|
||||
break # 一页只报一次
|
||||
|
||||
# relations 字段检查(可选,但建议)
|
||||
no_relations = []
|
||||
for s in synced:
|
||||
t = (DST_WIKI / f"{s}.md").read_text(encoding="utf-8")
|
||||
m = re.match(r"^---\s*\n(.*?)\n---\s*\n?", t, re.S)
|
||||
if m:
|
||||
fm_text = m.group(1)
|
||||
if not re.search(r"^[ \t]*relations[ \t]*:[ \t]*\n[ \t]+-", fm_text, re.M) and \
|
||||
not re.search(r"^[ \t]*relations[ \t]*:[ \t]*\[", fm_text, re.M):
|
||||
no_relations.append(s)
|
||||
|
||||
# 断链:同步页面引用的 [[x]] 在 wiki 是否存在(排除 raw 来源类、aliases、category)
|
||||
valid = allfiles | set(aliases_map.keys()) | set(synced)
|
||||
skip = {"People", "LLM Wiki", "wikilink"}
|
||||
@@ -540,10 +634,16 @@ def lint():
|
||||
print(f"\n[未入 index] {len(not_in_index)} 个(概念/实体未在跨库章节)")
|
||||
for s in not_in_index:
|
||||
print(f" - {s}")
|
||||
print(f"\n[缺 lifecycle 字段] {len(missing_lifecycle)} 个(Phase 1 必填:confidence/status/last_reviewed/review_interval_days)")
|
||||
for s in missing_lifecycle:
|
||||
print(f" - {s}")
|
||||
print(f"\n[无 relations] {len(no_relations)} 个(建议补充以激活图遍历)")
|
||||
for s in no_relations:
|
||||
print(f" - {s}")
|
||||
|
||||
issues = len(orphans) + len(empty_source) + len(not_in_index)
|
||||
print(f"\n硬性问题(孤儿+空source+未入index): {issues}")
|
||||
print(f"软性问题(断链,多为待创建概念/raw来源): {len(broken)}")
|
||||
issues = len(orphans) + len(empty_source) + len(not_in_index) + len(missing_lifecycle)
|
||||
print(f"\n硬性问题(孤儿+空source+未入index+缺lifecycle): {issues}")
|
||||
print(f"软性问题(断链/无relations,多为待创建概念/raw来源): {len(broken) + len(no_relations)}")
|
||||
return issues
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user