weflow-cli 1.12.0 → 1.13.1

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 (51) hide show
  1. package/CHANGELOG.md +228 -1
  2. package/OPERATIONS.md +255 -19
  3. package/README.md +10 -0
  4. package/bin/weflow-cli.ts +260 -51
  5. package/dist/bin/weflow-cli.js +255 -32
  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/configService.d.ts +3 -0
  19. package/dist/src/services/configService.d.ts.map +1 -1
  20. package/dist/src/services/configService.js +6 -3
  21. package/dist/src/services/configService.js.map +1 -1
  22. package/dist/src/services/diagnosticReport.d.ts +80 -0
  23. package/dist/src/services/diagnosticReport.d.ts.map +1 -0
  24. package/dist/src/services/diagnosticReport.js +191 -0
  25. package/dist/src/services/diagnosticReport.js.map +1 -0
  26. package/dist/src/services/wechatClient.d.ts +17 -3
  27. package/dist/src/services/wechatClient.d.ts.map +1 -1
  28. package/dist/src/services/wechatClient.js +68 -23
  29. package/dist/src/services/wechatClient.js.map +1 -1
  30. package/docs/DECISIONS.md +347 -1
  31. package/docs/NEXT_ACTIONS.md +7 -1
  32. package/docs/POSITIONING.md +68 -0
  33. package/docs/PROJECT_STATE.md +29 -8
  34. package/package.json +88 -86
  35. package/scripts/_utils.py +120 -4
  36. package/scripts/biz_daily.py +118 -11
  37. package/scripts/daily_task.ps1 +132 -0
  38. package/scripts/export_chat_html.py +12 -1
  39. package/scripts/install_tasks.cmd +32 -0
  40. package/scripts/install_tasks.ps1 +91 -0
  41. package/scripts/jev_client.py +335 -3
  42. package/scripts/nt_decrypt.py +79 -14
  43. package/scripts/pipeline.py +21 -0
  44. package/scripts/repro_customer_init.cmd +86 -0
  45. package/scripts/semantic_search.py +286 -24
  46. package/src/core/dbPathService.ts +5 -0
  47. package/src/core/ntCore.ts +34 -3
  48. package/src/core/sqlcipherCore.ts +47 -11
  49. package/src/services/configService.ts +12 -3
  50. package/src/services/diagnosticReport.ts +237 -0
  51. package/src/services/wechatClient.ts +428 -380
@@ -13,6 +13,7 @@
13
13
  python scripts/pipeline.py --date 2026-06-09 --skip-classify
14
14
  """
15
15
  import sys, os, subprocess, time, shutil
16
+ from datetime import datetime, timedelta
16
17
  from pathlib import Path
17
18
 
18
19
  SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -40,6 +41,10 @@ def main():
40
41
  parser.add_argument('--engine', default='deepseek',
41
42
  help='AI 引擎: deepseek(默认) / claude / ollama / local')
42
43
  parser.add_argument('--date', help='日期 YYYY-MM-DD')
44
+ parser.add_argument('--yesterday', action='store_true',
45
+ help='跑**昨天**(等价于 --date 昨天)。给计划任务用的:公众号发到 24:00 才停,'
46
+ '当晚跑的那一天永远不全;而"跑昨天"与时刻无关 —— 机器当时在睡觉、'
47
+ '起来补跑,结果完全相同(见 OPERATIONS「让它每天自己跑」)')
43
48
  parser.add_argument('--interest', default='AI', help='兴趣主题(默认 AI)')
44
49
  parser.add_argument('--wiki-limit', type=int, default=20, help='概念编译数量(默认 20)')
45
50
  parser.add_argument('--skip-classify', action='store_true', help='跳过后处理')
@@ -57,10 +62,20 @@ def main():
57
62
  '注意:下游步骤(行动建议/概念编译/AI 报告)仍会用 LLM,'
58
63
  '要全关请再加 --skip-classify --skip-ai-report')
59
64
  parser.add_argument('--ai-report-range', type=int, default=1, help='AI 报告覆盖最近 N 天(默认 1=仅当天)')
65
+ parser.add_argument('--prefetch-bodies', nargs='?', type=int, const=8, default=0, metavar='N',
66
+ help='只转发给 biz_daily:Phase 1 之前先并发把这一天的正文抓进缓存'
67
+ '(N=并发数,默认 8),让 Phase 1 走缓存命中、不再逐篇 sleep。'
68
+ '450 篇约 75 分钟 → 秒级;对上游的请求次数不变(见 biz_daily.prefetch_bodies)')
60
69
  parser.add_argument('--source', action='append', default=[], metavar='NAME',
61
70
  help='仅处理指定公众号,可重复或用逗号分隔')
62
71
  args = parser.parse_args()
63
72
 
73
+ if args.yesterday:
74
+ if args.date:
75
+ print('[ERROR] --date 与 --yesterday 不能同时给(前者是明确的一天,后者是"昨天")')
76
+ sys.exit(1)
77
+ args.date = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
78
+
64
79
  sys.path.insert(0, SCRIPTS_DIR)
65
80
  from _utils import load_config, get_api_key
66
81
  config = load_config()
@@ -109,6 +124,12 @@ def main():
109
124
  % '、'.join(n for n, skipped in (('classify_daily(行动建议)', args.skip_classify),
110
125
  ('compile_wiki(概念编译)', not args.with_wiki))
111
126
  if not skipped))
127
+ if args.prefetch_bodies:
128
+ # 原样转发:`--prefetch-bodies` 不带数字时 biz_daily 用自己的默认并发数,
129
+ # 所以这里不加数字(免得两处默认值各写一份、迟早漂移)。
130
+ step1_args += ['--prefetch-bodies']
131
+ if args.prefetch_bodies != 8: # 显式给了数字才转发数字
132
+ step1_args += [str(args.prefetch_bodies)]
112
133
  if not run_step('biz_daily — 抓取+摘要', step1_args):
113
134
  sys.exit(1)
114
135
 
@@ -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
@@ -344,6 +344,10 @@ def collect_articles(article_days=30):
344
344
  break
345
345
  # 有 `## 正文` 就用它(最精确的标记);没有就按真实结构取正文,
346
346
  # 不再退回到 YAML 头 —— 那正是"文章线只能靠标题排"的原因。
347
+ # 索引这一路**只用日报 md 那份**(`## 正文` 标记优先,否则按真实结构提取)。
348
+ # 2026-10-16 试过改成「与正文缓存取更长的那份」,实测**没有增益**(DECISIONS D-118):
349
+ # 100 条自造题里唯一中立的一组(标题)持平;两组碎片题各自跟着「题是从谁的文本里
350
+ # 抠的」走,方向相反 —— 自造题裁判不了这件事。正文缓存留给 `--with-text`,索引不吃它。
347
351
  body = ('\n'.join(lines[body_start:body_start + 50]) if body_start
348
352
  else extract_article_body(content))
349
353
  if title:
@@ -622,6 +626,13 @@ def build_rerank_questions(count):
622
626
  # 原来的写法里"重排失败"只往 stderr 打一行 WARN,而 CLI 不转发子进程 stderr,
623
627
  # 于是"没报错"会被读成"重排过了"。状态放这里,主流程把它塞进 JSON。
624
628
  RERANK_STATE = {'provider': None, 'degraded': False, 'error': None, 'scored': 0}
629
+ # 向量那条腿这次到底跑了没有、没跑是为什么、以及顺手算出的每线条数(省一次 13.5 MB 的解析)。
630
+ # 与 `RERANK_STATE` 同一个形状:`search()` 开头重置、`main()` 把它带进返回的 envelope。
631
+ #
632
+ # 为什么要这个:`search()` 原来有一个**裸 `except: pass`**,于是"向量路径任何异常"都会变成
633
+ # "静默只搜关键词",而返回里照样写着 `hybrid: true` —— 这是最难看出来的一种失效,
634
+ # 也让所有"快慢/好坏"的对比都可能落在不同路径上(2026-10-18 审阅时确认)。
635
+ VECTOR_STATE = {'used': False, 'fallback': None, 'counts': None}
625
636
 
626
637
  _RERANK_LLM_RUBRIC = (
627
638
  '给每条候选打 0-100 分,判据是**它是否直接回答了问题**("话题相近"不算):'
@@ -720,11 +731,16 @@ def rerank_dispatch(query, results, client=None, pool_size=RERANK_CANDIDATES):
720
731
 
721
732
  两个都失败时返回原序,并把 `RERANK_STATE` 留在"降级"上 —— 别让"没重排"看起来像"重排过了"。
722
733
  """
723
- RERANK_STATE.update(provider=None, degraded=False, error=None, scored=0)
734
+ RERANK_STATE.update(provider=None, degraded=False, error=None, scored=0, transport=None)
724
735
  if client is not None:
725
736
  out = rerank(query, results, client, pool_size=pool_size)
737
+ # **`provider` 记的是"哪套契约"**(`typesafe`),不是"谁在服务":`judgeProvider=chat` 时
738
+ # 契约是由 DeepSeek 类客户端满足的,只看 `provider` 会读成"原生通了"(2026-10-18 实测踩到:
739
+ # 同一批查询里有时 provider=typesafe 其实走的是 deepseek-flash)。所以再记一个 `transport`。
740
+ RERANK_STATE['transport'] = getattr(client, 'provider', 'typesafe')
726
741
  if not RERANK_STATE['degraded'] and RERANK_STATE['scored']:
727
742
  return out
743
+ RERANK_STATE['transport'] = 'llm'
728
744
  return llm_rerank(query, results, pool_size=pool_size)
729
745
  def rerank(query, results, client=None, pool_size=RERANK_CANDIDATES):
730
746
  """用**一次**决策请求给检索结果重排。
@@ -1037,6 +1053,39 @@ def _fetch_body(url):
1037
1053
 
1038
1054
  FULL_TEXT_LIMIT = 4000 # `--with-text` 默认给多少字(够一篇公众号全文)
1039
1055
 
1056
+ _FETCH_CACHE_DIR_NAME = ('.cache', 'fetch')
1057
+
1058
+
1059
+ def _fetch_cache_path(url):
1060
+ return Path(OUTPUT_ROOT) / '.cache' / 'fetch' / (hashlib.md5(url.encode('utf-8')).hexdigest() + '.md')
1061
+
1062
+
1063
+ def best_article_body(content, limit=None, md_body=None):
1064
+ """**展示路径**用:文章正文取**更长的那份** —— 日报 md,或它旁边那份正文缓存。
1065
+
1066
+ 日报 md 里正文中位只有 259 汉字,而日报**自己抓到的**完整正文缓存里是 2,063 汉字 ——
1067
+ 内容早就抓回来了,只写进 md 一小段。`--with-text` 要给人看的那份,所以取全的。
1068
+ ⚠️ **建索引不用这个函数**:实测吃完整正文对检索没有增益(DECISIONS D-118),
1069
+ 索引只吃 md 那份摘录(`collect_articles` 里 500 字)。
1070
+ 返回 `(body, url)`;url 从 frontmatter 取,取不到就是 None(那时只能用 md 那份)。
1071
+ `md_body` 由调用方给时就用它(例如调用方已按自己的规则切出正文 —— 调用方的切法优先)。
1072
+ """
1073
+ body = (md_body if md_body is not None
1074
+ else extract_article_body(content, limit or FULL_TEXT_LIMIT))
1075
+ url = None
1076
+ match = re.search(r'^url:\s*"?([^"\n]+)"?', content, re.M)
1077
+ if match:
1078
+ url = match.group(1).strip()
1079
+ cached = _fetch_cache_path(url)
1080
+ if cached.exists():
1081
+ try:
1082
+ full = cached.read_text(encoding='utf-8', errors='replace').strip()
1083
+ if len(full) > len(body):
1084
+ body = full
1085
+ except OSError:
1086
+ pass # 读不了缓存就当没有,别让这一篇失败
1087
+ return (body[:limit] if limit else body), url
1088
+
1040
1089
 
1041
1090
  def load_record_text(item, limit=FULL_TEXT_LIMIT):
1042
1091
  """把命中的记录还原成本地那份**正文** —— 索引里存的只是"用来匹配的摘录"。
@@ -1062,22 +1111,7 @@ def load_record_text(item, limit=FULL_TEXT_LIMIT):
1062
1111
  except OSError:
1063
1112
  return None, str(path), None
1064
1113
 
1065
- url = None
1066
- match = re.search(r'^url:\s*"?([^"\n]+)"?', content, re.M)
1067
- if match:
1068
- url = match.group(1).strip()
1069
-
1070
- body = extract_article_body(content, limit)
1071
- if url:
1072
- cached = Path(OUTPUT_ROOT) / '.cache' / 'fetch' / (
1073
- hashlib.md5(url.encode('utf-8')).hexdigest() + '.md')
1074
- if cached.exists():
1075
- try:
1076
- full = cached.read_text(encoding='utf-8', errors='replace').strip()
1077
- if len(full) > len(body): # 缓存那份才是完整正文
1078
- body = full[:limit]
1079
- except OSError:
1080
- pass
1114
+ body, url = best_article_body(content, limit)
1081
1115
  return readable_body(body), str(path), url
1082
1116
 
1083
1117
 
@@ -1108,6 +1142,165 @@ def attach_full_text(results, limit=FULL_TEXT_LIMIT, fetch_missing=0):
1108
1142
  return results, missing, fetched
1109
1143
 
1110
1144
 
1145
+ # ===== 精确短语定位(D-121)=====
1146
+ #
1147
+ # **为什么单独有一条腿**:现有两条腿都是"相似"——bigram+BM25 按词项计分、向量按语义近似。
1148
+ # 于是"哪个号在哪天**原样**写过这句话"这类问句(专有名词、代码名、政策原文、引文核对)
1149
+ # 它们答不了,也不该硬答:把"查不到原样"混进相似度排序里,用户看不出区别。
1150
+ #
1151
+ # 实现用 SQLite **FTS5 + trigram**,标准库自带的,不需要新依赖。四个实测结论决定了这个形状
1152
+ # (2026-10-17 本机 sqlite 3.45.1,8 次探针):
1153
+ # 1. 默认分词 `unicode61` 对中文**完全不可用**(整句一个 token:`沙箱逃逸` 命中 0)⇒ 必须 trigram;
1154
+ # 2. trigram 是**连续子串**匹配,不是词项 AND(`光伏补贴政策` 只命中原样连续的,
1155
+ # 而 `补贴政策` 命中"的补贴政策"/"补贴政策解读")⇒ 正好是"原样定位"要的语义,
1156
+ # 也正是它**不能**替我们那条关键词腿的原因(那条要的是"分开出现也能排上分");
1157
+ # 3. trigram 有 **3 字下限**:`光伏`(2 字)恒空 ⇒ 短查询必须**明说**,绝不能回报"0 命中";
1158
+ # 4. 查询里的标点会被当**语法**(`PM2.5` → `syntax error near "."`、`GPT-4` → `no such column: 4`)
1159
+ # ⇒ 整串加引号当字面量(内部 `"` 翻倍);加引号后 `PM2.5`/`C++`/`GPT-4` 全部正常,
1160
+ # 且 ASCII 大小写不敏感(`"pm2.5"` 能命中 `PM2.5`)。全角/半角不互认。
1161
+ #
1162
+ # **语料只从 `meta.json` 来**(语义索引的记录清单):再写一套记录枚举,两个索引的语料迟早分叉,
1163
+ # 而"两个索引覆盖的不是同一批"这种错不会报,只会让结果看起来少。
1164
+ PHRASE_INDEX_DIR = Path(OUTPUT_ROOT) / '.phrase_index'
1165
+ PHRASE_DB = PHRASE_INDEX_DIR / 'phrase.sqlite'
1166
+ PHRASE_MIN_CHARS = 3
1167
+ PHRASE_TEXT_LIMIT = FULL_TEXT_LIMIT
1168
+
1169
+
1170
+ def escape_phrase(query):
1171
+ """把整串当**字面量**(而不是 FTS 语法):外层加引号,内部 `"` 翻倍。"""
1172
+ return '"' + str(query or '').replace('"', '""') + '"'
1173
+
1174
+
1175
+ def phrase_chars(query):
1176
+ """"有效字符数":去掉所有空白再数。trigram 的下限按字符算,空格不参与。"""
1177
+ return len(''.join(str(query or '').split()))
1178
+
1179
+
1180
+ def _phrase_label(item):
1181
+ """「哪个号」:文章取文件名里第一个 `-` 前那段(`<账号>-<标题>.md`),聊天取会话名。
1182
+
1183
+ 与 `--from` 的口径保持一致:那条路也是靠 `id`(文件名)里含账号名来找号的
1184
+ (2026-10-15 修正文提取时 `source:` 随 YAML 头离开了嵌入文本,见 `search()` 里的注释)。
1185
+ """
1186
+ if str(item.get('type')) == 'chat':
1187
+ return str(item.get('talker') or '')
1188
+ rid = str(item.get('id') or '')
1189
+ stem = rid.split(':', 1)[-1].replace('\\', '/').rsplit('/', 1)[-1]
1190
+ if stem.endswith('.md'):
1191
+ stem = stem[:-3]
1192
+ return stem.split('-', 1)[0] if '-' in stem else stem
1193
+
1194
+
1195
+ def build_phrase_index(line: str = 'all', db_path=None, meta_path=None):
1196
+ """从 `meta.json` **全量重建**短语索引(不调用任何模型,所以重建便宜到可以每次都全量)。
1197
+
1198
+ 返回 `{'records', 'byLine', 'textMissing', 'db', 'bytes'}`;没有语义索引时返回 error ——
1199
+ 它是**派生**产物,不自己去找语料。
1200
+ """
1201
+ import sqlite3
1202
+ meta_file = Path(meta_path or META_FILE)
1203
+ if not meta_file.exists():
1204
+ return {'error': '还没有语义索引,短语索引由它派生',
1205
+ 'hint': 'weflow-cli search-index --yes'}
1206
+ meta = json.loads(meta_file.read_text(encoding='utf-8'))
1207
+ types = line_types(line)
1208
+ rows = []
1209
+ text_missing = 0
1210
+ by_line = {}
1211
+ for item in meta:
1212
+ if types is not None and str(item.get('type') or '') not in types:
1213
+ continue
1214
+ text, _source_file, _url = load_record_text(item, PHRASE_TEXT_LIMIT)
1215
+ if not text:
1216
+ # 读不到正文(md 被删/移动):退回索引里那段摘录,并**数出来**告诉用户,
1217
+ # 而不是让这篇悄悄从短语索引里消失。
1218
+ text = item.get('text') or ''
1219
+ text_missing += 1
1220
+ if not text:
1221
+ continue
1222
+ key = str(item.get('type') or '')
1223
+ by_line[key] = by_line.get(key, 0) + 1
1224
+ rows.append((text, str(item.get('id') or ''), key, _phrase_label(item),
1225
+ str(item.get('date') or item.get('time') or ''),
1226
+ str(item.get('title') or '')))
1227
+ path = Path(db_path or PHRASE_DB)
1228
+ path.parent.mkdir(parents=True, exist_ok=True)
1229
+ conn = sqlite3.connect(str(path))
1230
+ try:
1231
+ conn.execute('DROP TABLE IF EXISTS hits')
1232
+ conn.execute("CREATE VIRTUAL TABLE hits USING fts5("
1233
+ "body, rid UNINDEXED, line UNINDEXED, label UNINDEXED,"
1234
+ " date UNINDEXED, title UNINDEXED, tokenize='trigram')")
1235
+ conn.executemany('INSERT INTO hits(body, rid, line, label, date, title)'
1236
+ ' VALUES (?, ?, ?, ?, ?, ?)', rows)
1237
+ conn.commit()
1238
+ conn.execute('VACUUM')
1239
+ finally:
1240
+ conn.close()
1241
+ return {'records': len(rows), 'byLine': by_line, 'textMissing': text_missing,
1242
+ 'line': line, 'db': str(path), 'bytes': path.stat().st_size}
1243
+
1244
+
1245
+ def _phrase_where(line, from_source):
1246
+ """拼过滤条件。**与向量那条路同一口径**:`--from` 按 `id` 子串或标签精确匹配。"""
1247
+ clauses, params = [], []
1248
+ if line_types(line) is not None:
1249
+ clauses.append('line IN (%s)' % ','.join('?' * len(sorted(line_types(line)))))
1250
+ params.extend(sorted(line_types(line)))
1251
+ if from_source:
1252
+ want = from_source.strip().lower()
1253
+ clauses.append('(lower(rid) LIKE ? OR lower(label) = ?)')
1254
+ params.extend(['%' + want + '%', want])
1255
+ return (' AND ' + ' AND '.join(clauses) if clauses else ''), params
1256
+
1257
+
1258
+ def phrase_search(query, limit: int = 20, line: str = 'all', from_source: str = None,
1259
+ db_path=None, group_limit: int = 20):
1260
+ """原样定位:返回命中行 + 按(账号/会话 × 日期)的分组。
1261
+
1262
+ **两条不许静默**:查不到索引 → 报错并给建索引的命令;查询短于下限 → 明说"查不了"
1263
+ (而不是 0 命中)。把这两种说成"没有"是这条腿最坏的失效方式。
1264
+ """
1265
+ import sqlite3
1266
+ path = Path(db_path or PHRASE_DB)
1267
+ result = {'query': query, 'escaped': escape_phrase(query), 'line': line,
1268
+ 'from': from_source, 'total': 0, 'hits': [], 'groups': []}
1269
+ if not path.exists():
1270
+ result.update({'error': '短语索引还没建',
1271
+ 'hint': 'weflow-cli search-index --phrase(本地、不花钱)'})
1272
+ return result
1273
+ chars = phrase_chars(query)
1274
+ if chars < PHRASE_MIN_CHARS:
1275
+ result.update({'tooShort': True, 'chars': chars, 'minChars': PHRASE_MIN_CHARS,
1276
+ 'note': 'trigram 分词有 %d 字下限,更短的串它切不出任何词项 —— '
1277
+ '这里**不报"0 命中"**,因为那是把"查不了"说成"没有"'
1278
+ % PHRASE_MIN_CHARS})
1279
+ return result
1280
+ where, params = _phrase_where(line, from_source)
1281
+ conn = sqlite3.connect(str(path))
1282
+ try:
1283
+ total = conn.execute('SELECT count(*) FROM hits WHERE hits MATCH ?' + where,
1284
+ [result['escaped']] + params).fetchone()[0]
1285
+ result['total'] = total
1286
+ for label, day, n in conn.execute(
1287
+ 'SELECT label, substr(date, 1, 10) AS day, count(*) AS n FROM hits '
1288
+ 'WHERE hits MATCH ?' + where +
1289
+ ' GROUP BY label, day ORDER BY n DESC, day DESC LIMIT ?',
1290
+ [result['escaped']] + params + [group_limit]):
1291
+ result['groups'].append({'label': label, 'date': day, 'count': n})
1292
+ for rid, kind, label, date, title, ctx in conn.execute(
1293
+ 'SELECT rid, line, label, date, title, '
1294
+ "snippet(hits, 0, '【', '】', '…', 14) FROM hits "
1295
+ 'WHERE hits MATCH ?' + where + ' ORDER BY date DESC LIMIT ?',
1296
+ [result['escaped']] + params + [limit]):
1297
+ result['hits'].append({'id': rid, 'type': kind, 'label': label,
1298
+ 'date': date, 'title': title, 'context': ctx})
1299
+ finally:
1300
+ conn.close()
1301
+ return result
1302
+
1303
+
1111
1304
  def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = True,
1112
1305
  line: str = 'all', hybrid: bool = True, from_source: str = None,
1113
1306
  rerank_pool: int = RERANK_CANDIDATES,
@@ -1124,6 +1317,9 @@ def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = Tru
1124
1317
  "静默少搜一半"——后者在这个仓库里是最贵的那类 bug。
1125
1318
  """
1126
1319
  types = line_types(line) # 认不出的线在这里就报错,不静默当 all
1320
+ VECTOR_STATE.update(used=False, fallback=None, counts=None)
1321
+ # `--mmr` 在 fallback 里也要用 `vectors`;只在 try 里赋值会让它**未绑定**(NameError)。
1322
+ vectors = None
1127
1323
  # Try embedding-based search first
1128
1324
  if VECTORS_FILE.exists() and META_FILE.exists():
1129
1325
  try:
@@ -1138,6 +1334,8 @@ def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = Tru
1138
1334
  meta = meta[:rows]
1139
1335
  elif rows < int(vectors.shape[0]):
1140
1336
  vectors = vectors[:rows]
1337
+ # 顺手把每线条数算掉:`main()` 原来会再解析一遍同一份 meta(69 + 56 ms/次)
1338
+ VECTOR_STATE['counts'] = _counts_from_meta(meta)
1141
1339
  query_embeddings = get_embeddings([query], api_key)
1142
1340
  if query_embeddings and not all(v == 0 for v in query_embeddings[0]):
1143
1341
  query_vec = np.array(query_embeddings[0], dtype=np.float32)
@@ -1193,9 +1391,17 @@ def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = Tru
1193
1391
  results = mmr_select(results, vectors, top_k)
1194
1392
  for _item in results: # `_row` 只是内部用的,别漏进输出
1195
1393
  _item.pop('_row', None)
1394
+ VECTOR_STATE['used'] = True
1196
1395
  return results[:top_k]
1197
- except:
1198
- pass
1396
+ except Exception as error:
1397
+ # **显式降级**,原因留下来。原来是裸 `except: pass`:向量路径任何异常都会变成
1398
+ # "静默只搜关键词",而返回里照样写着 `hybrid: true` —— 那是最难看出来的一种失效,
1399
+ # 也让"快慢/好坏"的对比可能落在不同路径上。另外裸 except 会吞掉 KeyboardInterrupt。
1400
+ VECTOR_STATE['fallback'] = '%s: %s' % (type(error).__name__, error)
1401
+
1402
+ if not VECTOR_STATE['used'] and VECTOR_STATE['fallback'] is None:
1403
+ # 没异常也没返回 —— 最常见的原因是 embedding 拿到空/全零(上游失败)。
1404
+ VECTOR_STATE['fallback'] = '未做向量检索(embedding 为空或全零)'
1199
1405
 
1200
1406
  # Fallback: keyword search
1201
1407
  results = keyword_search(query, max(top_k, RERANK_CANDIDATES) if rerank_results else top_k,
@@ -1209,7 +1415,8 @@ def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = Tru
1209
1415
  if with_text:
1210
1416
  results, _missing, _fetched = attach_full_text(results, with_text,
1211
1417
  fetch_missing=fetch_missing)
1212
- if mmr:
1418
+ if mmr and vectors is not None:
1419
+ # 向量没拿到时**不能**做 MMR(它需要矩阵);以前这里会 NameError。
1213
1420
  results = mmr_select(results, vectors, top_k)
1214
1421
  for _item in results: # `_row` 只是内部用的,别漏进输出
1215
1422
  _item.pop('_row', None)
@@ -1230,6 +1437,17 @@ def line_counts() -> dict:
1230
1437
  meta = json.loads(META_FILE.read_text(encoding='utf-8'))
1231
1438
  except Exception:
1232
1439
  return counts
1440
+ return _counts_from_meta(meta)
1441
+
1442
+
1443
+ def _counts_from_meta(meta) -> dict:
1444
+ """从**已经解析好**的 meta 算每线条数(`line_counts` 与 `search()` 共用这一份)。
1445
+
1446
+ 抽出来是让 `search()` 复用自己刚解析的那份:它原来在返回之后又被 `main()` 调
1447
+ `line_counts()` **再解析一遍**同一个 13.5 MB 文件(实测 69 + 56 ms)。两份实现分开
1448
+ 写更糟 —— 两处解析同一份文件迟早会漂,而漂的表现是"条数对不上但谁都不报错"。
1449
+ """
1450
+ counts = {line: 0 for line in LINE_CHOICES}
1233
1451
  for item in meta:
1234
1452
  t = str(item.get('type', ''))
1235
1453
  for line, types in LINE_TYPES.items():
@@ -1295,6 +1513,13 @@ def main():
1295
1513
  help='不融合本地 BM25 名次(默认融合:加门控后实测三个台子都只增不减)')
1296
1514
  p.add_argument('--from', dest='from_source',
1297
1515
  help='只查某个公众号(按 id/文件名的子串匹配,不走语义)')
1516
+ p.add_argument('--phrase', action='store_true',
1517
+ help='把这串当**原样短语**在本地定位(FTS5/trigram;不走向量、不出境、不花钱):'
1518
+ '回答"哪个号在哪天原样写过它"。查不到原样不等于查不到语义,收窄了问题范围')
1519
+
1520
+ pp = subparsers.add_parser('phrase-build',
1521
+ help='构建/重建精确短语索引(本地,不调用任何模型)')
1522
+ add_line_arg(pp)
1298
1523
 
1299
1524
  add_line_arg(p)
1300
1525
 
@@ -1304,17 +1529,49 @@ def main():
1304
1529
  if getattr(args, 'days', 90) < 1 or getattr(args, 'article_days', 30) < 1:
1305
1530
  json_output({'error': '--days 与 --article-days 都要 ≥ 1(0 天等于索引一个空库)'})
1306
1531
  return
1307
- config = load_config()
1308
- api_key = args.api_key or os.environ.get('DASHSCOPE_API_KEY', '') or get_dashscope_key(config)
1532
+ # 读不到配置不该让**不需要配置**的子命令崩:`phrase-build` 与 `search --phrase` 只读本地索引,
1533
+ # 它们没有任何理由要求 `~/.weflow-cli/config.json` 存在(CI 上没有那个文件、本地有,于是这条
1534
+ # 会"本地绿、CI 红")。需要 key 的那条路会在下面自己报"缺 key"。
1535
+ try:
1536
+ config = load_config()
1537
+ except Exception:
1538
+ config = {}
1539
+ # `getattr`:不是每个子命令都有 `--api-key`(`phrase-build` 不需要 key —— 它不调模型)。
1540
+ # 无条件读会让"建本地短语索引"直接崩在一个**与它无关**的参数上。
1541
+ api_key = (getattr(args, 'api_key', '') or os.environ.get('DASHSCOPE_API_KEY', '')
1542
+ or get_dashscope_key(config))
1309
1543
 
1310
1544
  if args.command == 'build':
1311
1545
  result = build_index(api_key, full=args.full, days=args.days, article_days=args.article_days)
1312
1546
  elif args.command == 'update':
1313
1547
  result = build_index(api_key, full=False, days=args.days, article_days=args.article_days)
1548
+ elif args.command == 'phrase-build':
1549
+ result = build_phrase_index(line=args.line)
1550
+ result['success'] = 'error' not in result
1314
1551
  elif args.command == 'search':
1315
1552
  query = args.query or os.environ.get('WEFLOW_SEARCH_QUERY', '')
1316
1553
  if not query:
1317
1554
  result = {"error": "missing search query"}
1555
+ elif args.phrase:
1556
+ # 原样定位:**本地、无 key、不出境**。所以它不校验 `api_key`,
1557
+ # 也不该被当成"向量搜索的降级版"——两者问的是不同的问题。
1558
+ # 向量检索的那些旗标在这条路上不生效。CLI 会直接拒绝这种组合;这里兜住
1559
+ # **直接调脚本**的人:静默忽略会让人把默认行为当成"调过的结果"。
1560
+ ignored_flags = [name for name, on in (
1561
+ ('--mmr', getattr(args, 'mmr', False)),
1562
+ ('--no-rerank', getattr(args, 'no_rerank', False)),
1563
+ ('--no-hybrid', getattr(args, 'no_hybrid', False)),
1564
+ ('--no-dedup', getattr(args, 'no_dedup', False)),
1565
+ ('--with-text', getattr(args, 'with_text', 0)),
1566
+ ('--fetch-missing', getattr(args, 'fetch_missing', 0)),
1567
+ ('--rerank-pool', getattr(args, 'rerank_pool', RERANK_CANDIDATES) != RERANK_CANDIDATES),
1568
+ ) if on]
1569
+ if ignored_flags:
1570
+ print('[WARN] --phrase only locates verbatim text; ignored: %s'
1571
+ % ', '.join(ignored_flags), file=sys.stderr)
1572
+ result = phrase_search(query, limit=args.top_k, line=args.line,
1573
+ from_source=args.from_source)
1574
+ result['success'] = 'error' not in result
1318
1575
  else:
1319
1576
  # **输出带上是哪条线**(`--line` 默认文章线)。少了这一层,`--line chat`
1320
1577
  # 空成一片时读的人分不清"没命中"和"这条线根本没进索引"——而两者的下一步
@@ -1325,14 +1582,19 @@ def main():
1325
1582
  rerank_pool=args.rerank_pool,
1326
1583
  dedup=not args.no_dedup,
1327
1584
  with_text=args.with_text,
1328
- fetch_missing=args.fetch_missing,
1585
+ fetch_missing=args.fetch_missing,
1329
1586
  mmr=args.mmr)
1330
- counts = line_counts()
1587
+ # `search()` 本来就解析过 meta,顺手把每线条数算在 `VECTOR_STATE['counts']` 里;
1588
+ # 只有它没算(走了关键词 fallback)时才重新解析。省掉一次 13.5 MB 的解析。
1589
+ counts = VECTOR_STATE.get('counts') or line_counts()
1331
1590
  result = {
1332
1591
  'success': True,
1333
1592
  'line': args.line,
1334
1593
  'lineLabel': line_label(args.line),
1335
1594
  'hybrid': not args.no_hybrid,
1595
+ # **这次到底走没走向量**:没走时把原因写出来。缺了它,"只搜到关键词结果"
1596
+ # 与"关键词结果就是这样"在返回里长得一模一样。
1597
+ 'vector': {'used': VECTOR_STATE['used'], 'fallback': VECTOR_STATE['fallback']},
1336
1598
  'rerank': dict(RERANK_STATE),
1337
1599
  'from': args.from_source,
1338
1600
  'dedup': not args.no_dedup,
@@ -70,6 +70,11 @@ export class DbPathService {
70
70
  return [
71
71
  '$recycle.bin', 'node_modules', 'windows', 'program files',
72
72
  'program files (x86)', 'programdata', 'system volume information',
73
+ // 临时目录不是微信数据的合理位置,但**测试夹具**就建在这下面
74
+ // (`%LOCALAPPDATA%\Temp\weflow-custom-nt-*\xwechat_files`)。不排除的话,
75
+ // 空配置下的"自动发现"会把一个测试残留当成用户的微信目录 ——
76
+ // 2026-10-19 模拟新用户时实测命中,然后一路对着假数据初始化。
77
+ 'temp', 'tmp',
73
78
  ].includes(name.toLowerCase())
74
79
  }
75
80
 
@@ -21,6 +21,17 @@ function getPackageRoot(): string {
21
21
  return resolvePackageRoot(import.meta.url)
22
22
  }
23
23
 
24
+ /**
25
+ * `scan` 的执行器。默认就是真的 `execFile`;测试注入假执行器来钉住**错误映射**。
26
+ *
27
+ * 为什么需要这个口子:Windows 上"找不到命令"的表现是子进程以 **9009** 退出,
28
+ * 而它没有可移植的造假方式(要有真 exe / 真 shell 行为,Linux CI 上做不出来)。
29
+ * 少了这条测试,`9009` 那段用户提示就会在以后的改动里悄悄退回
30
+ * `NT scan failed (exit 9009)` —— 2026-10-18 那位用户卡住时看到的就是这句。
31
+ */
32
+ export type NtExecRunner =
33
+ (file: string, args: string[], options: Record<string, unknown>) => Promise<{ stdout: string }>
34
+
24
35
  export interface NtResult {
25
36
  success: boolean
26
37
  error?: string
@@ -191,11 +202,11 @@ export class NtCore {
191
202
  * Scan memory for NT keys and match to databases.
192
203
  * Requires WeChat to be running.
193
204
  */
194
- static async scan(root?: string): Promise<NtScanResult> {
205
+ static async scan(root?: string, run: NtExecRunner = execFileAsync as unknown as NtExecRunner): Promise<NtScanResult> {
195
206
  try {
196
207
  const scriptPath = join(getPackageRoot(), 'scripts', 'nt_decrypt.py')
197
208
  const scanArgs = ['scan', '--json']
198
- const { stdout } = await execFileAsync(getPythonCommand(), [scriptPath, ...scanArgs], {
209
+ const { stdout } = await run(getPythonCommand(), [scriptPath, ...scanArgs], {
199
210
  timeout: 300_000,
200
211
  maxBuffer: 10 * 1024 * 1024,
201
212
  env: createPythonProcessEnv(root ? { WEFLOW_SCAN_ROOT: root } : {}),
@@ -234,7 +245,27 @@ export class NtCore {
234
245
  return { success: false, error: 'Scan produced no valid output' }
235
246
  } catch (e: any) {
236
247
  if (e?.code === 'ENOENT') {
237
- return { success: false, error: 'Python not found. Install Python and add to PATH.' }
248
+ // 与 9009 那条**同一件事、同一句话**:一条是"命令名解析不到",一条是"解析到了但跑不起来",
249
+ // 对用户来说都是"这台机器没有可用的 Python"。以前这里留的是英文
250
+ // `Python not found. Install Python and add to PATH.`,于是同一个 init 里
251
+ // 上面刚用中文解释完"商店占位程序",下面又冒出一句英文(2026-10-19 实测)。
252
+ return {
253
+ success: false,
254
+ error: '没有可用的 Python:NT 扫描需要 Python 3.10+ 且能 `import sqlcipher3`。'
255
+ + '在终端里运行 `python --version`,能打出版本号才算装对',
256
+ }
257
+ }
258
+ if (e?.code === 9009) {
259
+ // Windows 的 9009 是"找不到命令"。最常见的原因是 PATH 上只有**微软商店的 python
260
+ // 占位程序**:`getPythonCommand()` 在所有候选都不过(没有真 Python / 没有 sqlcipher3)
261
+ // 时兜底返回裸名 `python`,正好交给那个占位程序,它一跑就以 9009 退出。
262
+ // 不把这条翻译出来,用户看到的是"NT scan failed (exit 9009)",而下一步该做什么完全没有线索
263
+ // (2026-10-18 一位用户就是这么卡住的)。
264
+ return {
265
+ success: false,
266
+ error: '没有可用的 Python:NT 扫描需要 Python 3.10+ 且能 `import sqlcipher3`。'
267
+ + 'exit 9009 在 Windows 上通常意味着只装了商店的 python 占位程序,装一个真正的 Python 后重试',
268
+ }
238
269
  }
239
270
  return { success: false, error: safeSubprocessError(e, 'NT scan failed') }
240
271
  }