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
package/CHANGELOG.md CHANGED
@@ -17,7 +17,234 @@ purpose** - a hand-written "latest is X" is exactly the claim that goes stale he
17
17
 
18
18
  All notable user-facing changes are recorded here. This project follows [Semantic Versioning](https://semver.org/).
19
19
 
20
- ## Unreleased
20
+ ## 1.13.1
21
+
22
+ - **Fix: a data directory containing non-ASCII characters was decoded wrong, so `check --report`
23
+ reported the wrong verdict.** Windows PowerShell writes to stdout in the console code page
24
+ (GBK on a Chinese system) while Node reads UTF-8, so `D:\微信\xwechat_files` arrived as
25
+ `D:\΢��\xwechat_files`. Nothing crashed - the comparison against the configured path simply
26
+ came out unequal, and the report told the user "you may be reading another account's database"
27
+ when they were reading exactly the right one. The PowerShell side now emits base64 (UTF-16LE)
28
+ and the Node side decodes it, so the code page cannot touch it. Verified by round-tripping a
29
+ Chinese path through the real `powershell.exe` call.
30
+
31
+ ## 1.13.0
32
+
33
+ - **`sessions` shows readable previews instead of raw message bytes.** The preview column used to
34
+ print whatever sat in `source` / `message_content`, so card and mini-program messages came out as
35
+ `(/ }25984…` fragments and text messages with a corrupt column showed binary. It now labels by
36
+ message type (`[图片]` / `[语音]` / `[视频]` / `[表情]` / `[链接/文件]` / `[系统消息]`, honouring
37
+ 4.x's packed `local_type` where the subtype lives in the high bits), picks whichever column decodes
38
+ as real text, and strips a leading run of ASCII symbols that group messages carry. Chinese
39
+ punctuation and emoji openings are left alone.
40
+ - **`check` says when the configured database path is a junction.** WeChat moved to another drive
41
+ leaves `C:\Users\<user>\xwechat_files` pointing at the real location; printing only the link made
42
+ it look like the config pointed somewhere wrong. It now adds `↳ 软链指向 <real path>`.
43
+ - **README: `init` needs `--path` when the data directory is not in a default location**, because
44
+ "close WeChat first" removes the only remaining way auto-discovery could find it.
45
+ - **Silent failures now say what broke, where.** Three places that used to lose data without a word:
46
+ a message shard that cannot be opened during `export` (it skipped the shard and still reported
47
+ success - you got a shorter transcript and no reason), a corrupt `.image_map.json` in the daily
48
+ report (images from earlier passes disappeared from the HTML), and an encrypted config value that
49
+ will not decrypt (`get_*_key` returned `''`, which reads as "not configured" even though it *is*
50
+ configured and merely broken). Each now prints one line naming the file and the exception type;
51
+ the config warnings are de-duplicated because those helpers run on every request. A guard test
52
+ (`test/silent_failure_guard_test.py`) pins the count of bare `except:` + `pass` bodies at 53 so
53
+ the number cannot creep back up.
54
+ - **`docs/POSITIONING.md`**: why this project exists next to WeChat itself. Written after 4.1.15
55
+ added "ask AI about a file / an article" - i.e. WeChat now does the simplest half of what a
56
+ "WeChat + AI" tool does. It lists the five moats (data that can leave the client, cross-time
57
+ queries, cross-corpus joins, local inference, traceable answers), the features that will be eaten
58
+ (single-document summaries, in-chat keyword search), and four questions that decide whether a
59
+ feature belongs here.
60
+ - **Two scheduled passes instead of one: a noon preview and a complete end-of-day run.**
61
+ `scripts/daily_task.ps1 noon` produces *today so far* (13:00); the bare invocation still
62
+ produces *yesterday* (now 00:05 + at logon). Both write the same date directory, and the
63
+ second pass is incremental - `.run_state.json` makes `biz_daily` skip what the noon pass
64
+ already handled, while README/`index.html` are rebuilt from every markdown file on disk.
65
+ `scripts/install_tasks.ps1` registers both (it needs an elevated terminal: Task Scheduler
66
+ writes are refused in a sandboxed shell while reads still work).
67
+ - **`.articles.json` is merged, not overwritten.** With two passes a day the second one only
68
+ carries the articles that arrived after noon, so overwriting dropped the morning batch's
69
+ titles/summaries/topics from the structured file while the page still listed the whole day -
70
+ downstream reports and statistics silently saw half a day. Entries are now merged by URL
71
+ (title+time when a push has no URL), `decisionModel` survives a `--no-ai` rerun, and a
72
+ corrupt old file starts from empty instead of failing the day.
73
+ - **The scheduled guard no longer trusts `index.html` alone.** The noon pass writes one too,
74
+ so "index.html exists" made the 00:05 run skip the afternoon and leave a complete-looking
75
+ half day behind. It now reads `.articles.json`'s `generated_at`: finished means generated
76
+ after the target day (the usual 00:0x pass) or after 23:00 that night.
77
+ - **`weflow-cli check --report`: a paste-ready, redacted diagnosis.** It prints the environment
78
+ (Node / `python --version` / `sqlcipher3` & friends), the WeChat client actually loaded and whether
79
+ its data directory matches the configured one, whether a key/passphrase is configured, and then a
80
+ **verdict** — "Python unusable", "missing sqlcipher3", "not initialised", "client not running",
81
+ "reading a different database" — each with the next command to run. Usernames, `wxid`s and
82
+ key-shaped strings become `<用户>` / `wxid_<已隐藏>` / `<密钥>`; message bodies and key values never
83
+ enter it. `sessions` printing "未找到会话" and a failed `init` now point at it, because that is the
84
+ point where "no sessions" and "cannot read them" used to look identical (the same root cause was
85
+ reported as four different problems between 2026-08 and 2026-10).
86
+ - **`wechatClient.running` and its explanation no longer contradict each other.** It used to be
87
+ `any WeChat process`, while the note was keyed on `Weixin.exe`, so "only WeChatAppEx is running"
88
+ produced `运行中: 是` next to `客户端没在运行`. Both now use the launcher process; the note names
89
+ the helper processes when that is what it saw.
90
+ - **A same-day rerun of the scheduled daily no longer erases the log of the run that failed.**
91
+ `scripts/daily_task.ps1` redirects through `Start-Process`, which **overwrites** its targets, so
92
+ retrying a failed day replaced the only evidence of what broke. Existing `daily-<date>.log` /
93
+ `.err.log` are now rotated to `daily-<date>.run<N>[.err].log` (first free index) before the new
94
+ run starts. Verified by planting a marker in both files and running the wrapper: the markers
95
+ landed in `.run1` and the fresh log did not contain them.
96
+ - **`search --phrase` now refuses the vector-leg flags instead of accepting and ignoring them.**
97
+ `--no-rerank`, `--rerank-pool`, `--no-hybrid`, `--no-dedup`, `--mmr`, `--with-text` and
98
+ `--fetch-missing` describe the vector/rerank path; on the verbatim (FTS5) leg they were forwarded
99
+ to the script and **silently dropped**, so a reader who passed one got the default behaviour while
100
+ believing they had changed it. The CLI now exits with `INVALID_ARGUMENT` naming the offending
101
+ flags (JSON-aware), and calling `semantic_search.py` directly prints
102
+ `[WARN] --phrase only locates verbatim text; ignored: ...`. `--top-k`, `--line` and `--from` still
103
+ apply. A test spawns the real CLI to prove the rejection happens before any script call.
104
+ - **Client-version detection keeps working on newer WeChat: the command line is no longer the only
105
+ observation.** Since **4.1.15.53** the `Weixin.exe` main process is launched with **no switches at
106
+ all** (`"D:\WeiXin\Weixin.exe"`), so the `--user-lib-dir` judgement that `check` relied on returned
107
+ "version unknown" on every machine that updated - the one field worth looking at first when reads
108
+ fail. It now falls back to the directory of the **`Weixin.dll` the process actually has mapped**
109
+ (same quantity, second observation), and the `note` says which source was used. Measured after the
110
+ update: `check --json` reports `4.1.15.53` instead of `null`. The no-guessing rules are unchanged -
111
+ multiple directories still yield no single version, an unreadable module table is still `null`
112
+ rather than an inference from what is on disk, and the hardcoded version literal is still not read
113
+ (D-125 amends D-107).
114
+ - **`init` no longer says "初始化完成" when it has no usable key, and a Windows `9009` now says what to
115
+ do.** A 2026-10-18 report ended at `NT 数据库扫描: NT scan failed (exit 9009)`, "✓ 已沿用配置中的密钥",
116
+ "初始化完成! (4.x 模式)" and "密钥: 已安全保存" - with `sessions` empty on every run. Two defects
117
+ behind it: `getPythonCommand()` falls back to the **bare name `python`** when every candidate fails its
118
+ probe (runnable *and* `import sqlcipher3`), and on Windows that bare name is often the Microsoft Store
119
+ placeholder, which exits `9009`; and the closing summary was unconditional, so a scan that never ran
120
+ still looked like a finished setup. The exit code is now translated into the actual requirement
121
+ (Python 3.10+ with `sqlcipher3`), and the summary is gated on whether **this run** obtained a key
122
+ (memory-scan match *or* passphrase derivation) - otherwise it prints a failed-init heading with the
123
+ three steps that fix it, instead of "密钥已保存" next to a key that was never checked against this
124
+ database.
125
+ - **`judgeProvider=chat` now honours `aiEngine`; a local-engine user is no longer shipped abroad
126
+ silently.** The chat branch (D-120) read `deepseekApiKey` unconditionally, so with `aiEngine=local`
127
+ turning on the judge sent article text to DeepSeek while nothing in the UI changed - not a cost
128
+ question, but a user who chose to stay local being sent out anyway. `local`/`ollama` now goes through
129
+ `detect_local_engine()` (`provider='chat-local'`, same contract checks), `claude` is **refused
130
+ explicitly** rather than swapped for another vendor, and an undetected local service returns `None`
131
+ with a WARN instead of falling back to the cloud (D-124). The local path is covered by unit tests
132
+ with a stub engine; it has **not** been run against a live Ollama/LM Studio.
133
+ - **A search that did not use the vector leg now says so, and `--mmr` can no longer crash on that
134
+ path.** `search()` used to wrap its whole vector path in a bare `except: pass`, so *any* failure
135
+ (an embedding upstream error, numpy missing, a broken `meta.json`) silently became a keyword-only
136
+ search while the envelope still reported `hybrid: true` - two very different quality levels that
137
+ looked identical in the output, and the reason every "faster/slower, better/worse" comparison could
138
+ land on a different code path without anyone noticing. The response now carries
139
+ `vector: {used, fallback}`, where `fallback` names the actual exception (or "embedding empty/all
140
+ zero"). Two further defects came out of the same read: `vectors` was assigned **only inside** the
141
+ `try`, so a failure before `np.load` (e.g. `require_numpy()` raising) made the fallback's
142
+ `mmr_select(..., vectors, ...)` throw `UnboundLocalError` under `--mmr` - now initialized to `None`
143
+ and guarded; and `main()` parsed the 13.5 MB `meta.json` a **second** time for `line_counts()`
144
+ (~125 ms/query, 1.2% of a search) - the counts are now computed from the meta `search()` already
145
+ has. The bare `except` also swallowed `KeyboardInterrupt`, which made cancelling a long run
146
+ unreliable on that path. The rerank state gained a **`transport`** field alongside `provider`,
147
+ because `provider: typesafe` names the *contract* that ran, not who served it - with
148
+ `judgeProvider=chat` the Jev contract is satisfied by DeepSeek, so the old field read as "the native
149
+ API worked" (it did not; the other half of the same batch fell back to the LLM path). Eight tests
150
+ pin the fallback behaviour, and the misleading-first-test episode is recorded in D-122: a
151
+ regression test that failed *after* `vectors` was assigned stayed green when the initialization was
152
+ removed, and a mutation check is what exposed it.
153
+
154
+
155
+ - **A third retrieval leg answers a question the other two cannot: "which account wrote this exact
156
+ phrase, and when?"** `search --phrase` locates a string **verbatim** using SQLite's bundled
157
+ **FTS5 with the `trigram` tokenizer** (no new dependency), returning hits with the matched run
158
+ highlighted plus `groups` counting them by account × date. It is a *different question*, not a
159
+ better ranking: BM25 scores terms and vectors score meaning, so neither can answer a code name
160
+ (`std::vector`), a proper noun, a policy phrase or a quotation check - and folding "the exact
161
+ string is absent" into a similarity ranking is a failure the reader cannot see. Four measured facts
162
+ set the shape: the default `unicode61` tokenizer is **useless for Chinese** (whole sentences become
163
+ one token - `沙箱逃逸` matched **0**); `trigram` matches **contiguous substrings, not a term AND**
164
+ (`光伏补贴政策` only hits the verbatim occurrence, while `补贴政策` also hits "的补贴政策" - which is
165
+ exactly what this leg is for, and exactly why it cannot replace the keyword leg, since
166
+ `光伏补贴政策` returns **0 of 10,759** articles); it has a **3-character floor**, so a short query
167
+ returns an explicit `tooShort` instead of "0 hits" (saying "cannot search" to mean "absent" is this
168
+ leg's worst failure); and query punctuation is parsed as **syntax** (`PM2.5` was a syntax error,
169
+ `GPT-4` a "no such column"), so the whole string is quoted as a literal (inner quotes doubled) -
170
+ after which `PM2.5` / `C++` / `GPT-4` all work. On this corpus: `沙箱逃逸` 2 hits across two
171
+ accounts, `PM2.5` **58** (including the chat line), `腾讯混元` 7, `分布式光伏` 1; `std::vector` 0.
172
+ **One table for the corpus, not one per article** - a table per article would mean ~96,000 tables
173
+ with FTS5's shadow tables, `bm25()`'s corpus-level statistics degenerating to one row each, and the
174
+ inverted index's only advantage thrown away. The index is **derived** from the semantic index's
175
+ `meta.json` (a second record enumeration would drift, silently). Build with
176
+ `search-index --phrase`: **local, free, no key, no confirmation gate** (that gate exists for
177
+ egress) - the CLI's preview says `mayUseCloudEmbedding: false` so this is not mistaken for a
178
+ vector call.
179
+
180
+
181
+ - **A Jev-like decision model that works from this region is one config line away.** `judgeProvider` now
182
+ selects among three transports for the *whole* decision layer (the daily's topic/relevance/inclusion
183
+ judgement, search reranking, reply debt, drafted replies and route-card term selection all share one
184
+ client): `native` (unchanged default), **`chat`** - the conversational model already configured for
185
+ summaries (`aiBaseUrl` relay / `aiModel` / `deepseekApiKey`), so it needs **no new vendor and no new
186
+ key** - and `openrouter` (a new encrypted `openrouterApiKey` plus `openrouterJudgeModel`, default
187
+ `typesafe/jev-router`). The native endpoint is region-blocked here (**HTTP 451**), and the failure shape
188
+ is silence rather than an error - 0 of 12,542 articles over the last 40 complete days carry a
189
+ `relevanceScore` - so the point of the switch is to route around a block, not to add a vendor.
190
+ Measured with the same prompt and the same validators against one real article: **`deepseek-chat`
191
+ passes in 1.0 s** (1,649 prompt + 69 completion tokens, cost on the order of ¥1/day for 500 articles),
192
+ `deepseek-reasoner` in 7.2 s, `qwen-plus` in 2.3 s.
193
+ **A passing contract is not the same judgement**: `deepseek-chat` scored that article relevance 1 with
194
+ an inclusion probability of 0.15 while `qwen-plus` scored it 2 and 0.85 - and the report's thresholds
195
+ were set against Jev, so switching judges means switching rulers. That is why the raw scores and
196
+ `usage.model` are stored; it also immediately paid off, since the served model for a `deepseek-chat`
197
+ request came back as **`deepseek-flash`**.
198
+
199
+
200
+ - **The decision model has a second transport, the region block is now a measured fact rather than a
201
+ guess, and the switch that uses it ships off.** `api.typesafe.ai` answers **HTTP 451 "not available in
202
+ your region"** from this machine, and the failure shape is silence rather than an error: of 12,542
203
+ articles across the last 40 complete days, **0** carry a `relevanceScore` - and the same client serves the
204
+ reranker, reply debt, drafted replies and route-card term selection, so the whole layer degrades together.
205
+ New config: `judgeProvider` (`native` default / `openrouter`), `openrouterApiKey` (encrypted at rest) and
206
+ `openrouterJudgeModel` (default `typesafe/jev-router`). One switch replaces the layer, which is exactly why
207
+ **the default stays `native`**.
208
+ **The region filter is per model, not per site** (measured): `typesafe/jev-router` and `:free` models pass
209
+ from here, while `openai/gpt-4o-mini` returns **403 "This model is not available in your region."** - so a
210
+ model swap has to be re-tested on the machine that will use it.
211
+ The contract moves to our side (the native endpoint returns structured answers; a chat model has to be
212
+ *asked* for them), so the prompt and four validators live in `jev_client.py` and drift raises `JevError`
213
+ instead of producing a plausible-looking default - including the two shapes that used to fail *quietly*:
214
+ `noul` arriving as a boolean (once read as "no" for every question) and `score` arriving as a string
215
+ (which raised `ValueError`, something the callers' `except JevError` could not catch). Cost is measured:
216
+ **$0.014** for one article with four topic choices on a call the router sent to `x-ai/grok-4.7` (1,535
217
+ reasoning tokens), and since the router chooses a different upstream per call, the worst case is what has
218
+ to be budgeted (**~$7 for a 500-article day**); `max_tokens` does **not** cap it. Four failures now say
219
+ four different things because they need four different fixes: **402** = credits, **403 + "region"** =
220
+ change the model (changing the key does nothing), **401** = auth, **empty content** = reasoning ate the
221
+ budget. **Off by default, and not usable on the key tested here**: `/credits` reports `total_credits: 0`
222
+ and calls are refused with 402.
223
+
224
+ - **The daily report can now be scheduled to run itself, and it knows what "yesterday" means.**
225
+ `daily` (and the `pipeline.py` it drives) gained `--yesterday`. A scheduled run has to be *the
226
+ previous day*, not "today": WeChat accounts keep publishing until midnight, so a same-evening run
227
+ silently ships a truncated day that nobody ever goes back to fix - while "yesterday" is
228
+ **time-insensitive** (any wake-up after midnight reads the same complete data, so a machine that
229
+ was asleep only needs to be allowed to start late, not have its date re-derived). Passing both
230
+ `--date` and `--yesterday` is an error rather than a silent pick, because then "which day" has no
231
+ single answer. `OPERATIONS.md` now carries a corrected recipe for the scheduled task - it uses
232
+ `Register-ScheduledTask` with `-StartWhenAvailable`, because `schtasks /create` cannot express
233
+ "start late if you missed it", and without that a sleeping machine means a **silently** missing
234
+ day.
235
+
236
+ - **`daily --prefetch-bodies [n]` (default off) fetches the day's article bodies concurrently
237
+ *before* the fetch phase.** That phase is serial and sleeps 8-12 s for every article that misses
238
+ the body cache - deliberate throttling, and on the order of an hour for a 450-article day - while
239
+ a **cache hit neither connects nor sleeps**. Warming the cache first turns the fetch phase into
240
+ cache hits. The **number of requests to upstream is unchanged**; only the window they are spread
241
+ over changes, which is exactly why this is opt-in rather than the new default. Measured on the
242
+ first real run (2026-10-16, a 523-article day): **516 bodies warmed in 10 minutes** and the whole
243
+ `biz_daily` step took **15m49s**, where the same articles serially with throttling take over an
244
+ hour. The scheduled task uses it, so a report that used to hold the machine for an hour finishes
245
+ in a fraction of that. (Related: the index does **not** read this cache - measured no gain, see
246
+ `docs/DECISIONS.md` D-118 - so the cache serves the display path (`search --with-text`) and this
247
+ speed-up only.)
21
248
 
22
249
  ## 1.12.0
23
250
 
package/OPERATIONS.md CHANGED
@@ -66,6 +66,28 @@ weflow-cli config forget-keys --yes
66
66
  weflow-cli init --refresh
67
67
  ```
68
68
 
69
+ ### `init` 结尾怎么读(以及 `NT scan failed (exit 9009)`)
70
+
71
+ `init` 结尾只有两种,**别把第二种当成成功**:
72
+
73
+ - `初始化完成! (4.x 模式)` —— 这次真的拿到了可用的密钥(内存扫描匹配上,或从 passphrase 派生成功)。
74
+ - `初始化未完成:数据库密钥没有就绪` —— 扫描没匹配上、派生也没成,`sessions`/`messages` 会读不到数据。
75
+ 这时配置里的旧密钥**没有被验证过**,所以不能当作可用。
76
+
77
+ **Windows 上的 `9009`** 是"找不到命令"。在本项目里它几乎总是同一个原因:PATH 上没有**真正的** Python
78
+ (`init` 会依次探测几个候选,能跑且能 `import sqlcipher3` 才算数),最后兜底用了裸名 `python`,
79
+ 而那个名字落在微软商店的占位程序上。按顺序做:
80
+
81
+ ```powershell
82
+ python --version # 要能打出版本号;报错或弹商店页面就是没装好
83
+ pip install sqlcipher3
84
+ weflow-cli config forget-keys --yes
85
+ weflow-cli init
86
+ ```
87
+
88
+ 扫描失败不等于数据有问题,也不等于密钥错了 —— 它只表示**这次没有验证过**密钥。不要因为看到 9009
89
+ 就去删数据目录或反复退出登录。
90
+
69
91
  ## 3. 数据读取验证
70
92
 
71
93
  ```powershell
@@ -623,6 +645,10 @@ weflow-cli search "..." --line all # 两条线一起(默认只搜文
623
645
  "要 10 条只回 3 条"或者"另一条线的结果混进来还标着本线的名字"。
624
646
 
625
647
  - 返回里 `score` 仍是相似度(含义没变),重排的分数在 `rerankScore`。
648
+ - **返回里的 `vector` 字段告诉你这次到底走没走向量**:`{"used": true/false, "fallback": null/<原因>}`。
649
+ `used: false` 时 `fallback` 会写明原因(embedding 上游失败 / numpy 不可用 / embedding 为空或全零…)——
650
+ 这条以前没有,于是"只搜到关键词结果"和"关键词结果就长这样"在输出里一模一样。
651
+ `hybrid` 的含义**没变**("融合开关是开的"),两者要一起读。
626
652
  - 没配 `typesafeApiKey`、连接失败或服务过载(529)时,**原序返回并打一行 WARN**,
627
653
  检索本身不会因此失败。瞬时错误会自动重试最多 2 次。
628
654
  - **本机实测重排是不生效的**(2026-10-15):Typesafe 返回 `HTTP 451: not available in your region`,
@@ -633,10 +659,18 @@ weflow-cli search "..." --line all # 两条线一起(默认只搜文
633
659
  (本机能连上的那个)。两段都失败就保持原序,并把 `provider` / `degraded` / `error` / `scored` 写进 JSON 的
634
660
  `rerank` 字段 —— 别把"没重排"读成"重排过了"。送进模型前会先**清洗段落**(去加粗标记、公众号套话、署名行、
635
661
  图片与链接语法),因为摘录只有 300 字。
636
- - **代价**:每次检索多一次模型调用(约 2–3k tokens ≈ ¥0.004)与几秒延迟;`search`、助手的 `search_semantic`、
637
- MCP 的 `search_knowledge`、`chat` 共享同一条路,所以一起受影响。要省/要快就 `--no-rerank`。
638
- 实测效果(同一批 40 条题):hit@1 0.55 → **0.65**、recall@5 0.65 → **0.80**、nDCG@10 0.63 → **0.74**;
639
- 最弱的"碎片/文章"那格 recall@5 0.30 → **0.70**。
662
+ - **代价**:每次检索多一次模型调用(池 100 时约 20k tokens,**约 9 秒**、约 ¥0.01);`search`、助手的
663
+ `search_semantic`、MCP 的 `search_knowledge`、`chat` 共享同一条路,所以一起受影响。
664
+ **这 9 秒不要省**——它是全流程最大的质量杠杆。实测(2026-10-18,同 40 题同种子):
665
+
666
+ | 重排 | hit@1 | recall@5 | recall@10 | MRR | nDCG |
667
+ | --- | --- | --- | --- | --- | --- |
668
+ | 关 | 0.550 | 0.650 | 0.750 | 0.594 | 0.630 |
669
+ | **池 20** | 0.675 | 0.800 | 0.825 | 0.733 | 0.756 |
670
+ | **池 100(默认)** | **0.825** | **0.925** | **0.950** | **0.863** | **0.884** |
671
+
672
+ ⇒ **池子从 100 降到 20 会掉一大截**(hit@1 −0.15),拿它换速度不划算(D-123)。
673
+ 离线批处理或赶时间时手动 `--rerank-pool 20` 可以,默认别改。
640
674
  - **默认会融合一次本地关键词名次**(门控 BM25 + RRF,2026-10-15 起;`--no-hybrid` 关掉)。它解决的是一种具体的失效:问句里
641
675
  最稀有的那个词被出现最多的词带走 —— 实测「卫星遥感反演气溶胶光学厚度」在纯向量下把"基于遥感的 PM2.5 反演"
642
676
  排到第 3,开了它到第 2。但它在**另外 4 条实测问句上都不优于纯向量**(「有没有讲遥感…」被挤出前五、
@@ -667,6 +701,112 @@ weflow-cli search "..." --line all # 两条线一起(默认只搜文
667
701
  掉了**(严格相关 40% → 30%,宽松 68% → 64%),recall@5 也掉 0.03 —— 因为这份语料里的"重复"多是
668
702
  **多个号转载同一件事**,MMR 把有用的重复一起删了。所以默认关;要"同一件事只留一条"用默认的
669
703
  **同文本折叠**(免费、不损信息),别用 MMR。
704
+ ### 决策模型走哪条通路(地区限制 + OpenRouter)
705
+
706
+ 判断这一层(日报的主题/相关度、检索重排、欠话台账、草稿回复、路由选词)用的是**同一个**客户端,
707
+ 所以它换通路是**一处开关替掉整层**:
708
+
709
+ ```powershell
710
+ weflow-cli config show # 看当前值(没有 config get,是 show)
711
+ weflow-cli config set judgeProvider chat # 国内对话模型(推荐,见下)
712
+ weflow-cli config set judgeProvider openrouter # 或走 OpenRouter
713
+ weflow-cli config set openrouterJudgeModel typesafe/jev-router # 默认就是这个
714
+ # key 用 set-env 设,别让密钥出现在命令参数里:
715
+ $env:OPENROUTER_KEY = '<key>'; weflow-cli config set-env openrouterApiKey OPENROUTER_KEY
716
+ ```
717
+
718
+ **推荐 `judgeProvider=chat`**:它复用配置里**已有的**对话模型(`aiBaseUrl` 中转站 / `aiModel` / `deepseekApiKey`),
719
+ 所以不需要新厂商、新 key,一句话就打开。**"已有的"到底指谁看 `aiEngine`**:
720
+
721
+ | `aiEngine` | `judgeProvider=chat` 往哪发 |
722
+ | --- | --- |
723
+ | `deepseek`(默认)/ 未配置 | `aiBaseUrl` 中转站,没配就直连 DeepSeek |
724
+ | `local` / `ollama` | 本机检测到的本地服务(Ollama / LM Studio 等),`provider=chat-local` |
725
+ | `local` 但没检测到服务 | **不发**,只打 WARN,这批判断走 LLM 路径(**不会退回云端**) |
726
+ | `claude` | 明确拒绝;要云端判断请改 `judgeProvider=openrouter` |
727
+
728
+ 本地引擎分支有单测(假引擎验分派与契约),**没有**对真实 Ollama/LM Studio 跑过端到端(D-124)。
729
+
730
+ 本机实测(2026-10-17,同一篇真实文章):
731
+
732
+ | 模型 | 延迟 | 契约 | relevance / 收录概率 |
733
+ | --- | --- | --- | --- |
734
+ | **`deepseek-chat`** | **1.0 s** | ✅ 通过 | 1 / 0.15 —— 成本量级 **¥1/天**(500 篇) |
735
+ | `deepseek-reasoner` | 7.2 s | ✅ 通过 | 1 / 0.05 |
736
+ | `qwen-plus`(百炼) | 2.3 s | ✅ 通过 | **2 / 0.85** |
737
+ | `qwen-turbo` | — | HTTP 429(账号速率限制) | — |
738
+
739
+ ⚠️ **契约一致不等于判断一致**:同一篇文章 `deepseek-chat` 与 `qwen-plus` 给的相关度与收录概率
740
+ 差得很远(0.15 vs 0.85),而下游阈值是对着 Jev 拍的。所以换判断器要当成**换了一把尺子**:
741
+ `relevanceScore` / `includeScore` 与 `usage.model` 都落盘,就是为了这种时候能看出"是谁判的"。
742
+
743
+ ⚠️ **地区限制是「按模型」的,不是「按网站」的 —— 这是 2026-10-17 在本机实测的**:
744
+
745
+ | 目标 | 结果 |
746
+ | --- | --- |
747
+ | `api.typesafe.ai`(原生 Jev) | **HTTP 451** "not available in your region" —— 本机一直是死的 |
748
+ | `typesafe/jev-router`(OpenRouter 上同一个模型) | ✅ 通 |
749
+ | `:free` 结尾的免费模型 | ✅ 通(零成本;免费额度 **50 次/天**,一天 500 篇只够约 10%) |
750
+ | `openai/gpt-4o-mini` 等付费通用模型 | **HTTP 403** "This model is not available in your region." |
751
+
752
+ 所以**换模型必须在本机重测一次**(`python scripts/jev_probe.py --limit 1 --yes` 或任何一次真实调用),
753
+ 不能假定"能调一个就能调所有"。
754
+
755
+ **代价(实测,不是估算)**:一篇真实文章 + 4 个候选主题的一次调用花 **$0.014**(当时路由到 `x-ai/grok-4.7`),
756
+ 其中推理 token 1535。**路由到谁每次都可能不同**(另一次是 `z-ai/glm-5.3-flash`),所以要按最坏估:
757
+ **一天 500 篇 ≈ $7/天**。`max_tokens` 管不住这个成本。想压价只有两条路:换更便宜的模型,或只对一部分文章用。
758
+
759
+ **四种失败要分开读**(它们的处置完全不同):
760
+
761
+ | 报错 | 含义 | 怎么办 |
762
+ | --- | --- | --- |
763
+ | 402 额度不足 | OpenRouter 按 `max_tokens × 单价` 预检,余额不够就拒 | 充值,或换免费模型 |
764
+ | 403 + "region" | 这个模型在本地区不可用 | **换模型**(换 key 没用) |
765
+ | 401 鉴权被拒 | key 不对/过期 | 换 key |
766
+ | 空正文(`content` 为空) | 推理 token 吃光了预算 | 调大 `OPENROUTER_MAX_TOKENS` |
767
+
768
+ **不许从这条路推断"与原生判断一致"**:原生在本机调不通,**没法对拍**。能声明的只有
769
+ "契约成立(过了仓库自己的 `_check_choice`)、分数落盘、成本可查"。
770
+
771
+ > 旁注:曾有一封自称来自 `nace.ai`、送 $25 额度、推销"最快的决策模型 Drex"的邮件。
772
+ > 实测:OpenRouter 的 **466 个模型里没有** `drex` / `nace`,那个 key 的账号 `total_credits` 是 **0**。
773
+ > 这类邮件的价值判断别靠邮件本身。
774
+
775
+ #### 原样定位:`search --phrase`(哪个号在哪天原样写过这句话)
776
+
777
+ 上面那条路(向量 + BM25 + 重排)回答的是"**意思上**最像的是哪几篇"。它答不了另一种问句:
778
+ **哪篇里原样写过这个名字 / 这句话**——专有名词、代码名(`std::vector`)、政策原文、引文核对。
779
+
780
+ ```powershell
781
+ weflow-cli search-index --phrase # 建/重建短语索引(本地,不联网、不花钱)
782
+ weflow-cli search "沙箱逃逸" --phrase # 原样定位;不需要 --yes
783
+ weflow-cli search "PM2.5" --phrase --from 生态环境部 --top-k 3 --json
784
+ ```
785
+
786
+ ⚠️ `--phrase` 只认 `--top-k` / `--line` / `--from` 这几个参数。**向量检索的那些旗标
787
+ (`--no-rerank`、`--rerank-pool`、`--no-hybrid`、`--no-dedup`、`--mmr`、`--with-text`、
788
+ `--fetch-missing`)在这条路上不生效**,CLI 会直接报 `INVALID_ARGUMENT` 而不是默默忽略——
789
+ 静默忽略会让人把默认行为当成"调过参数的结果"。直接调 `scripts/semantic_search.py` 时
790
+ 则会打一行 `[WARN] ... ignored: ...`。
791
+
792
+ 返回里除了命中行(带 `【原文串】` 上下文、号名、日期、标题),还有 `groups`:按
793
+ **(账号/会话 × 日期)**分组计数——"哪个号在哪天写过"直接读它。
794
+
795
+ 四条边界必须知道(都是实测的):
796
+
797
+ | 情形 | 行为 | 为什么 |
798
+ | --- | --- | --- |
799
+ | 查询 **< 3 字**(如"光伏") | 返回 `tooShort: true` + 说明,**不报 0 命中** | `trigram` 切不出词项。把"查不了"说成"没有"是这条腿最坏的失效方式 |
800
+ | 查询含标点(`PM2.5`/`C++`/`GPT-4`) | 正常 | 整串当**字面量**(加引号),不是 FTS 语法 |
801
+ | 索引没建 | 返回 `error` + `hint` | 别让人以为"库里没有这句话" |
802
+ | 同义改写 / 全角半角 / 断行 | **查不到** | 它只认**原样**。要"意思上"就用不带 `--phrase` 的那条路 |
803
+
804
+ - **纯本地**:不联网、不花钱、不需要 key,所以也**不需要 `--yes`**(那道确认门是为出境设的)。
805
+ - 索引是**派生**产物(`output/.phrase_index/`,约 97 MB,全量重建约 11 秒),**由语义索引派生**:
806
+ 语义索引更新了它就得重建(目前没有排期)。
807
+ - `--from` 的口径与语义那条路一致(**文件名子串**),所以命中的号名可能与你筛的词不同——
808
+ 号名常出现在标题里。要精确按号,命中的 `label` 就是号名本身。
809
+
670
810
  ### 检索质量怎么量(评测台)
671
811
 
672
812
  改了检索(换模型、调窗口、动排名)之后,"变好还是变坏"要用同一批题量,不能靠手工试问:
@@ -916,24 +1056,99 @@ weflow-cli daily --dry-run --json # 看它读到多少篇、准备处理哪
916
1056
  ```
917
1057
 
918
1058
  正常会打印当天扫到的文章数与一个 `{"success":true,"dryRun":true,...}`。读到几百篇就说明
919
- 读取没问题,缺的只是一个触发。交给计划任务(与「WeFlow Health Check」同一套做法,
920
- 不依赖任何编辑器或会话开着):
1059
+ 读取没问题,缺的只是一个触发。交给计划任务 —— **用仓库里的安装脚本注册,别手抄命令**:
1060
+
1061
+ ```text
1062
+ 双击 scripts\install_tasks.cmd ← 最省事:它自己申请管理员权限,装完窗口留着给你看结果
1063
+ ```
1064
+
1065
+ 或者在你自己的管理员 PowerShell 里:
921
1066
 
922
1067
  ```powershell
923
- schtasks /create /tn "WeFlow Daily" ^
924
- /tr "weflow-cli daily --yes" ^
925
- /sc daily /st 08:20
1068
+ powershell -NoProfile -ExecutionPolicy Bypass -File <仓库>\scripts\install_tasks.ps1
926
1069
  ```
927
1070
 
928
- - 查看:`Get-ScheduledTask -TaskName 'WeFlow Daily'`
929
- - 手动跑一次:`Start-ScheduledTask -TaskName 'WeFlow Daily'`
930
- - 上次结果:`schtasks /query /tn "WeFlow Daily" /fo LIST /v`
1071
+ 它装两个任务(重复运行只会覆盖成同样的定义):
931
1072
 
932
- 几点要事先知道:
1073
+ | 任务 | 时间 | 跑什么 | 为什么是这个点 |
1074
+ | --- | --- | --- | --- |
1075
+ | `WeFlow Daily` | **00:05** + 每次登录 | **昨天**(刚结束的完整一天) | 公众号发到 24:00 才停,这是唯一能拿到完整一天的时点;00:05 让"昨天"与日期目录天然对齐 |
1076
+ | `WeFlow Daily Noon` | **13:00** | **今天已到达的部分**(半天) | 想当天先看一眼重点;不完整是有意的,别拿它当"今天的全部" |
1077
+
1078
+ > **为什么这步得由你点一下**:计划任务的**写入**在受限 shell / agent 沙箱里是整族被拒的。
1079
+ > 2026-10-19 用管理员 token 实测三条路全红、而读取一直正常:
1080
+ >
1081
+ > | 路径 | 结果 |
1082
+ > | --- | --- |
1083
+ > | `schtasks /create` | `Access is denied` |
1084
+ > | COM `Schedule.Service` → `RegisterTaskDefinition` | `0x80070005` |
1085
+ > | `New-ScheduledTaskAction` / `-SettingsSet`(连**内存里构造对象**都) | `拒绝访问` |
1086
+ >
1087
+ > 所以"看得见却建不了"不是错觉,也**不是靠'以管理员运行'能解决的**——换一个不被沙箱监管的
1088
+ > 终端(双击上面那个 `.cmd` 就会用你自己的桌面会话申请提权)才行。
1089
+
1090
+ 两次跑的是**同一天目录**,靠 `.run_state.json` 增量:13:00 处理过的文章,00:05 不再重抓、不重复调 AI,
1091
+ 只补下午到达的那些;而 README 与 `index.html` 每次都从磁盘上的全部 md 重建,所以日终那份是全天完整版。
1092
+
1093
+ `.articles.json` 也必须是**合并写**(2026-10-19 修):它按 url 去重后并进旧文件。以前是覆盖写,
1094
+ 午报后再跑日终会把上午那批的标题/摘要/主题从结构化数据里抹掉 —— 页面是全天、数据只有半天,
1095
+ 下游(AI 报告、统计)静默少一半。
1096
+
1097
+ 动作只有一句"跑那个脚本",具体的事在仓库里的 `scripts/daily_task.ps1`(纯 ASCII、路径全部相对自身)。
1098
+ 第一个位置参数是模式(`noon` = 今天;缺省 = 昨天)。它做四件事,每件都有理由:
1099
+
1100
+ 1. **跑目标天**:日终 `pipeline.py --yesterday --prefetch-bodies`;午报 `pipeline.py --date <今天> --prefetch-bodies`。
1101
+ 2. **把输出落盘**:`logs/daily-<日期>[-noon].log` 与 `daily-<日期>[-noon].err.log`(`logs/` 已 gitignore)。
1102
+ 输出丢掉的计划任务只会回答"失败了",答不了"昨天为什么短"。
1103
+ **同一天重跑不会覆盖上一次**:开跑前,已存在的当天日志会先被挪成
1104
+ `daily-<日期>.run<N>.log`(`N` 取第一个空位,`.err.log` 同样处理)——
1105
+ `Start-Process` 的重定向是**覆盖**,而重跑前最想留的恰恰是刚失败那次的日志。
1106
+ 3. **设 `PYTHONIOENCODING`/`PYTHONUTF8`**:这不是洁癖 —— 把 stdout 重定向到**文件**会改变 python 的
1107
+ stdout 编码(不再是控制台、而是本地代码页)。标题里出现该代码页放不下的字符时,`print` 会在
1108
+ **这一天已经写完之后**抛 `UnicodeEncodeError`,看着像失败而产物其实是好的。
1109
+ 4. **幂等守卫**:日终任务看"昨天是否已完整",午报看 `.noon_done`。判据不是"index.html 在不在"——
1110
+ 午报也会写 index.html,只看它会让 00:05 跳过下午、留下一份"看着完整、实际半天"的日报。
1111
+ 实际判据是 `.articles.json` 的 `generated_at`:**生成于目标日之后**(通常就是次日 00:0x),
1112
+ 或当晚 23:00 之后,才算这天做完了。
1113
+
1114
+ > 为什么不把 python 命令直接写进任务动作:① 实测 `Register-ScheduledTask` 对带嵌套引号的
1115
+ > `-Argument` 会报 `0x80004005`,而包一层脚本就不用跟引号搏斗;② 任务动作指向 CLI 那个 npm 包时,
1116
+ > 它一旦是构建产物,"改完代码没 build"就变成"任务跑的还是旧逻辑"。
1117
+ > 冒烟测试:`powershell -NoProfile -File <仓库>\scripts\daily_task.ps1 --help`(只打印帮助、零副作用,
1118
+ > 并且会写进当天那份日志 —— 证明链路是通的)。
933
1119
 
934
- - **`--yes` 不能省**:机器模式下没有它就会停在确认门上(这是有意的——日报会调 AI、会写文件)。
1120
+ - 查看:`Get-ScheduledTask -TaskName 'WeFlow Daily'`
1121
+ - 手动跑一次:`Start-ScheduledTask -TaskName 'WeFlow Daily'`
1122
+ - 上次结果:`(Get-ScheduledTaskInfo -TaskName 'WeFlow Daily').LastTaskResult`
1123
+ (**`267009` 不是错误**,它是"任务正在运行"`0x41301`;`0` 才是成功。)
1124
+
1125
+ 几点要事先知道(每一条都是踩过的):
1126
+
1127
+ - **排期要用 `--yesterday`,不是"跑今天"**,理由只有一条但决定成败:公众号发到 24:00 才停,
1128
+ 所以**当晚跑的那一天永远不全**,而且以后没人会回头补它。反过来,"跑昨天"这个动作**与时刻无关** ——
1129
+ 00:00 之后的任何时间跑,读到的都是同一天的完整数据。于是"机器当时在睡觉"这个最常见的漏跑,
1130
+ 只要任务允许补跑就**结果完全相同**。
1131
+ (`daily` 默认跑"今天",所以必须显式给;`--date` 与 `--yesterday` 同时给会当场报错退出,
1132
+ 而不是替你选一个。)
1133
+ - **要 `-StartWhenAvailable`(所以用 `Register-ScheduledTask`,不是 `schtasks /create`)**:
1134
+ `schtasks` 没有对应开关,它藏在任务 XML 的 `<StartWhenAvailable>` 里。没有它,
1135
+ 机器 08:20 在睡觉 = 这天**静默**没有日报。
1136
+ - **`--prefetch-bodies` 把这一小时压成几分钟**:抓取阶段是**串行**的,每篇没命中缓存要
1137
+ `sleep` 8–12 秒(节流是为了少打扰上游),450 篇就是约 75 分钟。而**命中缓存既不联网也不 sleep**,
1138
+ 所以这个开关在抓取之前先用 8 个并发(可带数字改)把当天正文全抓进缓存,抓取阶段于是全是缓存命中。
1139
+ 对上游的**请求次数一模一样**,区别只是从"一个多小时里慢慢发"变成"十几分钟里发完"——
1140
+ 这是个取舍(瞬时并发更高),所以**默认关**、要显式打开。
1141
+ 【实测 2026-10-16,523 篇的那天】预热 **516 篇用了 10 分钟**(22:26:24 → 22:36:23;前 6 分钟约
1142
+ 75 篇/分钟,最后 8 篇慢的拖了 4 分钟),**整天 `biz_daily` 15 分 49 秒**(22:26:21 起,22:42:10
1143
+ 落 `.articles.json`)—— 同样这些篇数走串行 + 节流要一个多小时。
1144
+ - **任务跑的是 `scripts/pipeline.py`,不是 `weflow-cli`**:npm 全局那个包是**发布版**,
1145
+ 滞后于仓库;而且它一旦是构建产物,改完代码不 `npm run build` 就看不到改动(见
1146
+ 「改完没反应」那类问题)。python 层没有构建步骤,是同一份源码。人在终端里跑还是用
1147
+ `weflow-cli daily --yesterday --prefetch-bodies --yes`,两者等价。
1148
+ - **`--yes` 不能省**(走 CLI 时):机器模式下没有它就会停在确认门上(这是有意的——日报会调 AI、会写文件)。
935
1149
  - **会花钱**:默认跑 AI(摘要/标签/简报)。想让它不出网、不花钱就用
936
- `weflow-cli daily --yes --no-ai`;只要主题与相关度、不要摘要可用 `--no-summary`。
1150
+ `--no-ai`;只要主题与相关度、不要摘要可用 `--no-summary`。
1151
+ 【未知】单价仓库里没有常量、客户端也不回 usage,所以**事先算不准**,只能看账单。
937
1152
  - **没跑成留下的空目录不等于跑过了**:一个只有目录、没有 `README.md` / `.articles.json` /
938
1153
  `index.html` 的日期**不算完成**,再跑一次不带日期的 `daily` 会先把它补上(D-011)。
939
1154
  所以看到 `output/biz-daily/2026-09-22/` 这种空目录时,处理办法就是再跑一次,
@@ -948,17 +1163,26 @@ schtasks /create /tn "WeFlow Daily" ^
948
1163
  | --- | --- | --- |
949
1164
  | README 开头 | `# 公众号日报` | `# 公众号文章 — <日期>(历史回填)` |
950
1165
  | 内容 | AI 摘要 + 图片本地化 + `index.html` 阅读页 + 行动建议 | 只抓正文,**不下载图片、不调用模型** |
951
- | 成本 | 一天约 1 小时(含 8–12 秒/篇的节流)+ AI 费用 | 并发抓取,几分钟一天,**不花钱** |
952
- | 排期命令 | `schtasks /create /tn "WeFlow Daily" /tr "weflow-cli daily --yes" /sc daily /st 08:20` | `schtasks /create /tn "WeFlow Backfill" /tr "python <仓库>\scripts\backfill_articles.py --since <昨天> --until <昨天> --workers 4 --yes" /sc daily /st 08:20` |
1166
+ | 成本 | 一天约 1 小时(含 8–12 秒/篇的节流;**加 `--prefetch-bodies` 后抓取阶段只剩几分钟**)+ AI 费用 | 并发抓取,几分钟一天,**不花钱** |
1167
+ | 排期命令 | `pipeline.py --yesterday --prefetch-bodies`(见上「让它每天自己跑」) | `python <仓库>\scripts\backfill_articles.py --since <昨天> --until <昨天> --workers 4` |
953
1168
 
954
1169
  两条**可以并存**,但要知道区别:回填那条便宜得多、跑得快,代价是没有摘要/图片/网页。
955
- 本机实测(2026-10-14):归档里 359 天中只有 12 天是完整日报形态,其余都是回填形态——
956
- 也就是说"日报断了半个月"这类现象,多数时候断的是**回填**那条,而它同样需要有人触发。
1170
+ 本机实测(2026-10-16 复核):归档 **372** 个日期目录 = **13 天有网页/图片**(`08-24`~`09-05` 那 12 天,
1171
+ 加当天刚产出的 `10-15`)+ **358 天只有 `.articles.json`(回填形态)** + 1 个 `.template`。
1172
+
1173
+ > ⚠️ **别把"有 `.articles.json` 和 README"当成"完整日报"** —— 最后一天完整日报是 **2026-09-05**,
1174
+ > **不是** 09-28:那份 README 自己写着"历史回填…未下载图片、未调用模型"。也就是说完整日报这条线
1175
+ > 暗了 **40 天**,中间那些天标题和正文在、**网页和图片不在**。同一个 D-060 的教训:文件在 ≠ 这天完整。
957
1176
 
958
1177
  > 回填与完整日报**共用一份正文缓存**(`output/.cache/fetch/`,D-108):回填抓过的正文,之后
959
1178
  > `daily --date X` 会直接命中、不再联网、也不等节流。但**别把接力读成"那天就免费了"**——日报对
960
1179
  > 每篇有 url 的文章都要抓完整正文,回填只抓"本地正文过短"的那些,所以 2026-10-13 那天 485 篇里
961
1180
  > 只有 145 篇在缓存里。想强制重新抓正文用 `--no-fetch-cache`(两边同名同义:跳过"读",成功仍写回)。
1181
+ >
1182
+ > **预热缓存有两条路,别搞混**:① `daily --prefetch-bodies` 在**它自己要处理的那一天**上先并发抓一遍
1183
+ > (推荐,见上「让它每天自己跑」);② 回填时顺手抓(`backfill_articles.py`,但它的判据是"本地正文过短",
1184
+ > 所以填不满)。**索引不吃这份缓存**——2026-10-16 量过"让索引吃完整正文"没有增益(D-118),
1185
+ > 这份缓存只服务展示路径(`search --with-text`)与"抓取阶段不 sleep"。
962
1186
 
963
1187
  #### 怎么看出某天"没做完"
964
1188
 
@@ -1373,11 +1597,23 @@ weflow-cli scene unbind wxid_xxx --yes
1373
1597
 
1374
1598
  ## 7. 常见问题
1375
1599
 
1600
+ **卡住时先跑这一条:**
1601
+
1602
+ ```powershell
1603
+ weflow-cli check --report
1604
+ ```
1605
+
1606
+ 它输出一段**已经判定过、且脱敏**的文本:环境(Node/Python/依赖)、微信客户端版本与数据目录是否和配置对得上、配置里有没有密钥,最后是**结论**——直接写“卡在哪一步、下一步做什么”(Python 不可用 / 缺 sqlcipher3 / 还没初始化 / 客户端没在跑 / 数据目录不是同一份)。
1607
+
1608
+ 这段可以整段复制给 AI 或贴到 issue:用户名、wxid、密钥形态的字符串会被替换成 `<用户>` / `wxid_<已隐藏>` / `<密钥>`,聊天内容与密钥值从不进入这份报告。**比口头描述现象省事得多**——2026-10-18 之前同一个根因被报成了四种不同问题(issue #3 / #9 / #10 与一封邮件),就是因为现象描述里缺了这些事实。
1609
+
1376
1610
  | 现象 | 处理 |
1377
1611
  | --- | --- |
1612
+ | `Error: Cannot find module …\npm\node_modules\weflow-cli\cli.cjs` | 全局安装**残缺**(目录里只剩零散依赖,没有 `package.json`、没有 `cli.cjs`)。`npm ls -g weflow-cli` 显示版本号为空就是它。删掉 `%APPDATA%\npm\node_modules\weflow-cli`,再 `npm i -g weflow-cli@latest`。2026-10-19 在本机遇到过一次,重装即恢复 |
1378
1613
  | 找不到数据目录 | 先 `init --path`,再 `--search-drives`,最后 `--full-scan`。 |
1379
1614
  | `Python not found` | 安装 Python 并确保当前 PowerShell 能执行 `python --version`。 |
1380
1615
  | 缺少 `sqlcipher3` 等依赖 | 用同一个 Python 执行 `python -m pip install -r requirements.txt`,再 `weflow-cli check`。 |
1616
+ | `sessions` 显示"未找到会话"但微信里明明有聊天 | 先 `weflow-cli check --report` 看结论:这多半是读取那一步没成功,不是"没有会话"。 |
1381
1617
  | `WCDB ... -1006` | 检查 NT 配置和数据库路径,不要只看 WCDB 降级信息;必要时刷新初始化。 |
1382
1618
  | 日报缺昨天 | 无日期运行 `daily` 会检查并补齐昨天;补齐失败会停止今天,不覆盖失败状态。 |
1383
1619
  | `daily --dry-run` 不识别 | 确认使用的是当前源码或最新发布包;当前源码支持该参数,旧 npm 包可能落后。 |
package/README.md CHANGED
@@ -118,6 +118,12 @@ npm install -g weflow-cli
118
118
  weflow-cli check
119
119
  ```
120
120
 
121
+ > Windows 上如果这条报 **「无法加载文件 …\npm\weflow-cli.ps1,未进行数字签名」**,拦住它的是
122
+ > PowerShell 的执行策略,不是包本身。两条路,任选:
123
+ >
124
+ > - 马上能用:换成 `weflow-cli.cmd check`(`.cmd` 那份包装不走 PowerShell 策略);
125
+ > - 一次性解决:`Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`,然后重开终端。
126
+
121
127
  安装标准 Windows 4.x 工作流所需的 Python 依赖:
122
128
 
123
129
  ```powershell
@@ -134,6 +140,10 @@ python -m pip install -r requirements.txt
134
140
  weflow-cli init
135
141
  ```
136
142
 
143
+ > **数据目录不在默认位置时,请同时用 `--path` 指定。** 关掉微信之后,自动发现只剩下
144
+ > "`文档\xwechat_files` 这类默认位置"这一条路;微信"存储位置"改到别的盘的用户会直接看到
145
+ > 「未检测到微信数据目录」。
146
+
137
147
  微信“存储位置”、账号目录和其 `db_storage` 目录都可以直接指定:
138
148
 
139
149
  ```powershell