weflow-cli 1.8.2 → 1.10.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/ARCHITECTURE.md +62 -26
- package/CHANGELOG.md +1290 -0
- package/OPERATIONS.md +144 -11
- package/README.en.md +20 -4
- package/README.md +30 -5
- package/bin/weflow-cli-electron.cjs +2 -0
- package/bin/weflow-cli.ts +996 -118
- package/dist/bin/weflow-cli.js +1038 -146
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/http.d.ts +14 -0
- package/dist/mcp-server/http.d.ts.map +1 -0
- package/dist/mcp-server/http.js +84 -0
- package/dist/mcp-server/http.js.map +1 -0
- package/dist/mcp-server/httpConfig.d.ts +50 -0
- package/dist/mcp-server/httpConfig.d.ts.map +1 -0
- package/dist/mcp-server/httpConfig.js +104 -0
- package/dist/mcp-server/httpConfig.js.map +1 -0
- package/dist/mcp-server/index.d.ts +13 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +58 -374
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +12 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +20 -3
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/panel/server.d.ts.map +1 -1
- package/dist/src/panel/server.js +61 -1
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +69 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -1
- package/dist/src/services/assistantEval.js +270 -8
- package/dist/src/services/assistantEval.js.map +1 -1
- package/dist/src/services/assistantScenes.d.ts +81 -0
- package/dist/src/services/assistantScenes.d.ts.map +1 -0
- package/dist/src/services/assistantScenes.js +322 -0
- package/dist/src/services/assistantScenes.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +33 -4
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +102 -6
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantSkills.d.ts +94 -0
- package/dist/src/services/assistantSkills.d.ts.map +1 -0
- package/dist/src/services/assistantSkills.js +330 -0
- package/dist/src/services/assistantSkills.js.map +1 -0
- package/dist/src/services/assistantTools.d.ts +34 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +931 -88
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +4 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -1
- package/dist/src/services/assistantTrace.js +9 -0
- package/dist/src/services/assistantTrace.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +5 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +18 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +29 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/outboundSend.d.ts +53 -0
- package/dist/src/services/outboundSend.d.ts.map +1 -0
- package/dist/src/services/outboundSend.js +90 -0
- package/dist/src/services/outboundSend.js.map +1 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -1
- package/dist/src/services/pythonBridge.js +1 -0
- package/dist/src/services/pythonBridge.js.map +1 -1
- package/dist/src/services/wechatClient.d.ts +125 -0
- package/dist/src/services/wechatClient.d.ts.map +1 -0
- package/dist/src/services/wechatClient.js +344 -0
- package/dist/src/services/wechatClient.js.map +1 -0
- package/dist/src/services/wechatMedia.d.ts +44 -0
- package/dist/src/services/wechatMedia.d.ts.map +1 -0
- package/dist/src/services/wechatMedia.js +155 -0
- package/dist/src/services/wechatMedia.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +11 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +55 -11
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/types.d.ts +4 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/utils/python.js +2 -2
- package/dist/src/utils/python.js.map +1 -1
- package/docs/BRANCHES.md +37 -0
- package/docs/CONTACT_DB_SCHEMA.md +1085 -0
- package/docs/DECISIONS.md +1624 -0
- package/docs/EXTENDING.md +98 -10
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
- package/docs/HEALTH-CHECK.md +12 -7
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
- package/docs/MCP.md +58 -4
- package/docs/PROJECT_STATE.md +180 -11
- package/docs/RELEASING.md +115 -1
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/logo.png +0 -0
- package/mcp-server/http.ts +108 -0
- package/mcp-server/httpConfig.ts +126 -0
- package/mcp-server/index.ts +359 -681
- package/package.json +5 -3
- package/resources/js/graph3d/NOTICE.txt +35 -0
- package/resources/js/graph3d/d3-binarytree.min.js +2 -0
- package/resources/js/graph3d/d3-dispatch.min.js +2 -0
- package/resources/js/graph3d/d3-force-3d.min.js +2 -0
- package/resources/js/graph3d/d3-octree.min.js +2 -0
- package/resources/js/graph3d/d3-quadtree.min.js +2 -0
- package/resources/js/graph3d/d3-timer.min.js +2 -0
- package/resources/js/graph3d/layout.mjs +63 -0
- package/resources/js/graph3d/three.min.js +6 -0
- package/resources/js/graph3d/viewer2d.js +448 -0
- package/resources/panel/ball-position.cjs +48 -2
- package/resources/panel/index.html +18 -4
- package/resources/panel/main.cjs +137 -5
- package/resources/panel/mascot-base.png +0 -0
- package/resources/panel/mascot-blink-1.png +0 -0
- package/resources/panel/mascot-blink-2.png +0 -0
- package/resources/panel/mascot-iris.png +0 -0
- package/resources/panel/mascot-lift-down-1.png +0 -0
- package/resources/panel/mascot-lift-down-2.png +0 -0
- package/resources/panel/mascot-lift-down-3.png +0 -0
- package/resources/panel/mascot-lift-down-4.png +0 -0
- package/resources/panel/mascot-lift-down-5.png +0 -0
- package/resources/panel/mascot-lift-down-6.png +0 -0
- package/resources/panel/mascot-lift-up-1.png +0 -0
- package/resources/panel/mascot-lift-up-2.png +0 -0
- package/resources/panel/mascot-lift-up-3.png +0 -0
- package/resources/panel/mascot-lift-up-4.png +0 -0
- package/resources/panel/mascot-lift-up-5.png +0 -0
- package/resources/panel/mascot-lift-up-6.png +0 -0
- package/resources/panel/mascot-peek.png +0 -0
- package/resources/panel/mascot-tickle-1.png +0 -0
- package/resources/panel/mascot-tickle-2.png +0 -0
- package/resources/panel/mascot-tickle-3.png +0 -0
- package/resources/panel/mascot-tickle-4.png +0 -0
- package/resources/panel/panel.css +205 -59
- package/resources/panel/preload.cjs +26 -0
- package/resources/panel/renderer.js +646 -17
- package/scripts/_utils.py +1203 -931
- package/scripts/backfill_articles.py +202 -21
- package/scripts/biz_daily.py +26 -34
- package/scripts/bonds.py +331 -0
- package/scripts/chat_notes.py +561 -470
- package/scripts/chat_stats.py +8 -7
- package/scripts/check_readme_commands.py +426 -0
- package/scripts/compile_wiki.py +187 -59
- package/scripts/contact_schema.py +339 -0
- package/scripts/data/wechat_emoji.json +225 -0
- package/scripts/export_chat_html.py +3 -1
- package/scripts/fav_notes.py +4 -1
- package/scripts/graph_2d.py +151 -0
- package/scripts/graph_3d.py +646 -0
- package/scripts/health_check.py +163 -26
- package/scripts/mcp_bridge.py +6 -7
- package/scripts/nt_decrypt.py +3 -3
- package/scripts/panel_frames.py +683 -0
- package/scripts/pipeline.py +21 -10
- package/scripts/quality_eval.py +5 -0
- package/scripts/rag_chat.py +33 -16
- package/scripts/reply_debt.py +26 -6
- package/scripts/semantic_search.py +130 -22
- package/scripts/vault_rag.py +42 -16
- package/scripts/vault_search.py +44 -8
- package/scripts/wechat_emoticon.py +6 -0
- package/scripts/wechat_voice.py +9 -0
- package/scripts/wiki_lint.py +124 -24
- package/src/core/dbPathService.ts +22 -4
- package/src/panel/server.ts +58 -1
- package/src/services/assistantEval.ts +323 -12
- package/src/services/assistantScenes.ts +342 -0
- package/src/services/assistantService.ts +107 -6
- package/src/services/assistantSkills.ts +364 -0
- package/src/services/assistantTools.ts +2452 -1640
- package/src/services/assistantTrace.ts +9 -0
- package/src/services/configService.ts +415 -403
- package/src/services/messageQuery.ts +29 -0
- package/src/services/outboundSend.ts +126 -0
- package/src/services/pythonBridge.ts +1 -0
- package/src/services/wechatClient.ts +380 -0
- package/src/services/wechatMedia.ts +166 -0
- package/src/services/wechatMessageService.ts +62 -11
- package/src/types.ts +4 -0
- package/src/utils/python.ts +2 -2
- package/dist/src/utils/wechatEmoji.d.ts +0 -5
- package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
- package/dist/src/utils/wechatEmoji.js +0 -35
- package/dist/src/utils/wechatEmoji.js.map +0 -1
package/scripts/vault_rag.py
CHANGED
|
@@ -16,17 +16,35 @@ DEFAULT_VAULT = os.path.join(PROJECT_ROOT, 'output', 'wechat-vault')
|
|
|
16
16
|
DEFAULT_BIZ = os.path.join(PROJECT_ROOT, 'output', 'biz-daily')
|
|
17
17
|
|
|
18
18
|
sys.path.insert(0, SCRIPTS_DIR)
|
|
19
|
-
from _utils import CONCEPT_DIRS, call_deepseek
|
|
19
|
+
from _utils import CONCEPT_DIRS, LINE_LABELS, call_deepseek, line_for_relative_concepts
|
|
20
20
|
|
|
21
|
+
LINE_CHOICES = ('all', 'wiki', 'chat')
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
|
|
24
|
+
def line_label(line: str) -> str:
|
|
25
|
+
return '两条线' if line == 'all' else LINE_LABELS.get(line, line)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def collect_context(vault: str, biz_daily: str, question: str, top_k: int,
|
|
29
|
+
line: str = 'all') -> list[dict]:
|
|
30
|
+
"""从 Vault + biz-daily 检索相关上下文。
|
|
31
|
+
|
|
32
|
+
`line` 把**材料也分开**:概念页按目录分线,而用户的笔记层与日报文章都属于**文章线**
|
|
33
|
+
(`--line chat` 只给聊天线的概念页)。不这么分的话,"只看聊天知识库"仍然会从阅读笔记里
|
|
34
|
+
捞结果进来——那正是"两条线分开"要避免的事。
|
|
35
|
+
|
|
36
|
+
**函数默认 `all`(不缩小范围)**,用户看到的默认(文章线)在 CLI 那一层:忘了传 `line`
|
|
37
|
+
的调用方会多搜一条线,而不是静默少搜一半。
|
|
38
|
+
"""
|
|
24
39
|
results = []
|
|
25
40
|
search_terms = question.lower().split()
|
|
41
|
+
want = (lambda which: line == 'all' or line == which)
|
|
26
42
|
|
|
27
|
-
# 1.
|
|
43
|
+
# 1. 概念页(权重最高)——不传 `--line` 时两个目录都读:文章知识库与聊天知识库是分开的
|
|
28
44
|
# (`_utils.CONCEPT_DIRS`),只读一个的话分出去的那一半会静默地搜不到。
|
|
29
45
|
for relative in CONCEPT_DIRS:
|
|
46
|
+
if not want(line_for_relative_concepts(relative)):
|
|
47
|
+
continue
|
|
30
48
|
concepts_dir = Path(vault) / relative
|
|
31
49
|
if not concepts_dir.is_dir():
|
|
32
50
|
continue
|
|
@@ -41,7 +59,8 @@ def collect_context(vault: str, biz_daily: str, question: str, top_k: int) -> li
|
|
|
41
59
|
|
|
42
60
|
# 2. 阅读笔记 —— **`Notes/` 这个目录在本仓库里从来不存**(2026-09-27 实测:
|
|
43
61
|
# Vault 里没有它),所以这一段一直搜的是空气。笔记实际住在 `002_Literature`。
|
|
44
|
-
|
|
62
|
+
# 这是**用户自己的**层(文章线),所以 `--line chat` 时整段跳过。
|
|
63
|
+
for relative in (('002_Literature', '003_Ideas', '008_MOC') if want('wiki') else ()):
|
|
45
64
|
notes_dir = Path(vault) / relative
|
|
46
65
|
if not notes_dir.is_dir():
|
|
47
66
|
continue
|
|
@@ -57,9 +76,9 @@ def collect_context(vault: str, biz_daily: str, question: str, top_k: int) -> li
|
|
|
57
76
|
if score > 0:
|
|
58
77
|
results.append({'source': 'note', 'title': md.stem[:60], 'content': text, 'score': score})
|
|
59
78
|
|
|
60
|
-
# 3.
|
|
61
|
-
biz_path = Path(biz_daily)
|
|
62
|
-
for date_dir in sorted(biz_path.glob('20*'), reverse=True)[:7]: # 最近 7 天
|
|
79
|
+
# 3. 最新文章(日报 = 文章线,`--line chat` 时跳过)
|
|
80
|
+
biz_path = Path(biz_daily) if want('wiki') else None
|
|
81
|
+
for date_dir in (sorted(biz_path.glob('20*'), reverse=True)[:7] if biz_path else []): # 最近 7 天
|
|
63
82
|
for md in date_dir.rglob('*.md'):
|
|
64
83
|
if md.name == 'README.md' or 'README' in md.name:
|
|
65
84
|
continue
|
|
@@ -75,7 +94,7 @@ def collect_context(vault: str, biz_daily: str, question: str, top_k: int) -> li
|
|
|
75
94
|
return results[:top_k]
|
|
76
95
|
|
|
77
96
|
|
|
78
|
-
def build_prompt(question: str, context: list[dict]) -> str:
|
|
97
|
+
def build_prompt(question: str, context: list[dict], line: str = 'all') -> str:
|
|
79
98
|
sources_text = ''
|
|
80
99
|
for i, ctx in enumerate(context):
|
|
81
100
|
label = {'concept': '概念', 'note': '笔记', 'article': '文章'}.get(ctx['source'], ctx['source'])
|
|
@@ -83,6 +102,9 @@ def build_prompt(question: str, context: list[dict]) -> str:
|
|
|
83
102
|
|
|
84
103
|
return f'''你是个人知识助手,基于用户笔记库回答问题。
|
|
85
104
|
|
|
105
|
+
**这一趟只检索了:{line_label(line)}**(另外的范围里可能有相关内容)。没有段落时按
|
|
106
|
+
"**这个范围里**没有相关信息"说,别说成"知识库里没有"。
|
|
107
|
+
|
|
86
108
|
知识库内容:
|
|
87
109
|
{sources_text}
|
|
88
110
|
|
|
@@ -99,6 +121,8 @@ def main():
|
|
|
99
121
|
parser.add_argument('--vault', default=DEFAULT_VAULT, help='Vault 路径')
|
|
100
122
|
parser.add_argument('--biz-daily', default=DEFAULT_BIZ, help='biz-daily 路径')
|
|
101
123
|
parser.add_argument('--api-key', default='', help='DeepSeek API key')
|
|
124
|
+
parser.add_argument('--line', choices=list(LINE_CHOICES), default='wiki',
|
|
125
|
+
help='只看一条知识库:wiki=文章线(默认)、chat=聊天线、all=两条都看')
|
|
102
126
|
parser.add_argument('--json', action='store_true', help='JSON 输出')
|
|
103
127
|
args = parser.parse_args()
|
|
104
128
|
|
|
@@ -113,21 +137,23 @@ def main():
|
|
|
113
137
|
sys.exit(1)
|
|
114
138
|
|
|
115
139
|
top_k = int(args.top_k)
|
|
116
|
-
context = collect_context(args.vault, args.biz_daily, question, top_k)
|
|
140
|
+
context = collect_context(args.vault, args.biz_daily, question, top_k, line=args.line)
|
|
117
141
|
|
|
118
142
|
if args.json:
|
|
119
|
-
output = {'question': question, '
|
|
143
|
+
output = {'question': question, 'line': args.line, 'lineLabel': line_label(args.line),
|
|
144
|
+
'sources': [{'source': c['source'], 'title': c['title'], 'score': c['score']} for c in context]}
|
|
120
145
|
if context:
|
|
121
|
-
prompt = build_prompt(question, context)
|
|
146
|
+
prompt = build_prompt(question, context, args.line)
|
|
122
147
|
output['answer'] = call_deepseek(prompt, api_key, max_tokens=800)
|
|
123
148
|
else:
|
|
124
|
-
|
|
149
|
+
# **说清是哪个范围里没有**:`知识库中没有相关信息` 会被读成"整库都没有"
|
|
150
|
+
output['answer'] = '%s里没有相关信息' % line_label(args.line)
|
|
125
151
|
print(json.dumps(output, ensure_ascii=False, indent=2))
|
|
126
152
|
return
|
|
127
153
|
|
|
128
|
-
print(f'🔍 "{question}"
|
|
154
|
+
print(f'🔍 "{question}"(范围:{line_label(args.line)})\n')
|
|
129
155
|
if not context:
|
|
130
|
-
print('
|
|
156
|
+
print('%s里没有相关信息(`--line all` 两条一起看)' % line_label(args.line))
|
|
131
157
|
return
|
|
132
158
|
|
|
133
159
|
print(f'检索到 {len(context)} 条相关内容:\n')
|
|
@@ -135,7 +161,7 @@ def main():
|
|
|
135
161
|
icon = {'concept': '🧠', 'note': '📝', 'article': '📄'}.get(c['source'], '📎')
|
|
136
162
|
print(f' {i+1}. {icon} [{c["source"]}] {c["title"]}')
|
|
137
163
|
|
|
138
|
-
prompt = build_prompt(question, context)
|
|
164
|
+
prompt = build_prompt(question, context, args.line)
|
|
139
165
|
print(f'\n{"="*50}')
|
|
140
166
|
answer = call_deepseek(prompt, api_key, max_tokens=800)
|
|
141
167
|
print(answer)
|
package/scripts/vault_search.py
CHANGED
|
@@ -13,11 +13,24 @@ from datetime import datetime, timedelta
|
|
|
13
13
|
|
|
14
14
|
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
15
15
|
sys.path.insert(0, SCRIPTS_DIR)
|
|
16
|
-
from _utils import CONCEPT_DIRS # noqa: E402
|
|
16
|
+
from _utils import (CONCEPT_DIRS, LINE_LABELS, line_for_relative_concepts) # noqa: E402
|
|
17
17
|
PROJECT_ROOT = os.path.dirname(SCRIPTS_DIR)
|
|
18
18
|
DEFAULT_VAULT = os.path.join(PROJECT_ROOT, 'output', 'wechat-vault')
|
|
19
19
|
DEFAULT_BIZ = os.path.join(PROJECT_ROOT, 'output', 'biz-daily')
|
|
20
20
|
|
|
21
|
+
LINE_CHOICES = ('all', 'wiki', 'chat')
|
|
22
|
+
|
|
23
|
+
# 笔记层里**哪些属于聊天线**。其余笔记层是用户自己写的/读的(文章线)。
|
|
24
|
+
#
|
|
25
|
+
# 两个知识库分开之后,`--line` 要能真的把材料也分开,否则"只看聊天线"仍然会从用户的
|
|
26
|
+
# 阅读笔记里捞结果——那不是聊天知识库里的东西。`Sources/Chat` 是 `chat_notes --vault-copy`
|
|
27
|
+
# 放聊天卡的地方(`scripts/chat_notes.py`),只有它是聊天线的。
|
|
28
|
+
CHAT_NOTE_DIRS = ('Sources/Chat',)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def line_label(line: str) -> str:
|
|
32
|
+
return '两条线' if line == 'all' else LINE_LABELS.get(line, line)
|
|
33
|
+
|
|
21
34
|
|
|
22
35
|
def search_text(query: str, files: list[tuple], top_k: int) -> list[dict]:
|
|
23
36
|
"""关键词搜索 + 简单排序。"""
|
|
@@ -43,10 +56,19 @@ def search_text(query: str, files: list[tuple], top_k: int) -> list[dict]:
|
|
|
43
56
|
return scored[:top_k]
|
|
44
57
|
|
|
45
58
|
|
|
46
|
-
def collect_files(vault: str, biz_daily: str, search_type: str, days: int
|
|
47
|
-
|
|
59
|
+
def collect_files(vault: str, biz_daily: str, search_type: str, days: int,
|
|
60
|
+
line: str = 'all') -> list[tuple]:
|
|
61
|
+
"""收集可搜索文件。
|
|
62
|
+
|
|
63
|
+
`line` 在**收集这一步**就过滤(而不是收完再筛):三条来源各自属于哪条线是这里才知道的
|
|
64
|
+
—— 日报文章与用户的笔记层是文章线、`Sources/Chat` 的聊天卡是聊天线、概念页按目录分。
|
|
65
|
+
|
|
66
|
+
**函数默认 `all`(不缩小范围)**,用户看到的默认(文章线)在 CLI 那一层:忘了传 `line`
|
|
67
|
+
的调用方会多搜一条线,而不是静默少搜一半。
|
|
68
|
+
"""
|
|
48
69
|
files = []
|
|
49
70
|
cutoff = datetime.now() - timedelta(days=days)
|
|
71
|
+
want = (lambda which: line == 'all' or line == which)
|
|
50
72
|
|
|
51
73
|
def parse_fm(text):
|
|
52
74
|
if not text.startswith('---'):
|
|
@@ -61,7 +83,7 @@ def collect_files(vault: str, biz_daily: str, search_type: str, days: int) -> li
|
|
|
61
83
|
fm[k.strip()] = v.strip().strip('"').strip("'")
|
|
62
84
|
return fm
|
|
63
85
|
|
|
64
|
-
if search_type in ('all', 'article'):
|
|
86
|
+
if search_type in ('all', 'article') and want('wiki'):
|
|
65
87
|
# biz-daily 文章
|
|
66
88
|
biz_dir = Path(biz_daily)
|
|
67
89
|
for date_dir in sorted(biz_dir.glob('20*'), reverse=True):
|
|
@@ -86,6 +108,10 @@ def collect_files(vault: str, biz_daily: str, search_type: str, days: int) -> li
|
|
|
86
108
|
# ——命令照样返回结果,只是少了一半。
|
|
87
109
|
vault_path = Path(vault)
|
|
88
110
|
for relative in CONCEPT_DIRS:
|
|
111
|
+
# 概念页**按目录分线**(清单里每条线一个概念目录),而不是按库根——
|
|
112
|
+
# 测试的临时库、或 `--vault` 指到别处时,库根对不上但"这串相对路径属于哪条线"仍在
|
|
113
|
+
if not want(line_for_relative_concepts(relative)):
|
|
114
|
+
continue
|
|
89
115
|
concepts_dir = vault_path / relative
|
|
90
116
|
if not concepts_dir.is_dir():
|
|
91
117
|
continue
|
|
@@ -96,7 +122,7 @@ def collect_files(vault: str, biz_daily: str, search_type: str, days: int) -> li
|
|
|
96
122
|
fm = {}
|
|
97
123
|
files.append((md, 'concept', fm))
|
|
98
124
|
|
|
99
|
-
if search_type in ('all', 'note'):
|
|
125
|
+
if search_type in ('all', 'note') and (want('wiki') or want('chat')):
|
|
100
126
|
# **`Notes/` 这个目录在本仓库里从来不存。** 2026-09-27 实测:Vault 里没有它,
|
|
101
127
|
# 于是 `--type note` 一直搜的是空气——而它本该覆盖的阅读笔记有 25,676 篇,
|
|
102
128
|
# 是最大的一层。笔记实际住在下面这几个目录里。
|
|
@@ -105,6 +131,9 @@ def collect_files(vault: str, biz_daily: str, search_type: str, days: int) -> li
|
|
|
105
131
|
# `Sources/WeChat`(两万六千篇原始文章,走 biz-daily 那条路)不是一回事:
|
|
106
132
|
# 这里面是一百多张**已经提炼过的**卡——会话里聊了什么、有哪些人、欠着什么。
|
|
107
133
|
for sub in ('002_Literature', '001_Daily', '003_Ideas', '008_MOC', 'Sources/Chat'):
|
|
134
|
+
# 笔记层也分线:`Sources/Chat` 是聊天卡(聊天线),其余是用户自己的笔记(文章线)
|
|
135
|
+
if not want('chat' if sub in CHAT_NOTE_DIRS else 'wiki'):
|
|
136
|
+
continue
|
|
108
137
|
notes_dir = vault_path / sub
|
|
109
138
|
if not notes_dir.is_dir():
|
|
110
139
|
continue
|
|
@@ -127,6 +156,8 @@ def main():
|
|
|
127
156
|
parser.add_argument('--top-k', default='10', help='返回数量')
|
|
128
157
|
parser.add_argument('--type', default='all', choices=['all', 'article', 'concept', 'note'])
|
|
129
158
|
parser.add_argument('--days', type=int, default=90, help='搜索天数范围')
|
|
159
|
+
parser.add_argument('--line', choices=list(LINE_CHOICES), default='wiki',
|
|
160
|
+
help='只看一条知识库:wiki=文章线(默认)、chat=聊天线、all=两条都看')
|
|
130
161
|
parser.add_argument('--vault', default=DEFAULT_VAULT, help='Vault 路径')
|
|
131
162
|
parser.add_argument('--biz-daily', default=DEFAULT_BIZ, help='biz-daily 路径')
|
|
132
163
|
parser.add_argument('--json', action='store_true', help='JSON 输出')
|
|
@@ -136,14 +167,19 @@ def main():
|
|
|
136
167
|
print('请提供搜索关键词')
|
|
137
168
|
sys.exit(1)
|
|
138
169
|
|
|
139
|
-
files = collect_files(args.vault, args.biz_daily, args.type, args.days)
|
|
170
|
+
files = collect_files(args.vault, args.biz_daily, args.type, args.days, line=args.line)
|
|
140
171
|
results = search_text(args.query, files, int(args.top_k))
|
|
141
172
|
|
|
142
173
|
if args.json:
|
|
143
|
-
|
|
174
|
+
# **带上范围**:`--line chat` 搜空了和"知识库里真没有"是两回事,而这一支的输出
|
|
175
|
+
# 就是 JSON 本身(CLI 直接把它打给人看),没有别的地方能写这句。
|
|
176
|
+
print(json.dumps({'line': args.line, 'lineLabel': line_label(args.line),
|
|
177
|
+
'type': args.type, 'results': results},
|
|
178
|
+
ensure_ascii=False, indent=2))
|
|
144
179
|
return
|
|
145
180
|
|
|
146
|
-
print(f'🔍 搜索 "{args.query}" 找到 {len(results)} 条
|
|
181
|
+
print(f'🔍 搜索 "{args.query}" 找到 {len(results)} 条 '
|
|
182
|
+
f'(类型: {args.type}, 范围: {line_label(args.line)}, {args.days}天内)\n')
|
|
147
183
|
for i, r in enumerate(results):
|
|
148
184
|
icon = {'article': '📄', 'concept': '🧠', 'note': '📝'}.get(r['category'], '📎')
|
|
149
185
|
num = str(i + 1).rjust(2)
|
|
@@ -20,8 +20,14 @@ import glob
|
|
|
20
20
|
import hashlib
|
|
21
21
|
import os
|
|
22
22
|
import subprocess
|
|
23
|
+
import sys
|
|
23
24
|
import tempfile
|
|
24
25
|
|
|
26
|
+
# 手跑或被重定向时 stdout 走本机编码(Windows 是 GBK):非 GBK 字符(本文件里的 `✓`)会
|
|
27
|
+
# UnicodeEncodeError、整次输出没了;桥接跑有 PYTHONIOENCODING=utf-8 所以看不出来。
|
|
28
|
+
# 这条让两条路的输出一致(同 docs/EXTENDING.md Recipe E,守它的测试是 test/script_stdout_encoding_test.py)。
|
|
29
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
30
|
+
|
|
25
31
|
try:
|
|
26
32
|
from cryptography.hazmat.primitives import padding
|
|
27
33
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
package/scripts/wechat_voice.py
CHANGED
|
@@ -33,6 +33,15 @@ EMPTY_MARKER = '\x00empty'
|
|
|
33
33
|
|
|
34
34
|
DEFAULT_MODEL = 'small'
|
|
35
35
|
|
|
36
|
+
# **转写缓存的家**,由这个模块定义,别处一律引用它。
|
|
37
|
+
#
|
|
38
|
+
# 2026-10-01 踩过一次:`chat_notes` 拿自己的输出目录(`output/chat-notes`)去拼缓存路径,于是
|
|
39
|
+
# 它读到的是 `output/chat-notes/.voice-cache` —— 一个刚被它自己建出来的空目录,而真正的缓存
|
|
40
|
+
# 在 `output/.voice-cache`(1,788 条)。它「命中 16 条」那个数字是自洽的,只是与真缓存不相通,
|
|
41
|
+
# 报告看不出来。位置只要有两份定义就会漂,所以从这里定一份。
|
|
42
|
+
# 可用 `WEFLOW_VOICE_CACHE` 覆盖(测试与自定义布局用)。
|
|
43
|
+
DEFAULT_CACHE_DIR = os.environ.get('WEFLOW_VOICE_CACHE') or os.path.join('output', '.voice-cache')
|
|
44
|
+
|
|
36
45
|
# A Cantonese-tuned model, fetched separately (see the README note in
|
|
37
46
|
# OPERATIONS.md). Worth preferring automatically when present: stock Whisper
|
|
38
47
|
# answers Cantonese speech with fluent-looking Mandarin nonsense - text that
|
package/scripts/wiki_lint.py
CHANGED
|
@@ -25,12 +25,17 @@ import sys
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
27
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
28
|
-
from _utils import CONCEPT_DIRS,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
#
|
|
33
|
-
|
|
28
|
+
from _utils import (CONCEPT_DIRS, KNOWLEDGE_LINES, is_line_qualified, line_for_concept_dir, # noqa: E402
|
|
29
|
+
line_label, link_target_name, normalize_concept_name, parse_frontmatter)
|
|
30
|
+
|
|
31
|
+
# 这里原来还有一份 `DEFAULT_PAGES_DIR = 'output/wechat-vault/Wiki/Concepts'`:**没人用**,
|
|
32
|
+
# 而它是文章线概念目录的第二份拷贝 —— 谁哪天顺手用了它,聊天线就会静默地不被体检
|
|
33
|
+
# ("体检通过"于是成了假话)。默认值现在只有一个来源:`--dir` 不传就是清单里的全部线。
|
|
34
|
+
# 文章线的根(材料目录 `002_Literature`/`Sources/WeChat` 都在它下面)。取自清单的**第一条线**,
|
|
35
|
+
# 不再自己写一遍字符串 —— 两条线各自一个根之后,这里会跟着清单变。
|
|
36
|
+
VAULT_ROOT = KNOWLEDGE_LINES[0][1]
|
|
37
|
+
# 体检要看的**全部**概念页目录:由清单拼出(每条线 = 根 + 线内概念目录)。
|
|
38
|
+
VAULT_CONCEPT_DIRS = tuple(str(Path(root) / concepts) for _line, root, concepts in KNOWLEDGE_LINES)
|
|
34
39
|
# 链接可能指向的「材料」层(与卡片目录一起参与可解析性判断)
|
|
35
40
|
MATERIAL_DIRS = tuple(str(Path(VAULT_ROOT) / d) for d in
|
|
36
41
|
('002_Literature', '001_Daily', 'Sources/Chat', 'Sources/WeChat'))
|
|
@@ -69,19 +74,40 @@ def links_by_section(body: str) -> list:
|
|
|
69
74
|
才真的建了页。所以那一节里的"还没有页"是**扩张候选**,不是坏掉的东西;
|
|
70
75
|
而 `## 来源` 里的 `[[<卡片>.md]]` 断了就是**真断了**(卡片被删或改名)。
|
|
71
76
|
一视同仁地报"40 条死链",看的人只会以为这里烂掉了。
|
|
77
|
+
|
|
78
|
+
名字一律经 `link_target_name` **归一化**(去掉 `|显示文本`、去掉线的路径前缀):
|
|
79
|
+
跨线同名的链接写的是 `[[Chat/Concepts/DeepSeek|DeepSeek]]`,不归一化的话这些链接
|
|
80
|
+
会被当成"带路径的卡片链接"报成断链——而它们其实解析得好好的。
|
|
72
81
|
"""
|
|
73
82
|
section, out = '', []
|
|
74
83
|
for line in (body or '').split('\n'):
|
|
75
84
|
if line.strip().startswith('## '):
|
|
76
85
|
section = line.strip()[3:].strip()
|
|
77
86
|
continue
|
|
78
|
-
for
|
|
79
|
-
name =
|
|
87
|
+
for raw in _LINK.findall(line):
|
|
88
|
+
name = link_target_name(raw)
|
|
80
89
|
if name:
|
|
81
90
|
out.append((section, name))
|
|
82
91
|
return out
|
|
83
92
|
|
|
84
93
|
|
|
94
|
+
def raw_links_by_section(body: str) -> list:
|
|
95
|
+
"""和 `links_by_section` 一样,但**保留原始写法**:`(小节, 原文, 归一化后的名字)`。
|
|
96
|
+
|
|
97
|
+
体检要判"这条链接消歧了没有",而那是**原文**才有的信息(归一化之后两种写法一模一样)。
|
|
98
|
+
"""
|
|
99
|
+
section, out = '', []
|
|
100
|
+
for line in (body or '').split('\n'):
|
|
101
|
+
if line.strip().startswith('## '):
|
|
102
|
+
section = line.strip()[3:].strip()
|
|
103
|
+
continue
|
|
104
|
+
for raw in _LINK.findall(line):
|
|
105
|
+
raw = raw.strip()
|
|
106
|
+
if raw:
|
|
107
|
+
out.append((section, raw, link_target_name(raw)))
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
85
111
|
def collect_card_stems(card_dirs) -> set:
|
|
86
112
|
"""卡片文件名(不带 `.md`)。`## 来源` 那节的链接就是这些名字。"""
|
|
87
113
|
stems = set()
|
|
@@ -138,21 +164,48 @@ def inspect(pages: list, exists, card_links=()) -> dict:
|
|
|
138
164
|
- `aspirational`:`## 相关概念` 那节里指向"还没建页"的概念——**扩张候选,不是错误**;
|
|
139
165
|
- `orphans`:连卡片都没提到过它的页面(卡片链接也算入链,否则每张页都会被误报成孤儿);
|
|
140
166
|
- `empty`:没有定义也没有要点;
|
|
141
|
-
- `duplicateTitles
|
|
167
|
+
- `duplicateTitles`:**同一条线内**撞了名字的页(跨线同名不算,另见下一条);
|
|
168
|
+
- `crossLineSameName`:两条线都有这个名字(**设计使然,不是错误**)—— 合并视图里
|
|
169
|
+
就是靠它看两条线在哪重叠;
|
|
170
|
+
- `ambiguousLinks`:**指向**"两条线都有"那个名字、又**没带路径**的链接 —— 在 Obsidian
|
|
171
|
+
里点它会命中哪一张是不确定的。这一项**能修**(`wiki compile --qualify-links`),
|
|
172
|
+
所以它和上一条不同:上一条不用改,这一条要改;
|
|
142
173
|
- `nearDuplicates`:`{'sameNode': [...], 'contained': [...]}` 两档,可信度差一个量级
|
|
143
174
|
—— 见 `near_duplicate_titles`。
|
|
144
175
|
"""
|
|
145
176
|
stems = {page['stem'] for page in pages}
|
|
146
177
|
inbound = {stem: 0 for stem in stems}
|
|
178
|
+
# 哪条线有哪些名字:下面判"跨线同名""二义链接"都要用,所以先算一次。
|
|
179
|
+
lines_of = {}
|
|
180
|
+
for page in pages:
|
|
181
|
+
lines_of.setdefault(page['stem'], set()).add(page.get('dir', ''))
|
|
182
|
+
ambiguous_names = {name for name, where in lines_of.items() if len(where) > 1}
|
|
183
|
+
# **别名要归位到它那一页,否则那张页永远被报成孤儿。** 文件名是概念名的一个有损变换
|
|
184
|
+
# (`:` `/ ? * " < > |` 换成 `_`,再截到 60 字符),而指向它的链接用的是**原名**:
|
|
185
|
+
# `resolvable_names` 早就把别名算进"存在"了(所以断链是 0),但**入链计数**这里原来只认页码
|
|
186
|
+
# —— 名字对不上,卡片那条链接既不计入链也不报断链,那一页于是在图里是孤岛。
|
|
187
|
+
# (2026-10-01 实测:`[[Qwen3.5:9B]]` → `Qwen3.5_9B.md`,49,953 页里有 28 张。)
|
|
188
|
+
alias_to_stem = {str(alias): page['stem']
|
|
189
|
+
for page in pages
|
|
190
|
+
for alias in (page.get('aliases') or [])
|
|
191
|
+
if str(alias) not in stems}
|
|
192
|
+
|
|
193
|
+
def stem_of(name: str) -> str:
|
|
194
|
+
return alias_to_stem.get(name, name)
|
|
195
|
+
|
|
147
196
|
for name in card_links: # 卡片 → 概念,这是主要入链
|
|
148
|
-
if not (name.endswith('.md') or '/' in name) and name in inbound:
|
|
149
|
-
inbound[name] += 1
|
|
150
|
-
broken, aspirational, empty = [], [], []
|
|
197
|
+
if not (name.endswith('.md') or '/' in name) and stem_of(name) in inbound:
|
|
198
|
+
inbound[stem_of(name)] += 1
|
|
199
|
+
broken, aspirational, empty, ambiguous_links = [], [], [], []
|
|
151
200
|
for page in pages:
|
|
152
|
-
|
|
201
|
+
# 走**保留原文**的那一份:判"消歧了没有"只有原文里有信息(归一化之后两种写法一样)
|
|
202
|
+
for section, raw, name in raw_links_by_section(page['body']):
|
|
203
|
+
if name in ambiguous_names and not is_line_qualified(raw):
|
|
204
|
+
ambiguous_links.append({'page': page['stem'], 'dir': page.get('dir', ''),
|
|
205
|
+
'target': name})
|
|
153
206
|
if exists(name):
|
|
154
|
-
if not (name.endswith('.md') or '/' in name) and name in inbound:
|
|
155
|
-
inbound[name] += 1
|
|
207
|
+
if not (name.endswith('.md') or '/' in name) and stem_of(name) in inbound:
|
|
208
|
+
inbound[stem_of(name)] += 1
|
|
156
209
|
continue
|
|
157
210
|
entry = {'page': page['stem'], 'target': name, 'section': section}
|
|
158
211
|
if any(section.startswith(prefix) for prefix in RELATED_SECTIONS):
|
|
@@ -164,10 +217,24 @@ def inspect(pages: list, exists, card_links=()) -> dict:
|
|
|
164
217
|
orphans = sorted(stem for stem, count in inbound.items() if count == 0)
|
|
165
218
|
titles = {}
|
|
166
219
|
for page in pages:
|
|
167
|
-
|
|
168
|
-
|
|
220
|
+
# **同名要按线分组判**。两条线各有一张同名页是设计使然(用户 2026-10-10 要求两个
|
|
221
|
+
# 概念命名空间分开),它不是"同名页"这一项要找的东西;真该报的是**同一条线里**
|
|
222
|
+
# 撞了名字 —— 那会让 `[[名字]]` 在那一棵目录里就没有确定目标。
|
|
223
|
+
titles.setdefault(page.get('dir', ''), {}).setdefault(
|
|
224
|
+
page['title'] or page['stem'], []).append(page['stem'])
|
|
225
|
+
duplicates = {}
|
|
226
|
+
for _where, by_title in sorted(titles.items()):
|
|
227
|
+
for title, stems in by_title.items():
|
|
228
|
+
if len(stems) > 1:
|
|
229
|
+
duplicates.setdefault(title, []).extend(stems)
|
|
230
|
+
# **跨线同名另立一节**:它不是错误,是"这两条线在哪些概念上有重叠"——合并视图里
|
|
231
|
+
# 唯一想看的那个数(2026-10-10 实测 36 个)。混进 `duplicateTitles` 的话,
|
|
232
|
+
# 读的人会去"修"一个设计如此的东西。
|
|
233
|
+
cross_line = sorted(ambiguous_names)
|
|
169
234
|
return {'pages': len(pages), 'broken': broken, 'aspirational': aspirational,
|
|
170
235
|
'orphans': orphans, 'empty': empty, 'duplicateTitles': duplicates,
|
|
236
|
+
'crossLineSameName': cross_line,
|
|
237
|
+
'ambiguousLinks': ambiguous_links,
|
|
171
238
|
'degenerateFields': degenerate_fields(pages),
|
|
172
239
|
'nearDuplicates': near_duplicate_titles(pages)}
|
|
173
240
|
|
|
@@ -214,7 +281,7 @@ def near_duplicate_titles(pages: list) -> dict:
|
|
|
214
281
|
# **按目录分开比。** 两个概念目录是**两个知识库**(D-051),同一个名字两边各有一张页是
|
|
215
282
|
# 设计使然、不是重复;`compile_wiki --merge-duplicates` 也是按目录跑的,永远动不了跨线的
|
|
216
283
|
# 组。2026-09-28 实测:合并跑完后还剩 2 组(`AI 工具`、`GLORIA`)全是跨线的 —— 而报告
|
|
217
|
-
# 写着"这些就是合并会合并的",是句假话。跨线同名另有 `
|
|
284
|
+
# 写着"这些就是合并会合并的",是句假话。跨线同名另有 `crossLineSameName` 那一节在报。
|
|
218
285
|
named = sorted({(p.get('dir', ''), p['title'] or p['stem']) for p in pages})
|
|
219
286
|
same, contained = [], []
|
|
220
287
|
for where in sorted({w for w, _ in named}):
|
|
@@ -326,12 +393,16 @@ def collect(pages_dir: str, card_dirs) -> list:
|
|
|
326
393
|
# 判重按目录分开比(两个目录是两个知识库),所以要记住这张页住哪儿
|
|
327
394
|
'dir': str(pages_dir),
|
|
328
395
|
'topic': compile_frontmatter_topic(frontmatter),
|
|
329
|
-
|
|
396
|
+
# `parse_frontmatter` 已经把外层引号拆掉了,**这里不能再剥一次**:
|
|
397
|
+
# `strip('"')` 会连名字**自己末尾**的引号一起吃掉,`AI 长出"手脚"` 变成
|
|
398
|
+
# `AI 长出"手脚` —— 然后它与卡片里那条链接差一个字符,永远匹配不上,
|
|
399
|
+
# 那页就被报成孤儿(2026-10-01 实测,49,953 页里 1 张)。
|
|
400
|
+
'title': str(frontmatter.get('title') or ''),
|
|
330
401
|
# **别名也算"指向这一页的名字"**:`--merge-duplicates` 把
|
|
331
402
|
# `GPT-5.6` 并进 `GPT 5.6` 时就是靠 aliases 让旧链接不断的,
|
|
332
403
|
# Obsidian 认它。体检不认的话,会把这些名字报成"还没建页、
|
|
333
404
|
# 建议再跑 compile" —— 而它们已经有页了。
|
|
334
|
-
'aliases': [str(a).strip()
|
|
405
|
+
'aliases': [str(a).strip() for a in raw_aliases],
|
|
335
406
|
'links': extract_links(body),
|
|
336
407
|
'body': body})
|
|
337
408
|
return pages
|
|
@@ -364,12 +435,24 @@ def main():
|
|
|
364
435
|
# 只查卡片目录会把这类全报成断链——实测 1,950 条,而它们在 Obsidian 里都是好的
|
|
365
436
|
# (两边文件名本来就不同:卡片保留完整标题,阅读笔记截到 50 字)。
|
|
366
437
|
card_stems = collect_card_stems(tuple(CARD_DIRS) + MATERIAL_DIRS)
|
|
438
|
+
resolvable = resolvable_names(pages) # 只算一次(见下面那条注释)
|
|
367
439
|
report = inspect(pages,
|
|
368
|
-
|
|
440
|
+
|
|
441
|
+
# **注意别把它写回 lambda 里**:闭包体是**调用时**求值的,
|
|
442
|
+
# 写在里面等于每条链接都重建一次全部页名的集合 —— 实测文章线
|
|
443
|
+
# (3,623 页)因此要 9 分 29 秒,聊天线(1,527 页)只要 3.2 秒。
|
|
444
|
+
# 提出来算一次之后是同一条线的秒级。
|
|
445
|
+
lambda name: resolve(name, resolvable, CARD_DIRS, card_stems),
|
|
369
446
|
card_links)
|
|
370
447
|
report['success'] = True
|
|
371
448
|
report['pagesDir'] = existing
|
|
372
449
|
report['cardLinks'] = len(card_links)
|
|
450
|
+
# **这一趟体检的是哪条线**:读的人(助手的 `wiki_health`)要按线分栏报,而它原来是从
|
|
451
|
+
# 路径里找 `Chat` 这个词猜的 —— 两条线各自一个根之后聊天线很可能不再叫 `Chat`,
|
|
452
|
+
# 于是它会**把聊天线报成文章线**,且不报错。所以由这一侧给出(认不出线就给 null)。
|
|
453
|
+
lines = [line_for_concept_dir(d) for d in existing]
|
|
454
|
+
report['line'] = lines[0] if len(set(lines)) == 1 else None
|
|
455
|
+
report['lineLabel'] = line_label(report['line'])
|
|
373
456
|
|
|
374
457
|
if args.json:
|
|
375
458
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
@@ -392,9 +475,25 @@ def main():
|
|
|
392
475
|
print(' %s(%d 字)' % (item['page'], item['chars']))
|
|
393
476
|
print(' 重新跑一次 wiki compile 通常就好了(那次模型返回不完整)')
|
|
394
477
|
if report['duplicateTitles']:
|
|
395
|
-
print('\n同名页 %d
|
|
478
|
+
print('\n同名页 %d 组(**同一条线内**撞了名字):' % len(report['duplicateTitles']))
|
|
396
479
|
for title, stems in list(report['duplicateTitles'].items())[:5]:
|
|
397
480
|
print(' %s ← %s' % (title, '、'.join(stems)))
|
|
481
|
+
if report.get('crossLineSameName'):
|
|
482
|
+
# 不当成问题报:两条线各留一张同名页是设计(用户要求两个命名空间分开)。
|
|
483
|
+
# 列出来是因为"哪些概念两条线都有"本身有信息量,而它**只能从这里看到**。
|
|
484
|
+
print('\n两条线都有的概念 %d 个(**设计如此,不用改**):%s'
|
|
485
|
+
% (len(report['crossLineSameName']), '、'.join(report['crossLineSameName'][:12])))
|
|
486
|
+
print(' 在 Obsidian 里这些名字的裸链接是**二义的**(官方:可能命中的不是你想要的'
|
|
487
|
+
'那一张)。要精确指着某一条线就用带路径的链接 `[[Wiki/Concepts/名字]]`')
|
|
488
|
+
if report.get('ambiguousLinks'):
|
|
489
|
+
rows = report['ambiguousLinks']
|
|
490
|
+
pages_hit = len({r['page'] for r in rows})
|
|
491
|
+
print('\n**二义的链接 %d 条**(指向"两条线都有"的名字、又没带路径;%d 张页):'
|
|
492
|
+
% (len(rows), pages_hit))
|
|
493
|
+
for item in rows[:5]:
|
|
494
|
+
print(' %s → [[%s]]' % (item['page'], item['target']))
|
|
495
|
+
print(' 修:`weflow-cli wiki compile --qualify-links`(本地重写、不调模型;'
|
|
496
|
+
'先加 `--dry-run` 看条数)')
|
|
398
497
|
if report['aspirational']:
|
|
399
498
|
names = sorted({item['target'] for item in report['aspirational']})
|
|
400
499
|
print('\n还没建页的相关概念 %d 个(**扩张候选,不是错误**):%s'
|
|
@@ -420,8 +519,9 @@ def main():
|
|
|
420
519
|
for field, info in report['degenerateFields'].items():
|
|
421
520
|
print(' %s: %s(%s/%s 页)' % (field, info['值'], info['页数'], info.get('总页数', report['pages'])))
|
|
422
521
|
print(' 多半是上游分类器把整批语料扔进了一个桶——不改分类器的话,别信这个字段')
|
|
423
|
-
if not any((report['broken'], report['orphans'], report['empty'], report['duplicateTitles']
|
|
424
|
-
|
|
522
|
+
if not any((report['broken'], report['orphans'], report['empty'], report['duplicateTitles'],
|
|
523
|
+
report.get('ambiguousLinks'))):
|
|
524
|
+
print('\n没有需要修的问题(断链/孤儿/空页/同名/二义链接)')
|
|
425
525
|
return 0
|
|
426
526
|
|
|
427
527
|
|
|
@@ -183,7 +183,18 @@ export class DbPathService {
|
|
|
183
183
|
/**
|
|
184
184
|
* 自动检测微信数据库根目录
|
|
185
185
|
*/
|
|
186
|
-
|
|
186
|
+
/**
|
|
187
|
+
* 找一个可用的数据根。
|
|
188
|
+
*
|
|
189
|
+
* `hintedRoots` 是**调用方拿到的线索**,会被**最先**试(见 bin 里的 init:它把客户端
|
|
190
|
+
* 命令行自报的 `--wechat-files-path` 放进来)。为什么值得优先:下面那两条都是**按目录名猜**
|
|
191
|
+
* (常见路径 / 全盘扫 `xwechat_files`),而这一条是**客户端自己说的** —— 也是唯一一条
|
|
192
|
+
* 不受"目录被建在别处、名字还不一样"影响的来源。
|
|
193
|
+
*
|
|
194
|
+
* 线索**同样要过 `resolveDataRoot` 验证**(找到账户目录才算数):拿到一个路径 ≠ 它能用。
|
|
195
|
+
*/
|
|
196
|
+
async autoDetect(options: { searchDrives?: boolean; fullScan?: boolean; hintedRoots?: string[] } = {}):
|
|
197
|
+
Promise<{ success: boolean; path?: string; error?: string; source?: 'client' | 'known' | 'scan' }> {
|
|
187
198
|
try {
|
|
188
199
|
const possiblePaths: string[] = []
|
|
189
200
|
const home = homedir()
|
|
@@ -227,10 +238,17 @@ export class DbPathService {
|
|
|
227
238
|
}
|
|
228
239
|
}
|
|
229
240
|
|
|
241
|
+
// 1) **客户端自己说的**(命令行里的 `--wechat-files-path`)。取不到就走下面的老路。
|
|
242
|
+
for (const hint of options.hintedRoots ?? []) {
|
|
243
|
+
const dataRoot = this.resolveDataRoot(hint)
|
|
244
|
+
if (dataRoot) return { success: true, path: dataRoot, source: 'client' }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 2) 常见路径(含按目录名在几个已知位置里找)
|
|
230
248
|
for (const path of possiblePaths) {
|
|
231
249
|
const dataRoot = this.resolveDataRoot(path)
|
|
232
250
|
if (dataRoot) {
|
|
233
|
-
return { success: true, path: dataRoot }
|
|
251
|
+
return { success: true, path: dataRoot, source: 'known' }
|
|
234
252
|
}
|
|
235
253
|
}
|
|
236
254
|
|
|
@@ -238,7 +256,7 @@ export class DbPathService {
|
|
|
238
256
|
for (const volumeRoot of this.getVolumeRoots()) {
|
|
239
257
|
for (const dataRootPath of this.findKnownDataRoots(volumeRoot, 12)) {
|
|
240
258
|
const dataRoot = this.resolveDataRoot(dataRootPath)
|
|
241
|
-
if (dataRoot) return { success: true, path: dataRoot }
|
|
259
|
+
if (dataRoot) return { success: true, path: dataRoot, source: 'scan' }
|
|
242
260
|
}
|
|
243
261
|
}
|
|
244
262
|
}
|
|
@@ -246,7 +264,7 @@ export class DbPathService {
|
|
|
246
264
|
if (options.fullScan) {
|
|
247
265
|
for (const volumeRoot of this.getVolumeRoots()) {
|
|
248
266
|
const accountPath = this.findAccountDescendant(volumeRoot, 32)
|
|
249
|
-
if (accountPath) return { success: true, path: dirname(accountPath) }
|
|
267
|
+
if (accountPath) return { success: true, path: dirname(accountPath), source: 'scan' }
|
|
250
268
|
}
|
|
251
269
|
}
|
|
252
270
|
|