weflow-cli 1.6.4 → 1.8.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 +3 -2
- package/CHANGELOG.md +1966 -0
- package/OPERATIONS.md +808 -0
- package/README.en.md +93 -7
- package/README.md +31 -6
- package/bin/weflow-cli.ts +1245 -26
- package/dist/bin/weflow-cli.js +1349 -72
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.js +39 -13
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/appMsgFormat.d.ts +37 -0
- package/dist/src/core/appMsgFormat.d.ts.map +1 -0
- package/dist/src/core/appMsgFormat.js +106 -0
- package/dist/src/core/appMsgFormat.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +43 -0
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +31 -0
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts +0 -12
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +5 -64
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/core/wcdbCore.d.ts +1 -1
- package/dist/src/core/wcdbCore.d.ts.map +1 -1
- package/dist/src/core/wcdbCore.js +5 -1
- package/dist/src/core/wcdbCore.js.map +1 -1
- package/dist/src/panel/client.d.ts +25 -0
- package/dist/src/panel/client.d.ts.map +1 -0
- package/dist/src/panel/client.js +34 -0
- package/dist/src/panel/client.js.map +1 -0
- package/dist/src/panel/endpoint.d.ts +45 -0
- package/dist/src/panel/endpoint.d.ts.map +1 -0
- package/dist/src/panel/endpoint.js +132 -0
- package/dist/src/panel/endpoint.js.map +1 -0
- package/dist/src/panel/launch.d.ts +50 -0
- package/dist/src/panel/launch.d.ts.map +1 -0
- package/dist/src/panel/launch.js +74 -0
- package/dist/src/panel/launch.js.map +1 -0
- package/dist/src/panel/server.d.ts +23 -0
- package/dist/src/panel/server.d.ts.map +1 -0
- package/dist/src/panel/server.js +413 -0
- package/dist/src/panel/server.js.map +1 -0
- package/dist/src/panel/userId.d.ts +37 -0
- package/dist/src/panel/userId.d.ts.map +1 -0
- package/dist/src/panel/userId.js +56 -0
- package/dist/src/panel/userId.js.map +1 -0
- package/dist/src/services/assistantDaemon.d.ts +20 -3
- package/dist/src/services/assistantDaemon.d.ts.map +1 -1
- package/dist/src/services/assistantDaemon.js +46 -9
- package/dist/src/services/assistantDaemon.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +141 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -0
- package/dist/src/services/assistantEval.js +497 -0
- package/dist/src/services/assistantEval.js.map +1 -0
- package/dist/src/services/assistantMemory.d.ts +106 -4
- package/dist/src/services/assistantMemory.d.ts.map +1 -1
- package/dist/src/services/assistantMemory.js +396 -61
- package/dist/src/services/assistantMemory.js.map +1 -1
- package/dist/src/services/assistantPrivacy.d.ts +15 -2
- package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
- package/dist/src/services/assistantPrivacy.js +18 -4
- package/dist/src/services/assistantPrivacy.js.map +1 -1
- package/dist/src/services/assistantRouter.d.ts +41 -0
- package/dist/src/services/assistantRouter.d.ts.map +1 -0
- package/dist/src/services/assistantRouter.js +151 -0
- package/dist/src/services/assistantRouter.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +209 -3
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +590 -75
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +205 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +1089 -58
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +47 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -0
- package/dist/src/services/assistantTrace.js +150 -0
- package/dist/src/services/assistantTrace.js.map +1 -0
- package/dist/src/services/chatService.d.ts +13 -0
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +23 -0
- package/dist/src/services/chatService.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 +15 -8
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +35 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +61 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/pythonBridge.d.ts +41 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -0
- package/dist/src/services/pythonBridge.js +119 -0
- package/dist/src/services/pythonBridge.js.map +1 -0
- package/dist/src/services/syncService.d.ts +127 -0
- package/dist/src/services/syncService.d.ts.map +1 -0
- package/dist/src/services/syncService.js +263 -0
- package/dist/src/services/syncService.js.map +1 -0
- package/dist/src/services/syncState.d.ts +113 -0
- package/dist/src/services/syncState.d.ts.map +1 -0
- package/dist/src/services/syncState.js +214 -0
- package/dist/src/services/syncState.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +25 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +69 -7
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/utils/atomicWrite.d.ts +4 -0
- package/dist/src/utils/atomicWrite.d.ts.map +1 -0
- package/dist/src/utils/atomicWrite.js +60 -0
- package/dist/src/utils/atomicWrite.js.map +1 -0
- package/dist/src/utils/dateRange.d.ts +15 -0
- package/dist/src/utils/dateRange.d.ts.map +1 -1
- package/dist/src/utils/dateRange.js +47 -0
- package/dist/src/utils/dateRange.js.map +1 -1
- package/dist/src/utils/text.d.ts +18 -0
- package/dist/src/utils/text.d.ts.map +1 -0
- package/dist/src/utils/text.js +20 -0
- package/dist/src/utils/text.js.map +1 -0
- package/docs/AI_INTERFACE.md +2 -0
- package/docs/DECISIONS.md +1484 -20
- package/docs/EXTENDING.md +187 -0
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
- package/docs/MCP.md +23 -4
- package/docs/PROJECT_STATE.md +299 -96
- package/docs/SETUP.md +22 -0
- package/docs/SYNC_CONTRACT.md +180 -0
- package/mcp-server/index.ts +681 -657
- package/package.json +84 -83
- package/resources/panel/ball-position.cjs +153 -0
- package/resources/panel/index.html +46 -0
- package/resources/panel/main.cjs +447 -0
- package/resources/panel/mascot-focus.png +0 -0
- package/resources/panel/mascot-happy.png +0 -0
- package/resources/panel/mascot-sorry.png +0 -0
- package/resources/panel/mascot-tired.png +0 -0
- package/resources/panel/mascot.png +0 -0
- package/resources/panel/package.json +7 -0
- package/resources/panel/panel.css +407 -0
- package/resources/panel/preload.cjs +72 -0
- package/resources/panel/quick-menu.cjs +197 -0
- package/resources/panel/renderer.js +441 -0
- package/resources/panel/tray-menu.cjs +40 -0
- package/resources/panel/tray.png +0 -0
- package/scripts/_utils.py +403 -54
- package/scripts/annual_report.py +4 -2
- package/scripts/article_notes.py +423 -0
- package/scripts/backfill_articles.py +438 -0
- package/scripts/biz_daily.py +590 -105
- package/scripts/chat_notes.py +470 -0
- package/scripts/classify_daily.py +10 -18
- package/scripts/compile_wiki.py +1059 -46
- package/scripts/create_reading_notes.py +199 -14
- package/scripts/decide.py +253 -0
- package/scripts/draft_reply.py +671 -0
- package/scripts/export_chat_html.py +212 -46
- package/scripts/extract_todos.py +31 -5
- package/scripts/fav_notes.py +318 -0
- package/scripts/fix_topics.py +2 -1
- package/scripts/generate_ai_report.py +214 -9
- package/scripts/generate_html.py +18 -3
- package/scripts/generate_review.py +2 -2
- package/scripts/jev_client.py +294 -0
- package/scripts/jev_probe.py +440 -0
- package/scripts/nt_common.py +82 -0
- package/scripts/nt_decrypt.py +1755 -1352
- package/scripts/pipeline.py +27 -2
- package/scripts/promote_ideas.py +19 -4
- package/scripts/quality_eval.py +707 -0
- package/scripts/rag_chat.py +4 -4
- package/scripts/read_image.py +187 -0
- package/scripts/reply_debt.py +488 -0
- package/scripts/route_cards.py +453 -0
- package/scripts/semantic_search.py +208 -49
- package/scripts/user_notes.py +315 -0
- package/scripts/vault_rag.py +13 -7
- package/scripts/vault_search.py +21 -4
- package/scripts/wiki_lint.py +397 -0
- package/src/core/appMsgFormat.ts +117 -0
- package/src/core/ntCore.ts +67 -0
- package/src/core/sqlcipherCore.ts +5 -61
- package/src/core/wcdbCore.ts +6 -2
- package/src/panel/client.ts +49 -0
- package/src/panel/endpoint.ts +144 -0
- package/src/panel/launch.ts +101 -0
- package/src/panel/server.ts +424 -0
- package/src/panel/userId.ts +72 -0
- package/src/services/assistantDaemon.ts +57 -9
- package/src/services/assistantEval.ts +611 -0
- package/src/services/assistantMemory.ts +413 -57
- package/src/services/assistantPrivacy.ts +18 -4
- package/src/services/assistantRouter.ts +191 -0
- package/src/services/assistantService.ts +671 -74
- package/src/services/assistantTools.ts +1118 -57
- package/src/services/assistantTrace.ts +175 -0
- package/src/services/chatService.ts +30 -0
- package/src/services/configService.ts +403 -385
- package/src/services/messageQuery.ts +97 -0
- package/src/services/pythonBridge.ts +139 -0
- package/src/services/syncService.ts +363 -0
- package/src/services/syncState.ts +275 -0
- package/src/services/wechatMessageService.ts +73 -7
- package/src/utils/atomicWrite.ts +56 -0
- package/src/utils/dateRange.ts +46 -0
- package/src/utils/text.ts +20 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
知识库体检 — 概念页的死链、孤儿与空页。**全本地:不联网、不调用任何模型。**
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python scripts/wiki_lint.py # 人看的报告
|
|
7
|
+
python scripts/wiki_lint.py --json # 机器读的
|
|
8
|
+
|
|
9
|
+
查四样(前两样就是 D-049 里记着"没做"的那两件事):
|
|
10
|
+
- **死链**:页面里的 `[[X]]` 指向一个不存在的页面。概念页之间互链,以及 `## 来源` 那节
|
|
11
|
+
指回卡片——卡片被删或改名,链接就断了,而**看页面本身看不出来**。
|
|
12
|
+
- **孤儿**:没有任何页面链接到它。多半意味着它聚出来的概念名跟别处对不上
|
|
13
|
+
(同一个东西写成两个名字),是"该合并"的信号。
|
|
14
|
+
- **空页**:没有定义也没有要点(正文几乎为空)。模型那次返回不完整时会留下这种。
|
|
15
|
+
- **同名**:两页标题相同(文件名不同)——会把同一个概念劈成两份。
|
|
16
|
+
|
|
17
|
+
为什么值得有:生成那一步(`wiki compile`)**不会**告诉你这些。它按概念名写文件,
|
|
18
|
+
写完就算成功;而"写进去的东西连不连得起来"是另一件事。
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
28
|
+
from _utils import CONCEPT_DIRS, parse_frontmatter # noqa: E402
|
|
29
|
+
|
|
30
|
+
DEFAULT_PAGES_DIR = 'output/wechat-vault/Wiki/Concepts'
|
|
31
|
+
VAULT_ROOT = 'output/wechat-vault'
|
|
32
|
+
# 体检要看的**全部**概念页目录(Vault 相对路径那份清单在 `_utils.CONCEPT_DIRS`)。
|
|
33
|
+
VAULT_CONCEPT_DIRS = tuple(str(Path(VAULT_ROOT) / d) for d in CONCEPT_DIRS)
|
|
34
|
+
# 链接可能指向的「材料」层(与卡片目录一起参与可解析性判断)
|
|
35
|
+
MATERIAL_DIRS = tuple(str(Path(VAULT_ROOT) / d) for d in
|
|
36
|
+
('002_Literature', '001_Daily', 'Sources/Chat', 'Sources/WeChat'))
|
|
37
|
+
# 卡片在哪(`## 来源` 那节的 `[[<相对路径>.md]]` 要从这些目录里找)。
|
|
38
|
+
# **用通配而不是写死目录名**:今天是文章线与对话线,明天再加一条来源(收藏线就是这么加的),
|
|
39
|
+
# 写死的话体检会**静默漏算**那条线的入链——而漏算的表现是"每张新页都是孤儿"。
|
|
40
|
+
CARD_DIRS = tuple(str(path) for path in sorted(Path('output').glob('*-notes'))) or (
|
|
41
|
+
'output/article-notes', 'output/chat-notes')
|
|
42
|
+
# 少于这个字数就算空页(概念页有定义+要点,正常几百字)
|
|
43
|
+
MIN_BODY_CHARS = 80
|
|
44
|
+
|
|
45
|
+
_LINK = re.compile(r'\[\[([^\]]+)\]\]')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def compile_frontmatter_topic(frontmatter: dict) -> str:
|
|
49
|
+
"""页面的 `topics`(生成时写进去的)取第一个——体检只关心它退不退化。"""
|
|
50
|
+
raw = frontmatter.get('topics') or frontmatter.get('topic') or ''
|
|
51
|
+
if isinstance(raw, (list, tuple)):
|
|
52
|
+
raw = raw[0] if raw else ''
|
|
53
|
+
return str(raw).strip().strip('[]').strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def extract_links(body: str) -> list:
|
|
57
|
+
"""页面里所有 `[[…]]` 的名字。
|
|
58
|
+
|
|
59
|
+
**故意不过滤**(不像生成那侧会滤掉路径与主题词):体检查的是"链接连不连得起来",
|
|
60
|
+
滤掉就等于把断掉的链接藏起来——那正好是它要找的东西。
|
|
61
|
+
"""
|
|
62
|
+
return [name.strip() for name in _LINK.findall(body or '') if name.strip()]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def links_by_section(body: str) -> list:
|
|
66
|
+
"""每条链接连同它所在的 `## 小节` —— 因为**两种死链的意义完全不同**。
|
|
67
|
+
|
|
68
|
+
`## 相关概念` 里的链接是模型提出的"这个概念还该跟谁连",而只有被引用最多的那些
|
|
69
|
+
才真的建了页。所以那一节里的"还没有页"是**扩张候选**,不是坏掉的东西;
|
|
70
|
+
而 `## 来源` 里的 `[[<卡片>.md]]` 断了就是**真断了**(卡片被删或改名)。
|
|
71
|
+
一视同仁地报"40 条死链",看的人只会以为这里烂掉了。
|
|
72
|
+
"""
|
|
73
|
+
section, out = '', []
|
|
74
|
+
for line in (body or '').split('\n'):
|
|
75
|
+
if line.strip().startswith('## '):
|
|
76
|
+
section = line.strip()[3:].strip()
|
|
77
|
+
continue
|
|
78
|
+
for name in _LINK.findall(line):
|
|
79
|
+
name = name.strip()
|
|
80
|
+
if name:
|
|
81
|
+
out.append((section, name))
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def collect_card_stems(card_dirs) -> set:
|
|
86
|
+
"""卡片文件名(不带 `.md`)。`## 来源` 那节的链接就是这些名字。"""
|
|
87
|
+
stems = set()
|
|
88
|
+
for directory in card_dirs:
|
|
89
|
+
root = Path(directory)
|
|
90
|
+
if root.exists():
|
|
91
|
+
stems |= {p.stem for p in root.rglob('*.md')}
|
|
92
|
+
return stems
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def collect_cards(card_dirs) -> list:
|
|
96
|
+
"""卡片里的链接——它们才是概念页**真正的**入链(每张卡都链着它提炼出的概念)。"""
|
|
97
|
+
links = []
|
|
98
|
+
for directory in card_dirs:
|
|
99
|
+
root = Path(directory)
|
|
100
|
+
if not root.exists():
|
|
101
|
+
continue
|
|
102
|
+
for path in root.rglob('*.md'):
|
|
103
|
+
try:
|
|
104
|
+
_, body = parse_frontmatter(path.read_text(encoding='utf-8'))
|
|
105
|
+
except Exception:
|
|
106
|
+
continue
|
|
107
|
+
links.extend(extract_links(body))
|
|
108
|
+
return links
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve(name: str, page_stems: set, card_dirs, card_stems=None) -> bool:
|
|
112
|
+
"""这个链接指向的东西存在吗。
|
|
113
|
+
|
|
114
|
+
- 带路径或以 `.md` 结尾的 → 是**来源卡片**,去卡片目录里找;
|
|
115
|
+
- 否则 → ⚠️ **既可能是概念,也可能是卡片**。
|
|
116
|
+
|
|
117
|
+
这里原来写的是"裸名字就是概念",而 `## 来源` 那节的链接**就是裸的卡片名**
|
|
118
|
+
(`compile_wiki --fix-source-links` 把 `.md` 去掉了,与全库其余链接统一)。
|
|
119
|
+
于是那条判据把 7,872 条**在 Obsidian 里解析得好好的**链接全报成了断链。
|
|
120
|
+
|
|
121
|
+
`card_stems` 为空时退回旧行为,好让调用方不必一次性全改。
|
|
122
|
+
"""
|
|
123
|
+
if name.endswith('.md') or '/' in name:
|
|
124
|
+
target = name if name.endswith('.md') else name + '.md'
|
|
125
|
+
return any((Path(d) / target).exists() for d in card_dirs)
|
|
126
|
+
if name in page_stems:
|
|
127
|
+
return True
|
|
128
|
+
return name in (card_stems or set())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
RELATED_SECTIONS = ('相关概念', '相关主题', '相关')
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def inspect(pages: list, exists, card_links=()) -> dict:
|
|
135
|
+
"""纯函数:给定页面、"链接是否存在"的判据、以及卡片里的入链,报出问题。
|
|
136
|
+
|
|
137
|
+
- `broken`:**真断了的**链接——指向不存在的卡片(卡片删了/改名了);
|
|
138
|
+
- `aspirational`:`## 相关概念` 那节里指向"还没建页"的概念——**扩张候选,不是错误**;
|
|
139
|
+
- `orphans`:连卡片都没提到过它的页面(卡片链接也算入链,否则每张页都会被误报成孤儿);
|
|
140
|
+
- `empty`:没有定义也没有要点;
|
|
141
|
+
- `duplicateTitles`:同名页。
|
|
142
|
+
"""
|
|
143
|
+
stems = {page['stem'] for page in pages}
|
|
144
|
+
inbound = {stem: 0 for stem in stems}
|
|
145
|
+
for name in card_links: # 卡片 → 概念,这是主要入链
|
|
146
|
+
if not (name.endswith('.md') or '/' in name) and name in inbound:
|
|
147
|
+
inbound[name] += 1
|
|
148
|
+
broken, aspirational, empty = [], [], []
|
|
149
|
+
for page in pages:
|
|
150
|
+
for section, name in links_by_section(page['body']):
|
|
151
|
+
if exists(name):
|
|
152
|
+
if not (name.endswith('.md') or '/' in name) and name in inbound:
|
|
153
|
+
inbound[name] += 1
|
|
154
|
+
continue
|
|
155
|
+
entry = {'page': page['stem'], 'target': name, 'section': section}
|
|
156
|
+
if any(section.startswith(prefix) for prefix in RELATED_SECTIONS):
|
|
157
|
+
aspirational.append(entry)
|
|
158
|
+
else:
|
|
159
|
+
broken.append(entry)
|
|
160
|
+
if len(page['body'].strip()) < MIN_BODY_CHARS:
|
|
161
|
+
empty.append({'page': page['stem'], 'chars': len(page['body'].strip())})
|
|
162
|
+
orphans = sorted(stem for stem, count in inbound.items() if count == 0)
|
|
163
|
+
titles = {}
|
|
164
|
+
for page in pages:
|
|
165
|
+
titles.setdefault(page['title'] or page['stem'], []).append(page['stem'])
|
|
166
|
+
duplicates = {title: stems for title, stems in titles.items() if len(stems) > 1}
|
|
167
|
+
return {'pages': len(pages), 'broken': broken, 'aspirational': aspirational,
|
|
168
|
+
'orphans': orphans, 'empty': empty, 'duplicateTitles': duplicates,
|
|
169
|
+
'degenerateFields': degenerate_fields(pages),
|
|
170
|
+
'nearDuplicates': near_duplicate_titles(pages)}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# 一个字段有 ≥ 这么多页、且某一个值占了 ≥ 这个比例,就当成"退化了"(等于没携带信息)
|
|
174
|
+
DEGENERATE_MIN_PAGES = 5
|
|
175
|
+
DEGENERATE_RATIO = 0.8
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def longest_common_run(left: str, right: str) -> int:
|
|
179
|
+
"""两串的最长公共子串长度(中文里"同一件事被转发多次"的信号)。"""
|
|
180
|
+
if not left or not right:
|
|
181
|
+
return 0
|
|
182
|
+
previous = [0] * (len(right) + 1)
|
|
183
|
+
best = 0
|
|
184
|
+
for i in range(1, len(left) + 1):
|
|
185
|
+
current = [0] * (len(right) + 1)
|
|
186
|
+
for j in range(1, len(right) + 1):
|
|
187
|
+
if left[i - 1] == right[j - 1]:
|
|
188
|
+
current[j] = previous[j - 1] + 1
|
|
189
|
+
best = max(best, current[j])
|
|
190
|
+
previous = current
|
|
191
|
+
return best
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
DUP_RUN_CHARS = 5 # 公共子串至少这么长
|
|
195
|
+
DUP_COVER_RATIO = 0.4 # 而且占较短标题的这么一大部分
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def near_duplicate_titles(pages: list) -> list:
|
|
199
|
+
"""标题高度相似的页——**同一件事被多个公众号转发**的痕迹。只报告,不自动去重。
|
|
200
|
+
|
|
201
|
+
这条判据**有两处已知的失效,都是 2026-09-28 量出来的**,改判据前先看这里:
|
|
202
|
+
|
|
203
|
+
1. **它抓不住它自己当初的动机用例**。写这个函数是因为《打虎!陈勇被查》与
|
|
204
|
+
《中建集团副总经理陈勇被查》是同一件事 —— 但那是"同一人、换了标题",两串的最长公共
|
|
205
|
+
子串只有 4 字("陈勇被查"),**任何子串判据都抓不到**。当年那段注释把这条写成了
|
|
206
|
+
"实测那批里有一组四条",是**不成立的**:实测那四个人两两之间只有 2 对满足判据。
|
|
207
|
+
2. **它在今天的规模上退化成了噪音**。判据(公共子串 ≥5 字、且占较短标题 ≥40%)是
|
|
208
|
+
5,062 张页、长中文新闻标题时定的;现在 22,374 张页里有很多**短名字**
|
|
209
|
+
(`AI Agent开发框架` 与 `生物信息学LLM Agent综述` 共享 "Agent"),于是报出
|
|
210
|
+
**424,697 组**,前几条全是这类巧合。这个数不是"转发灌水",是判据失去了分辨力。
|
|
211
|
+
|
|
212
|
+
抽 20 组复核判据本身都成立(没有假阳性),所以问题在**门槛**不在实现。要收紧的话,
|
|
213
|
+
"规范化后一个标题包含另一个(短的 ≥8 字)"实测 1,234 组、看着都是真的相似
|
|
214
|
+
(`世界人工智能大会` / `2026世界人工智能大会`),代价是**换一类漏检**(同样抓不到第 1 条
|
|
215
|
+
那种换标题的写法)。哪个更合用于"关系图谱该不该合并",得由人定 —— 所以这里不动,
|
|
216
|
+
只把失效写在看得见的地方。
|
|
217
|
+
"""
|
|
218
|
+
# **按 5-gram 分桶,而不是两两比。** 判据是"最长公共子串 ≥ DUP_RUN_CHARS",那**等价于**
|
|
219
|
+
# "两者共享一个这么长的子串" —— 所以只把共享某个 5-gram 的页放进同一个桶里两两比,
|
|
220
|
+
# **结果与全量两两比完全一样**,只是不再做那 2.5 亿次注定失败的比较。
|
|
221
|
+
# 实测:5,062 张页全量要 1-2 分钟;22,445 张是 20 倍的对数(几十分钟),分桶后几秒。
|
|
222
|
+
index = {}
|
|
223
|
+
for page in pages:
|
|
224
|
+
name = page['title'] or page['stem']
|
|
225
|
+
if len(name) < DUP_RUN_CHARS:
|
|
226
|
+
# 比 5 字符还短的名字不可能满足"公共子串 ≥5"(子串不可能比它自己长),
|
|
227
|
+
# 原判据对它们也从不命中
|
|
228
|
+
continue
|
|
229
|
+
for gram in {name[i:i + DUP_RUN_CHARS] for i in range(len(name) - DUP_RUN_CHARS + 1)}:
|
|
230
|
+
index.setdefault(gram, []).append(page)
|
|
231
|
+
groups, seen = [], set()
|
|
232
|
+
for bucket in index.values():
|
|
233
|
+
for i, left in enumerate(bucket):
|
|
234
|
+
for right in bucket[i + 1:]:
|
|
235
|
+
pair = (id(left), id(right))
|
|
236
|
+
if pair in seen:
|
|
237
|
+
continue # 一对可能共享多个 5-gram,会落进多个桶
|
|
238
|
+
seen.add(pair)
|
|
239
|
+
a, b = left['title'] or left['stem'], right['title'] or right['stem']
|
|
240
|
+
run = longest_common_run(a, b)
|
|
241
|
+
if run >= DUP_RUN_CHARS and run >= DUP_COVER_RATIO * min(len(a), len(b)):
|
|
242
|
+
groups.append([a, b])
|
|
243
|
+
return groups
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def degenerate_fields(pages: list) -> dict:
|
|
247
|
+
"""**退化字段**:取值几乎只有一种。
|
|
248
|
+
|
|
249
|
+
实测:39 张概念页的 `topics` **全是「学术」**——上游那个主题分类器把最近这批语料
|
|
250
|
+
全扔进一个桶(另一处独立测量:标题像新闻的 224 篇里 40 篇也被标成「学术」)。
|
|
251
|
+
一个字段整列同一个值,看起来像"有元数据",实际什么也没说;而**它会被照着信**。
|
|
252
|
+
这条检查的作用不是修分类器(那是日报那条线的事),而是让这种退化**在页面上看得见**。
|
|
253
|
+
"""
|
|
254
|
+
if len(pages) < DEGENERATE_MIN_PAGES:
|
|
255
|
+
return {}
|
|
256
|
+
out = {}
|
|
257
|
+
# 只查**概念页上真有、而且"看起来像元数据"**的字段。别去查 `source`——概念页用的是复数
|
|
258
|
+
# `sources`,查一个不存在的字段只会报出一句"(全空)",那是拿噪音换注意力。
|
|
259
|
+
for field in ('topic',):
|
|
260
|
+
values = [str(page.get(field) or '').strip() for page in pages]
|
|
261
|
+
values = [value for value in values if value]
|
|
262
|
+
if not values:
|
|
263
|
+
continue
|
|
264
|
+
counts = {}
|
|
265
|
+
for value in values:
|
|
266
|
+
counts[value] = counts.get(value, 0) + 1
|
|
267
|
+
top, count = max(counts.items(), key=lambda item: item[1])
|
|
268
|
+
if count / len(pages) >= DEGENERATE_RATIO:
|
|
269
|
+
out[field] = {'值': top, '页数': count, '总页数': len(pages)}
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def resolvable_names(pages: list) -> set:
|
|
274
|
+
"""体检认为"指向它就算解析成功"的所有名字:页的 stem **加上别名**。
|
|
275
|
+
|
|
276
|
+
`--merge-duplicates` 把 `GPT-5.6` 并进 `GPT 5.6` 时,就是靠 aliases 让旧链接不断的
|
|
277
|
+
(实测库里 98 条),Obsidian 认它。体检要是不认,会把这些名字报成"还没建页、
|
|
278
|
+
建议再跑 compile 提高 --limit" —— 而它们已经有页了,那是**误导**。
|
|
279
|
+
"""
|
|
280
|
+
names = set()
|
|
281
|
+
for page in pages:
|
|
282
|
+
names.add(page['stem'])
|
|
283
|
+
names |= set(page.get('aliases') or [])
|
|
284
|
+
return names
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def collect(pages_dir: str, card_dirs) -> list:
|
|
288
|
+
pages = []
|
|
289
|
+
for path in sorted(Path(pages_dir).glob('*.md')):
|
|
290
|
+
if path.name == '00-Overview.md': # 索引页不是概念,它引用的东西另算
|
|
291
|
+
continue
|
|
292
|
+
frontmatter, body = parse_frontmatter(path.read_text(encoding='utf-8'))
|
|
293
|
+
raw_aliases = frontmatter.get('aliases') or []
|
|
294
|
+
if isinstance(raw_aliases, str):
|
|
295
|
+
raw_aliases = [x for x in raw_aliases.strip('[]').split(',')]
|
|
296
|
+
pages.append({'stem': path.stem,
|
|
297
|
+
'topic': compile_frontmatter_topic(frontmatter),
|
|
298
|
+
'title': str(frontmatter.get('title') or '').strip('"'),
|
|
299
|
+
# **别名也算"指向这一页的名字"**:`--merge-duplicates` 把
|
|
300
|
+
# `GPT-5.6` 并进 `GPT 5.6` 时就是靠 aliases 让旧链接不断的,
|
|
301
|
+
# Obsidian 认它。体检不认的话,会把这些名字报成"还没建页、
|
|
302
|
+
# 建议再跑 compile" —— 而它们已经有页了。
|
|
303
|
+
'aliases': [str(a).strip().strip('"').strip("'") for a in raw_aliases],
|
|
304
|
+
'links': extract_links(body),
|
|
305
|
+
'body': body})
|
|
306
|
+
return pages
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def main():
|
|
310
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
311
|
+
parser = argparse.ArgumentParser(description='知识库体检(本地,不联网、不调用模型)')
|
|
312
|
+
# **默认体检全部概念页目录**(文章一个、聊天一个,见 `_utils.CONCEPT_DIRS`)。
|
|
313
|
+
# 这条一旦只读一个,分出去的那一半就会"体检通过"——因为它根本没被看。
|
|
314
|
+
parser.add_argument('--dir', action='append', default=[],
|
|
315
|
+
help='概念页目录(可重复;默认 %s)'
|
|
316
|
+
% '、'.join(VAULT_CONCEPT_DIRS))
|
|
317
|
+
parser.add_argument('--json', action='store_true')
|
|
318
|
+
args = parser.parse_args()
|
|
319
|
+
|
|
320
|
+
dirs = args.dir or VAULT_CONCEPT_DIRS
|
|
321
|
+
existing = [d for d in dirs if Path(d).exists()]
|
|
322
|
+
if not existing:
|
|
323
|
+
message = ('没有概念页(%s 都不存在)——先跑 article-notes / chat-notes,'
|
|
324
|
+
'再跑 wiki compile' % '、'.join(dirs))
|
|
325
|
+
print(json.dumps({'success': False, 'error': message}, ensure_ascii=False)
|
|
326
|
+
if args.json else message)
|
|
327
|
+
return 1
|
|
328
|
+
|
|
329
|
+
pages = [page for d in existing for page in collect(d, CARD_DIRS)]
|
|
330
|
+
card_links = collect_cards(CARD_DIRS)
|
|
331
|
+
# **链接可能指向材料,而不是卡片。** `## 来源` 指的是「这一页是从哪来的」:
|
|
332
|
+
# 文章线指向 `002_Literature` 里的阅读笔记,聊天线指向 `Sources/Chat` 里的卡。
|
|
333
|
+
# 只查卡片目录会把这类全报成断链——实测 1,950 条,而它们在 Obsidian 里都是好的
|
|
334
|
+
# (两边文件名本来就不同:卡片保留完整标题,阅读笔记截到 50 字)。
|
|
335
|
+
card_stems = collect_card_stems(tuple(CARD_DIRS) + MATERIAL_DIRS)
|
|
336
|
+
report = inspect(pages,
|
|
337
|
+
lambda name: resolve(name, resolvable_names(pages), CARD_DIRS, card_stems),
|
|
338
|
+
card_links)
|
|
339
|
+
report['success'] = True
|
|
340
|
+
report['pagesDir'] = existing
|
|
341
|
+
report['cardLinks'] = len(card_links)
|
|
342
|
+
|
|
343
|
+
if args.json:
|
|
344
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
345
|
+
return 0
|
|
346
|
+
|
|
347
|
+
print('概念页 %d 张(卡片侧入链 %d 条)' % (report['pages'], report['cardLinks']))
|
|
348
|
+
if report['broken']:
|
|
349
|
+
print('\n**断链 %d 条**(指向不存在的卡片——卡片删了或改名了):' % len(report['broken']))
|
|
350
|
+
for item in report['broken'][:10]:
|
|
351
|
+
print(' %s → %s' % (item['page'], item['target']))
|
|
352
|
+
print(' 改名字就统一,删掉了就把那行去掉')
|
|
353
|
+
if report['orphans']:
|
|
354
|
+
print('\n孤儿页 %d 张(连卡片都没提到过它):' % len(report['orphans']))
|
|
355
|
+
for name in report['orphans'][:10]:
|
|
356
|
+
print(' %s' % name)
|
|
357
|
+
print(' 多半意味着它已经不被任何来源支持了')
|
|
358
|
+
if report['empty']:
|
|
359
|
+
print('\n空页 %d 张(没有定义或要点):' % len(report['empty']))
|
|
360
|
+
for item in report['empty'][:10]:
|
|
361
|
+
print(' %s(%d 字)' % (item['page'], item['chars']))
|
|
362
|
+
print(' 重新跑一次 wiki compile 通常就好了(那次模型返回不完整)')
|
|
363
|
+
if report['duplicateTitles']:
|
|
364
|
+
print('\n同名页 %d 组:' % len(report['duplicateTitles']))
|
|
365
|
+
for title, stems in list(report['duplicateTitles'].items())[:5]:
|
|
366
|
+
print(' %s ← %s' % (title, '、'.join(stems)))
|
|
367
|
+
if report['aspirational']:
|
|
368
|
+
names = sorted({item['target'] for item in report['aspirational']})
|
|
369
|
+
print('\n还没建页的相关概念 %d 个(**扩张候选,不是错误**):%s'
|
|
370
|
+
% (len(names), '、'.join(names[:12])))
|
|
371
|
+
print(' 想要它们就再跑 wiki compile(提高 --limit),或把它们当下一步的线索')
|
|
372
|
+
if report.get('nearDuplicates'):
|
|
373
|
+
count = len(report['nearDuplicates'])
|
|
374
|
+
# 这行必须带劝阻:判据在今天的规模上几乎对谁都成立(2026-09-28 实测 424,697 组,
|
|
375
|
+
# 前几条是 `AI Agent开发框架` / `生物信息学LLM Agent综述` 这种共享 "Agent" 的巧合)。
|
|
376
|
+
# 不写清楚,这个数会被当成"有 42 万对重复该合并"。
|
|
377
|
+
print('\n标题高度相似的页 %d 组(**判据偏松,规模一大就失去分辨力,当成线索别当结论**):'
|
|
378
|
+
% count)
|
|
379
|
+
for pair in report['nearDuplicates'][:4]:
|
|
380
|
+
print(' %s / %s' % (pair[0][:28], pair[1][:28]))
|
|
381
|
+
if count > 10000:
|
|
382
|
+
print(' 判据是"公共子串 ≥5 字且占较短标题 ≥40%",短名字一多就会互相命中;'
|
|
383
|
+
'真要按它去重,先把门槛收紧(见 wiki_lint.near_duplicate_titles 的说明)')
|
|
384
|
+
else:
|
|
385
|
+
print(' 只报告不去重:不同公众号的写法可能各有信息,删哪张该由人定')
|
|
386
|
+
if report.get('degenerateFields'):
|
|
387
|
+
print('\n退化字段(整列几乎同一个值,等于没携带信息):')
|
|
388
|
+
for field, info in report['degenerateFields'].items():
|
|
389
|
+
print(' %s: %s(%s/%s 页)' % (field, info['值'], info['页数'], info.get('总页数', report['pages'])))
|
|
390
|
+
print(' 多半是上游分类器把整批语料扔进了一个桶——不改分类器的话,别信这个字段')
|
|
391
|
+
if not any((report['broken'], report['orphans'], report['empty'], report['duplicateTitles'])):
|
|
392
|
+
print('\n没有需要修的问题(断链/孤儿/空页/同名)')
|
|
393
|
+
return 0
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
if __name__ == '__main__':
|
|
397
|
+
sys.exit(main())
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type=49 AppMsg 的**显示形态**:链接、文件、引用、转账…
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个文件:这段解析此前是 `sqlcipherCore` 的私有方法,**一行测试都没有**——
|
|
5
|
+
* 而它们其实都是纯函数(字符串进、字符串出),提到模块级就能直接测。
|
|
6
|
+
*
|
|
7
|
+
* 它是 Python 侧 `nt_decrypt.py` 的 `non_text_display()` 的**第二份实现**:微信 4.x 走
|
|
8
|
+
* Python 那份(`ntCore` 是个 Python 桥),3.x 走这份。两边的格式必须一致,否则同一条消息
|
|
9
|
+
* 在不同微信版本里读起来不一样——截断长度与分隔符尤其容易分叉,所以下面三个常量在两个
|
|
10
|
+
* 文件里是同一组值。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { clipWithMarker } from '../utils/text.js'
|
|
14
|
+
|
|
15
|
+
export interface AppMsgInfo {
|
|
16
|
+
title?: string
|
|
17
|
+
description?: string
|
|
18
|
+
url?: string
|
|
19
|
+
type?: string
|
|
20
|
+
/** 引用消息里被引用的原文(`refermsg/content`),与 Python 侧同名 */
|
|
21
|
+
quotedText?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 回复正文与被引原文之间的分隔。用词而不是符号:下游会把它读成一句话。 */
|
|
25
|
+
export const QUOTE_SEP = ' | 引:'
|
|
26
|
+
/** 被引原文的截断长度。实测 37 条真实引用消息:中位 36 字、最长的 12733 字。 */
|
|
27
|
+
export const QUOTE_CLIP = 120
|
|
28
|
+
/** 标题类文本的截断长度 */
|
|
29
|
+
export const TITLE_CLIP = 60
|
|
30
|
+
|
|
31
|
+
/** 占位值不是内容——`<content>null</content>` 按"没有原文"处理 */
|
|
32
|
+
const PLACEHOLDERS = ['null', 'undefined', '0']
|
|
33
|
+
|
|
34
|
+
export function decodeXmlEntities(str: string): string {
|
|
35
|
+
return str
|
|
36
|
+
.replace(/&/g, '&')
|
|
37
|
+
.replace(/</g, '<')
|
|
38
|
+
.replace(/>/g, '>')
|
|
39
|
+
.replace(/"/g, '"')
|
|
40
|
+
.replace(/'/g, "'")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 取一个标签的文本,取不到回 undefined(自闭合 `<title />` 也算取不到)。
|
|
45
|
+
*
|
|
46
|
+
* 起始标签里不许出现 `/`:否则 `<title />` 会被当成开标签,一路吃到后面某个 `</title>`,
|
|
47
|
+
* 把中间整段 XML 当成文本返回。这条纪律与 Python 侧的 `_xml_text` 相同。
|
|
48
|
+
*/
|
|
49
|
+
function tagText(xml: string, tag: string): string | undefined {
|
|
50
|
+
const pattern = new RegExp(`<${tag}(?:\\s[^>/]*)?>(?:<!\\[CDATA\\[)?([\\s\\S]*?)(?:\\]\\]>)?</${tag}>`)
|
|
51
|
+
const match = xml.match(pattern)
|
|
52
|
+
const text = match ? decodeXmlEntities(match[1].trim()) : ''
|
|
53
|
+
return text || undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 解析 Type=49 AppMsg XML。
|
|
58
|
+
*
|
|
59
|
+
* 引用消息(`<type>57</type>`)的形状是**实测**出来的,不是猜的:`<title>` 是回复正文,
|
|
60
|
+
* 被引用的原文在 `<refermsg><content>`。此前只取 title,于是模型看到的是一句
|
|
61
|
+
* "是呀,够得意个"却不知道在回什么——引用不带原文等于没引用。
|
|
62
|
+
*/
|
|
63
|
+
export function parseAppMsgXml(xml: string): AppMsgInfo {
|
|
64
|
+
try {
|
|
65
|
+
// 一律在 `<appmsg>` 段里找:`<refermsg>` 里的 `<type>` 是**被引消息**的类型,
|
|
66
|
+
// 全文档取第一个会把它当成这条消息的类型(实测真的会)。没有 `<appmsg>` 外层
|
|
67
|
+
// 包装的载荷退回整份文档——与原行为一致,不因为这次改动少读到东西。
|
|
68
|
+
const appmsg = xml.match(/<appmsg(?:\s[^>]*)?>([\s\S]*?)<\/appmsg>/)
|
|
69
|
+
const inner = appmsg ? appmsg[1] : xml
|
|
70
|
+
|
|
71
|
+
const referBlock = inner.match(/<refermsg>([\s\S]*?)<\/refermsg>/)
|
|
72
|
+
const rawQuoted = referBlock ? tagText(referBlock[1], 'content') : undefined
|
|
73
|
+
// 摘掉 `<refermsg>` 再取本条消息自己的字段:被引消息的 `<type>`/`<title>` 不是这条的。
|
|
74
|
+
const scope = referBlock ? inner.replace(referBlock[0], '') : inner
|
|
75
|
+
|
|
76
|
+
const rawType = tagText(scope, 'type')
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
title: tagText(scope, 'title'),
|
|
80
|
+
description: tagText(scope, 'des'),
|
|
81
|
+
url: tagText(scope, 'url'),
|
|
82
|
+
type: rawType && /^\d+$/.test(rawType) ? rawType : undefined,
|
|
83
|
+
quotedText: rawQuoted && !PLACEHOLDERS.includes(rawQuoted.toLowerCase()) ? rawQuoted : undefined,
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
return {}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** AppMsgInfo → 给人看的文本。取不到任何东西时回 `[链接/文件]`(原有兜底)。 */
|
|
91
|
+
export function formatAppMsg(info: AppMsgInfo): string {
|
|
92
|
+
const parts: string[] = []
|
|
93
|
+
const title = clipWithMarker(info.title ?? '', TITLE_CLIP)
|
|
94
|
+
|
|
95
|
+
switch (info.type) {
|
|
96
|
+
case '5': // 链接分享
|
|
97
|
+
if (title) parts.push(`[分享] ${title}`)
|
|
98
|
+
if (info.description) parts.push(info.description)
|
|
99
|
+
if (info.url) parts.push(info.url)
|
|
100
|
+
break
|
|
101
|
+
case '6': // 文件分享
|
|
102
|
+
if (title) parts.push(`[文件] ${title}`)
|
|
103
|
+
break
|
|
104
|
+
case '57': { // 引用回复:回复正文 + 被引原文
|
|
105
|
+
const quoted = clipWithMarker(info.quotedText ?? '', QUOTE_CLIP)
|
|
106
|
+
if (quoted) parts.push(title ? `[引用] ${title}${QUOTE_SEP}${quoted}` : `[引用] ${quoted}`)
|
|
107
|
+
else if (title) parts.push(`[引用] ${title}`)
|
|
108
|
+
break
|
|
109
|
+
}
|
|
110
|
+
default:
|
|
111
|
+
if (title) parts.push(`[AppMsg] ${title}`)
|
|
112
|
+
if (info.description) parts.push(info.description)
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return parts.join('\n') || '[链接/文件]'
|
|
117
|
+
}
|
package/src/core/ntCore.ts
CHANGED
|
@@ -30,8 +30,38 @@ export interface NtSessionsResult extends NtResult {
|
|
|
30
30
|
sessions?: ChatSession[]
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** One shard's outcome, as reported by `nt_decrypt.py --report-shards`. */
|
|
34
|
+
export interface ShardOutcome {
|
|
35
|
+
/** basename only - the child never reports a full path */
|
|
36
|
+
name: string
|
|
37
|
+
opened: boolean
|
|
38
|
+
/** null when the shard could not be read at all */
|
|
39
|
+
hasTalkerTable: boolean | null
|
|
40
|
+
rowsForTalker: number | null
|
|
41
|
+
/**
|
|
42
|
+
* null | KEY_REJECTED | OPEN_FAILED | READ_FAILED | SCHEMA_MISMATCH |
|
|
43
|
+
* WINDOW_UNAVAILABLE
|
|
44
|
+
*/
|
|
45
|
+
reason: string | null
|
|
46
|
+
/**
|
|
47
|
+
* Columns the reader needed and this shard did not have. Reported on a shard
|
|
48
|
+
* that was still read, so a non-empty list with a null `reason` means the
|
|
49
|
+
* read succeeded but lost fields - which a caller checking coverage has to
|
|
50
|
+
* be able to see.
|
|
51
|
+
*/
|
|
52
|
+
missingColumns?: string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ShardReport {
|
|
56
|
+
scanned: number
|
|
57
|
+
opened: number
|
|
58
|
+
failed: number
|
|
59
|
+
items: ShardOutcome[]
|
|
60
|
+
}
|
|
61
|
+
|
|
33
62
|
export interface NtMessagesResult extends NtResult {
|
|
34
63
|
messages?: Message[]
|
|
64
|
+
shards?: ShardReport
|
|
35
65
|
}
|
|
36
66
|
|
|
37
67
|
export interface NtContactsResult extends NtResult {
|
|
@@ -282,6 +312,43 @@ export class NtCore {
|
|
|
282
312
|
return { success: true, messages: result.messages || [] }
|
|
283
313
|
}
|
|
284
314
|
|
|
315
|
+
/**
|
|
316
|
+
* `getMessages` plus the per-shard read report.
|
|
317
|
+
*
|
|
318
|
+
* Separate method rather than a flag on `getMessages`: the extra argv token
|
|
319
|
+
* changes what the child returns, and every existing caller is better off
|
|
320
|
+
* with the smaller payload it already parses.
|
|
321
|
+
*
|
|
322
|
+
* `fromTime` is pushed into SQL so a resumed read does not fetch the whole
|
|
323
|
+
* conversation only to discard it. It is an optimisation and nothing more:
|
|
324
|
+
* callers must still filter by window themselves, because the child refuses
|
|
325
|
+
* the window outright (WINDOW_UNAVAILABLE) on a shard that cannot express
|
|
326
|
+
* it rather than approximating it. Only the lower bound is worth pushing -
|
|
327
|
+
* the upper bound is normally "now", above which nothing exists to skip.
|
|
328
|
+
*/
|
|
329
|
+
async getMessagesWithShards(
|
|
330
|
+
talker: string,
|
|
331
|
+
limit = 100,
|
|
332
|
+
offset = 0,
|
|
333
|
+
fromTime?: number | null
|
|
334
|
+
): Promise<NtMessagesResult> {
|
|
335
|
+
const args: string[] = [
|
|
336
|
+
'messages',
|
|
337
|
+
'--limit', String(limit),
|
|
338
|
+
'--offset', String(offset),
|
|
339
|
+
'--report-shards',
|
|
340
|
+
]
|
|
341
|
+
if (typeof fromTime === 'number' && Number.isFinite(fromTime)) {
|
|
342
|
+
args.push('--from', String(fromTime))
|
|
343
|
+
}
|
|
344
|
+
const ownWxid = configService.get('wxid')
|
|
345
|
+
const result = await this.callPython(args, { talker, ownWxid })
|
|
346
|
+
if (result.error) {
|
|
347
|
+
return { success: false, error: result.error }
|
|
348
|
+
}
|
|
349
|
+
return { success: true, messages: result.messages || [], shards: result.shards }
|
|
350
|
+
}
|
|
351
|
+
|
|
285
352
|
async getContacts(keyword?: string, limit = 200): Promise<NtContactsResult> {
|
|
286
353
|
const args: string[] = [
|
|
287
354
|
'contacts',
|