weflow-cli 1.11.1 → 1.13.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +378 -0
  2. package/OPERATIONS.md +359 -11
  3. package/README.md +4 -0
  4. package/bin/weflow-cli.ts +289 -37
  5. package/dist/bin/weflow-cli.js +300 -35
  6. package/dist/bin/weflow-cli.js.map +1 -1
  7. package/dist/src/core/dbPathService.d.ts.map +1 -1
  8. package/dist/src/core/dbPathService.js +5 -0
  9. package/dist/src/core/dbPathService.js.map +1 -1
  10. package/dist/src/core/ntCore.d.ts +12 -1
  11. package/dist/src/core/ntCore.d.ts.map +1 -1
  12. package/dist/src/core/ntCore.js +23 -3
  13. package/dist/src/core/ntCore.js.map +1 -1
  14. package/dist/src/core/sqlcipherCore.d.ts +8 -0
  15. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  16. package/dist/src/core/sqlcipherCore.js +46 -11
  17. package/dist/src/core/sqlcipherCore.js.map +1 -1
  18. package/dist/src/services/assistantEval.d.ts.map +1 -1
  19. package/dist/src/services/assistantEval.js +3 -1
  20. package/dist/src/services/assistantEval.js.map +1 -1
  21. package/dist/src/services/assistantTools.d.ts.map +1 -1
  22. package/dist/src/services/assistantTools.js +25 -7
  23. package/dist/src/services/assistantTools.js.map +1 -1
  24. package/dist/src/services/configService.d.ts +3 -0
  25. package/dist/src/services/configService.d.ts.map +1 -1
  26. package/dist/src/services/configService.js +6 -3
  27. package/dist/src/services/configService.js.map +1 -1
  28. package/dist/src/services/diagnosticReport.d.ts +80 -0
  29. package/dist/src/services/diagnosticReport.d.ts.map +1 -0
  30. package/dist/src/services/diagnosticReport.js +191 -0
  31. package/dist/src/services/diagnosticReport.js.map +1 -0
  32. package/dist/src/services/exportService.d.ts +7 -0
  33. package/dist/src/services/exportService.d.ts.map +1 -1
  34. package/dist/src/services/exportService.js +1 -1
  35. package/dist/src/services/exportService.js.map +1 -1
  36. package/dist/src/services/wechatClient.d.ts +9 -3
  37. package/dist/src/services/wechatClient.d.ts.map +1 -1
  38. package/dist/src/services/wechatClient.js +55 -22
  39. package/dist/src/services/wechatClient.js.map +1 -1
  40. package/docs/DECISIONS.md +662 -1
  41. package/docs/MCP.md +1 -1
  42. package/docs/NEXT_ACTIONS.md +236 -0
  43. package/docs/POSITIONING.md +68 -0
  44. package/docs/PROJECT_STATE.md +119 -17
  45. package/docs/RELEASING.md +14 -0
  46. package/package.json +88 -86
  47. package/scripts/_utils.py +120 -4
  48. package/scripts/backfill_articles.py +64 -14
  49. package/scripts/biz_daily.py +118 -11
  50. package/scripts/daily_task.ps1 +132 -0
  51. package/scripts/export_chat_html.py +79 -31
  52. package/scripts/install_tasks.cmd +32 -0
  53. package/scripts/install_tasks.ps1 +91 -0
  54. package/scripts/jev_client.py +335 -3
  55. package/scripts/nt_decrypt.py +79 -14
  56. package/scripts/pipeline.py +21 -0
  57. package/scripts/recall_eval.py +247 -0
  58. package/scripts/repro_customer_init.cmd +86 -0
  59. package/scripts/semantic_search.py +926 -51
  60. package/src/core/dbPathService.ts +5 -0
  61. package/src/core/ntCore.ts +34 -3
  62. package/src/core/sqlcipherCore.ts +47 -11
  63. package/src/services/assistantEval.ts +3 -1
  64. package/src/services/assistantTools.ts +2471 -2452
  65. package/src/services/configService.ts +12 -3
  66. package/src/services/diagnosticReport.ts +237 -0
  67. package/src/services/exportService.ts +14 -2
  68. package/src/services/wechatClient.ts +418 -380
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env python3
2
+ """语义索引的**召回/排序评价**(可复跑、可对比的评测台)。
3
+
4
+ 为什么要有它:2026-10-15 之前,检索质量的结论都来自"手工试问 + 看前几条"——那种判据查不出
5
+ "改完是变好还是变坏",也复现不了。指标一律用业界标准口径,不自创:
6
+
7
+ · Recall@1/5/10 —— 该回来的回来没有(RAG 真正在乎的是 @5/@10)
8
+ · MRR@10 —— 第一条正确结果的倒数名次(单答案查询的主指标,MS MARCO 口径)
9
+ · nDCG@10 —— 排序质量(BEIR / MTEB 的主指标;这里是二值相关性、单条标准答案,
10
+ 所以 nDCG@10 = 1/log2(1+rank),命中在第 10 名之外记 0)
11
+ · Hit@1 —— 与 Recall@1 同义,单列是因为中文规范(《人工智能 检索增强生成系统测评
12
+ 规范》2024003631)里用的是"命中率"这个词
13
+ · 覆盖(coverage)—— **这一条是我们这类语料特有的**:答案在库里根本不存在时,任何排序都救不了,
14
+ 把它算进召回率是在诬赖排序。所以每题先判"库里有没有"。
15
+
16
+ 两套标准答案(都不依赖人工标注、也不把用户数据写进仓库):
17
+ · `frag` —— 从记录**自己正文中段**取 12 字干净片段当问句(唯一性已校验)。测"局部能不能召回整条"。
18
+ · `title` —— 文章用它的标题、聊天用它开头 30 字。这是用户最常干的事(记得那句话)。
19
+ · `question`(可选)—— 你手写的真问题 + 一个"答案里必然出现的字面量"当锚,走 `--queries-file`
20
+ (TSV:问题<TAB>锚<TAB>线)。**该文件请放在仓库外**:锚串取自你的语料。
21
+
22
+ `--dedup` 会先把**同文本**的记录合并再排名(多号转载同一篇时,top-k 里塞满同一份内容会虚高
23
+ 召回、掏空信息量)。它与不合并的两种名次都报,差多少一眼能看出来。
24
+
25
+ 用法(调 embedding 服务,需 --yes):
26
+ python scripts/recall_eval.py --records 30 --yes
27
+ python scripts/recall_eval.py --records 30 --dedup --json --yes
28
+ python scripts/recall_eval.py --queries-file D:/tmp/q.tsv --records 0 --yes
29
+ """
30
+
31
+ import argparse
32
+ import importlib.util
33
+ import json
34
+ import math
35
+ import os
36
+ import random
37
+ import re
38
+ import sys
39
+ from pathlib import Path
40
+
41
+ HERE = Path(__file__).resolve().parent
42
+ sys.path.insert(0, str(HERE))
43
+
44
+ spec = importlib.util.spec_from_file_location('semantic_search', HERE / 'semantic_search.py')
45
+ ss = importlib.util.module_from_spec(spec)
46
+ spec.loader.exec_module(ss)
47
+
48
+ CJK = lambda ch: '\u4e00' <= ch <= '\u9fff'
49
+ BANNED = set(' \t\n\r:/\\"\'|<>()()【】[],;○●·、。!?,')
50
+ JUNK = set('=_&%/.?#@~^`$*+{}[]')
51
+
52
+
53
+ def clean_window(text, mid, length=12):
54
+ """取一段干净的中文片段(≥60% 汉字、不含标点/URL 残片/长数字)——否则会拿 URL 碎片当问句。"""
55
+ for start in range(max(0, mid), max(0, min(len(text) - length, mid + 60))):
56
+ w = text[start:start + length]
57
+ if len(w) < length or set(w) & BANNED or set(w) & JUNK:
58
+ continue
59
+ if sum(1 for c in w if CJK(c)) / len(w) < 0.6 or re.search(r'\d{5,}', w):
60
+ continue
61
+ return w
62
+ return None
63
+
64
+
65
+ def rank_of(order, target_group):
66
+ """目标(或其同文本组)在结果里的名次;不在表里返回 None。"""
67
+ for i, idx in enumerate(order, 1):
68
+ if idx in target_group:
69
+ return i
70
+ return None
71
+
72
+
73
+ def metrics(rank, k=10):
74
+ hit = rank is not None and rank <= k
75
+ return {
76
+ 'hit@1': 1.0 if rank == 1 else 0.0,
77
+ 'recall@1': 1.0 if rank == 1 else 0.0,
78
+ 'recall@5': 1.0 if rank is not None and rank <= 5 else 0.0,
79
+ 'recall@10': 1.0 if hit else 0.0,
80
+ 'mrr@10': (1.0 / rank) if hit else 0.0,
81
+ 'ndcg@10': (1.0 / math.log2(1 + rank)) if hit else 0.0,
82
+ }
83
+
84
+
85
+ def build_cases(meta, texts, per_line, seed):
86
+ """自动造题:每线抽 per_line 条记录,各出 frag / title 两种问句。"""
87
+ groups = {}
88
+ for i, t in enumerate(texts):
89
+ groups.setdefault(hash(t), []).append(i)
90
+
91
+ rng = random.Random(seed)
92
+ cases = []
93
+ for kind, line, minlen in (('article', 'wiki', 220), ('chat', 'chat', 70)):
94
+ pool = [i for i, m in enumerate(meta) if m.get('type') == kind and len(texts[i]) >= minlen]
95
+ rng.shuffle(pool)
96
+ picked = 0
97
+ for i in pool:
98
+ if picked >= per_line:
99
+ break
100
+ t = texts[i]
101
+ body = t[t.find('\n') + 1:] if kind == 'article' and t.find('\n') >= 0 else t
102
+ frag = clean_window(body, len(body) // 3) or clean_window(body, len(body) // 2)
103
+ title = (meta[i].get('title') or '').strip() or clean_window(t, 0, 30)
104
+ if not frag or not title:
105
+ continue
106
+ group = set(groups[hash(t)])
107
+ cases.append({'form': 'frag', 'line': line, 'idx': i, 'group': group, 'query': frag})
108
+ cases.append({'form': 'title', 'line': line, 'idx': i, 'group': group, 'query': title})
109
+ picked += 1
110
+ return cases, groups
111
+
112
+
113
+ def load_questions(path, texts):
114
+ """TSV:问题<TAB>锚<TAB>线 → 标准答案 = 含锚的记录集合(覆盖为 0 的题单独列出,不计入召回)。"""
115
+ cases = []
116
+ for raw in Path(path).read_text(encoding='utf-8').splitlines():
117
+ raw = raw.strip()
118
+ if not raw or raw.startswith('#') or '\t' not in raw:
119
+ continue
120
+ parts = raw.split('\t')
121
+ q, anchor = parts[0].strip(), parts[1].strip()
122
+ line = parts[2].strip() if len(parts) > 2 else 'all'
123
+ truth = {i for i, t in enumerate(texts) if anchor.lower() in t.lower()}
124
+ cases.append({'form': 'question', 'line': line, 'idx': -1, 'group': truth or set(),
125
+ 'query': q, 'anchor': anchor, 'covered': bool(truth)})
126
+ return cases
127
+
128
+
129
+ def main():
130
+ ap = argparse.ArgumentParser(description='语义索引召回/排序评测台')
131
+ ap.add_argument('--records', type=int, default=30, help='每条线抽多少条记录造题(默认 30)')
132
+ ap.add_argument('--queries-file', help='手写问句 TSV(问题<TAB>锚<TAB>线);请放仓库外')
133
+ ap.add_argument('--top-k', type=int, default=10)
134
+ ap.add_argument('--seed', type=int, default=7)
135
+ ap.add_argument('--dedup', action='store_true', help='排名前把同文本记录合并')
136
+ ap.add_argument('--rerank', action='store_true',
137
+ help='调真正的重排(先试 Jev 契约,再退到已配置的对话模型)')
138
+ ap.add_argument('--mmr', action='store_true', help='排名后做 MMR 去冗余(λ=0.7)')
139
+ ap.add_argument('--rerank-pool', type=int, default=ss.RERANK_CANDIDATES,
140
+ help='重排看多少条候选(默认 %d)' % ss.RERANK_CANDIDATES)
141
+ ap.add_argument('--json', action='store_true')
142
+ ap.add_argument('--yes', action='store_true', help='确认调用云端 embedding(每条约十几 tokens)')
143
+ args = ap.parse_args()
144
+
145
+ if not args.yes:
146
+ raise SystemExit('这一步会调用 embedding 服务(花钱、且问句出境)。确认后加 --yes。')
147
+
148
+ meta = json.loads(ss.META_FILE.read_text(encoding='utf-8'))
149
+ vectors = None
150
+ import numpy as np
151
+ vectors = np.load(ss.VECTORS_FILE, mmap_mode='r')
152
+ texts = [m.get('text') or '' for m in meta]
153
+
154
+ cases, groups = build_cases(meta, texts, args.records, args.seed) if args.records else ([], {})
155
+ if args.queries_file:
156
+ cases += load_questions(args.queries_file, texts)
157
+ if not cases:
158
+ raise SystemExit('没有题(--records 0 且没给 --queries-file)')
159
+
160
+ key = ss.get_dashscope_key(ss.load_config())
161
+ embs = ss.get_embeddings([c['query'] for c in cases], key)
162
+ if any(all(v == 0 for v in e) for e in embs):
163
+ raise SystemExit('embedding 服务返回全零 —— 先查 key/网络,别拿这个结果下结论')
164
+
165
+ rows = []
166
+ for c, emb in zip(cases, embs):
167
+ v = np.array(emb, dtype=np.float32)
168
+ v = v / np.linalg.norm(v)
169
+ sims = np.asarray(vectors @ v)
170
+ types = ss.line_types(c['line'])
171
+ if types is not None:
172
+ sims = np.where(np.array([str(m.get('type', '')) in types for m in meta]), sims, -np.inf)
173
+ order = [int(i) for i in np.argsort(sims)[::-1]]
174
+ order = [i for i in order if np.isfinite(sims[i])]
175
+ if args.rerank:
176
+ # 与 `search()` 同一条路:先按原契约(Jev)排,失败再退到已配置的对话模型
177
+ pool = [{**meta[i], '_idx': i, 'score': float(sims[i])}
178
+ for i in order[:args.rerank_pool]]
179
+ ranked = ss.rerank_dispatch(c['query'], pool, ss.create_client(),
180
+ pool_size=args.rerank_pool)
181
+ order = [it['_idx'] for it in ranked] + list(order[args.rerank_pool:])
182
+ if args.mmr:
183
+ pool2 = [{**meta[i], '_row': i, 'rerankScore': r.get('rerankScore')}
184
+ for i, r in zip(order[:args.rerank_pool], ranked)]
185
+ order = [it['_row'] for it in ss.mmr_select(pool2, vectors, args.top_k)]
186
+ if args.dedup:
187
+ seen, keep = set(), []
188
+ for i in order:
189
+ h = hash(texts[i])
190
+ if h in seen:
191
+ continue
192
+ seen.add(h)
193
+ keep.append(i)
194
+ order = keep
195
+ rank = rank_of(order, c['group'])
196
+ # 多样性:最终 top-k 里两两余弦的均值 —— 越低说明结果越不重复。
197
+ # 加它是因为 MMR 这类去冗余与「单答案召回」是相冲的,只看召回会误判。
198
+ top = [i for i in order[:args.top_k] if i < len(texts)]
199
+ div = None
200
+ if len(top) >= 2:
201
+ mat = np.asarray(vectors[top], dtype=np.float32)
202
+ sims = mat @ mat.T
203
+ n = len(top)
204
+ div = float((sims.sum() - np.trace(sims)) / (n * (n - 1)))
205
+ rows.append({**c, 'group': None, 'rank': rank, 'div': div,
206
+ 'metrics': metrics(rank, args.top_k)})
207
+
208
+ def agg(sel):
209
+ covered = [r for r in sel if r['rank'] is not None or (r.get('covered') is not False)]
210
+ scored = [r for r in covered if r['rank'] is not None]
211
+ out = {'n': len(sel), 'n_covered': len(covered),
212
+ 'n_coverage_gap': sum(1 for r in sel if r.get('covered') is False)}
213
+ for k in ('hit@1', 'recall@1', 'recall@5', 'recall@10', 'mrr@10', 'ndcg@10'):
214
+ out[k] = (sum(r['metrics'][k] for r in covered) / len(covered)) if covered else 0.0
215
+ out['n_recalled'] = len(scored)
216
+ divs = [r['div'] for r in sel if r.get('div') is not None]
217
+ out['pairwiseDiversity'] = (sum(divs) / len(divs)) if divs else None
218
+ return out
219
+
220
+ report = {'config': {'records': args.records, 'seed': args.seed, 'dedup': args.dedup,
221
+ 'rerank': args.rerank, 'rerankPool': args.rerank_pool, 'mmr': args.mmr, 'rerankState': dict(ss.RERANK_STATE),
222
+ 'topK': args.top_k, 'indexCounts': ss.line_counts()},
223
+ 'overall': agg(rows), 'byGroup': {}, 'cases': [
224
+ {'form': r['form'], 'line': r['line'], 'query': r['query'], 'rank': r['rank']}
225
+ for r in rows]}
226
+ for form in sorted({r['form'] for r in rows}):
227
+ for line in sorted({r['line'] for r in rows if r['form'] == form}):
228
+ sel = [r for r in rows if r['form'] == form and r['line'] == line]
229
+ if sel:
230
+ report['byGroup']['%s/%s' % (form, line)] = agg(sel)
231
+
232
+ if args.json:
233
+ print(json.dumps(report, ensure_ascii=False, indent=2))
234
+ return
235
+ print('题数 %d(覆盖缺口 %d) dedup=%s' % (report['overall']['n'],
236
+ report['overall']['n_coverage_gap'], args.dedup))
237
+ print('%-16s %6s %8s %8s %8s %8s %8s %9s'
238
+ % ('组', 'n', 'hit@1', 'recall@5', 'recall@10', 'mrr@10', 'ndcg@10', '多样性'))
239
+ for name, m in [('overall', report['overall'])] + list(report['byGroup'].items()):
240
+ div = m.get('pairwiseDiversity')
241
+ print('%-16s %6d %8.2f %8.2f %8.2f %8.2f %8.2f %9s'
242
+ % (name, m['n'], m['hit@1'], m['recall@5'], m['recall@10'], m['mrr@10'],
243
+ m['ndcg@10'], ('%.3f' % div) if div is not None else '-'))
244
+
245
+
246
+ if __name__ == '__main__':
247
+ main()
@@ -0,0 +1,86 @@
1
+ @echo off
2
+ chcp 65001 >nul
3
+ setlocal
4
+ rem ============================================================================
5
+ rem Reproduce the customer's failing scenario on YOUR machine, safely.
6
+ rem
7
+ rem What he hit (2026-10-18):
8
+ rem init -> "NT scan failed (exit 9009)" ... then "初始化完成!"
9
+ rem sessions -> "未找到会话"
10
+ rem The tool could not read the database but still reported success.
11
+ rem
12
+ rem This window recreates it:
13
+ rem * throw-away HOME (D:\tmp\wf-test) - your real config is never touched
14
+ rem * PATH with node + system tools but NO python (the "Store placeholder" case)
15
+ rem * init / sessions / check --report, in that order
16
+ rem Read-only. No network, no API key, no cost.
17
+ rem
18
+ rem PREREQUISITE: open WeChat and log in first.
19
+ rem ============================================================================
20
+
21
+ cd /d "%~dp0.."
22
+ if not exist "dist\bin\weflow-cli.js" (
23
+ echo [ERROR] dist\bin\weflow-cli.js not found. Run "npm run build" in this repo first.
24
+ pause
25
+ exit /b 1
26
+ )
27
+
28
+ set "SCOPE=D:\tmp\wf-test"
29
+ if not exist "%SCOPE%" mkdir "%SCOPE%"
30
+ set "USERPROFILE=%SCOPE%"
31
+ set "HOME=%SCOPE%"
32
+
33
+ set "NODEDIR="
34
+ for /f "delims=" %%i in ('where node 2^>nul') do if not defined NODEDIR set "NODEDIR=%%~dpi"
35
+ if not defined NODEDIR (
36
+ echo [ERROR] node.exe not found on PATH.
37
+ pause
38
+ exit /b 1
39
+ )
40
+ set "PATH=%NODEDIR%;C:\Windows\System32;C:\Windows\System32\WindowsPowerShell\v1.0"
41
+
42
+ set "DATA=D:\微信\xwechat_files"
43
+
44
+ echo ==========================================================================
45
+ echo A) Environment: python MUST fail here - that is the whole point
46
+ echo ==========================================================================
47
+ python --version
48
+ echo.
49
+ echo ^(error / "not recognized" above = correct^)
50
+ echo.
51
+
52
+ echo ==========================================================================
53
+ echo B1) init with an explicit path - guarantees we reach the key step
54
+ echo ==========================================================================
55
+ if exist "%DATA%" (
56
+ node dist\bin\weflow-cli.js init --path "%DATA%"
57
+ ) else (
58
+ echo [WARN] %DATA% not found - running init without a path instead
59
+ node dist\bin\weflow-cli.js init
60
+ )
61
+ echo.
62
+ echo --------------------------------------------------------------------------
63
+ echo B2) init again WITHOUT a path - does auto-discovery still work?
64
+ echo --------------------------------------------------------------------------
65
+ node dist\bin\weflow-cli.js init
66
+ echo.
67
+
68
+ echo ==========================================================================
69
+ echo C) sessions - should point you at check --report
70
+ echo ==========================================================================
71
+ node dist\bin\weflow-cli.js sessions
72
+ echo.
73
+
74
+ echo ==========================================================================
75
+ echo D) check --report - should blame Python, NOT the WeChat version
76
+ echo ==========================================================================
77
+ node dist\bin\weflow-cli.js check --report
78
+ echo.
79
+
80
+ echo ==========================================================================
81
+ echo DONE.
82
+ echo - If every section matches what you expected, the bug is fixed.
83
+ echo - Otherwise send a screenshot of this window (or the lines that differ).
84
+ echo - Close this window when done: HOME/PATH were changed inside it only.
85
+ echo ==========================================================================
86
+ pause