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
package/CHANGELOG.md CHANGED
@@ -17,6 +17,384 @@ 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
+ ## 1.13.0
21
+
22
+ - **`sessions` shows readable previews instead of raw message bytes.** The preview column used to
23
+ print whatever sat in `source` / `message_content`, so card and mini-program messages came out as
24
+ `(/ }25984…` fragments and text messages with a corrupt column showed binary. It now labels by
25
+ message type (`[图片]` / `[语音]` / `[视频]` / `[表情]` / `[链接/文件]` / `[系统消息]`, honouring
26
+ 4.x's packed `local_type` where the subtype lives in the high bits), picks whichever column decodes
27
+ as real text, and strips a leading run of ASCII symbols that group messages carry. Chinese
28
+ punctuation and emoji openings are left alone.
29
+ - **`check` says when the configured database path is a junction.** WeChat moved to another drive
30
+ leaves `C:\Users\<user>\xwechat_files` pointing at the real location; printing only the link made
31
+ it look like the config pointed somewhere wrong. It now adds `↳ 软链指向 <real path>`.
32
+ - **README: `init` needs `--path` when the data directory is not in a default location**, because
33
+ "close WeChat first" removes the only remaining way auto-discovery could find it.
34
+ - **Silent failures now say what broke, where.** Three places that used to lose data without a word:
35
+ a message shard that cannot be opened during `export` (it skipped the shard and still reported
36
+ success - you got a shorter transcript and no reason), a corrupt `.image_map.json` in the daily
37
+ report (images from earlier passes disappeared from the HTML), and an encrypted config value that
38
+ will not decrypt (`get_*_key` returned `''`, which reads as "not configured" even though it *is*
39
+ configured and merely broken). Each now prints one line naming the file and the exception type;
40
+ the config warnings are de-duplicated because those helpers run on every request. A guard test
41
+ (`test/silent_failure_guard_test.py`) pins the count of bare `except:` + `pass` bodies at 53 so
42
+ the number cannot creep back up.
43
+ - **`docs/POSITIONING.md`**: why this project exists next to WeChat itself. Written after 4.1.15
44
+ added "ask AI about a file / an article" - i.e. WeChat now does the simplest half of what a
45
+ "WeChat + AI" tool does. It lists the five moats (data that can leave the client, cross-time
46
+ queries, cross-corpus joins, local inference, traceable answers), the features that will be eaten
47
+ (single-document summaries, in-chat keyword search), and four questions that decide whether a
48
+ feature belongs here.
49
+ - **Two scheduled passes instead of one: a noon preview and a complete end-of-day run.**
50
+ `scripts/daily_task.ps1 noon` produces *today so far* (13:00); the bare invocation still
51
+ produces *yesterday* (now 00:05 + at logon). Both write the same date directory, and the
52
+ second pass is incremental - `.run_state.json` makes `biz_daily` skip what the noon pass
53
+ already handled, while README/`index.html` are rebuilt from every markdown file on disk.
54
+ `scripts/install_tasks.ps1` registers both (it needs an elevated terminal: Task Scheduler
55
+ writes are refused in a sandboxed shell while reads still work).
56
+ - **`.articles.json` is merged, not overwritten.** With two passes a day the second one only
57
+ carries the articles that arrived after noon, so overwriting dropped the morning batch's
58
+ titles/summaries/topics from the structured file while the page still listed the whole day -
59
+ downstream reports and statistics silently saw half a day. Entries are now merged by URL
60
+ (title+time when a push has no URL), `decisionModel` survives a `--no-ai` rerun, and a
61
+ corrupt old file starts from empty instead of failing the day.
62
+ - **The scheduled guard no longer trusts `index.html` alone.** The noon pass writes one too,
63
+ so "index.html exists" made the 00:05 run skip the afternoon and leave a complete-looking
64
+ half day behind. It now reads `.articles.json`'s `generated_at`: finished means generated
65
+ after the target day (the usual 00:0x pass) or after 23:00 that night.
66
+ - **`weflow-cli check --report`: a paste-ready, redacted diagnosis.** It prints the environment
67
+ (Node / `python --version` / `sqlcipher3` & friends), the WeChat client actually loaded and whether
68
+ its data directory matches the configured one, whether a key/passphrase is configured, and then a
69
+ **verdict** — "Python unusable", "missing sqlcipher3", "not initialised", "client not running",
70
+ "reading a different database" — each with the next command to run. Usernames, `wxid`s and
71
+ key-shaped strings become `<用户>` / `wxid_<已隐藏>` / `<密钥>`; message bodies and key values never
72
+ enter it. `sessions` printing "未找到会话" and a failed `init` now point at it, because that is the
73
+ point where "no sessions" and "cannot read them" used to look identical (the same root cause was
74
+ reported as four different problems between 2026-08 and 2026-10).
75
+ - **`wechatClient.running` and its explanation no longer contradict each other.** It used to be
76
+ `any WeChat process`, while the note was keyed on `Weixin.exe`, so "only WeChatAppEx is running"
77
+ produced `运行中: 是` next to `客户端没在运行`. Both now use the launcher process; the note names
78
+ the helper processes when that is what it saw.
79
+ - **A same-day rerun of the scheduled daily no longer erases the log of the run that failed.**
80
+ `scripts/daily_task.ps1` redirects through `Start-Process`, which **overwrites** its targets, so
81
+ retrying a failed day replaced the only evidence of what broke. Existing `daily-<date>.log` /
82
+ `.err.log` are now rotated to `daily-<date>.run<N>[.err].log` (first free index) before the new
83
+ run starts. Verified by planting a marker in both files and running the wrapper: the markers
84
+ landed in `.run1` and the fresh log did not contain them.
85
+ - **`search --phrase` now refuses the vector-leg flags instead of accepting and ignoring them.**
86
+ `--no-rerank`, `--rerank-pool`, `--no-hybrid`, `--no-dedup`, `--mmr`, `--with-text` and
87
+ `--fetch-missing` describe the vector/rerank path; on the verbatim (FTS5) leg they were forwarded
88
+ to the script and **silently dropped**, so a reader who passed one got the default behaviour while
89
+ believing they had changed it. The CLI now exits with `INVALID_ARGUMENT` naming the offending
90
+ flags (JSON-aware), and calling `semantic_search.py` directly prints
91
+ `[WARN] --phrase only locates verbatim text; ignored: ...`. `--top-k`, `--line` and `--from` still
92
+ apply. A test spawns the real CLI to prove the rejection happens before any script call.
93
+ - **Client-version detection keeps working on newer WeChat: the command line is no longer the only
94
+ observation.** Since **4.1.15.53** the `Weixin.exe` main process is launched with **no switches at
95
+ all** (`"D:\WeiXin\Weixin.exe"`), so the `--user-lib-dir` judgement that `check` relied on returned
96
+ "version unknown" on every machine that updated - the one field worth looking at first when reads
97
+ fail. It now falls back to the directory of the **`Weixin.dll` the process actually has mapped**
98
+ (same quantity, second observation), and the `note` says which source was used. Measured after the
99
+ update: `check --json` reports `4.1.15.53` instead of `null`. The no-guessing rules are unchanged -
100
+ multiple directories still yield no single version, an unreadable module table is still `null`
101
+ rather than an inference from what is on disk, and the hardcoded version literal is still not read
102
+ (D-125 amends D-107).
103
+ - **`init` no longer says "初始化完成" when it has no usable key, and a Windows `9009` now says what to
104
+ do.** A 2026-10-18 report ended at `NT 数据库扫描: NT scan failed (exit 9009)`, "✓ 已沿用配置中的密钥",
105
+ "初始化完成! (4.x 模式)" and "密钥: 已安全保存" - with `sessions` empty on every run. Two defects
106
+ behind it: `getPythonCommand()` falls back to the **bare name `python`** when every candidate fails its
107
+ probe (runnable *and* `import sqlcipher3`), and on Windows that bare name is often the Microsoft Store
108
+ placeholder, which exits `9009`; and the closing summary was unconditional, so a scan that never ran
109
+ still looked like a finished setup. The exit code is now translated into the actual requirement
110
+ (Python 3.10+ with `sqlcipher3`), and the summary is gated on whether **this run** obtained a key
111
+ (memory-scan match *or* passphrase derivation) - otherwise it prints a failed-init heading with the
112
+ three steps that fix it, instead of "密钥已保存" next to a key that was never checked against this
113
+ database.
114
+ - **`judgeProvider=chat` now honours `aiEngine`; a local-engine user is no longer shipped abroad
115
+ silently.** The chat branch (D-120) read `deepseekApiKey` unconditionally, so with `aiEngine=local`
116
+ turning on the judge sent article text to DeepSeek while nothing in the UI changed - not a cost
117
+ question, but a user who chose to stay local being sent out anyway. `local`/`ollama` now goes through
118
+ `detect_local_engine()` (`provider='chat-local'`, same contract checks), `claude` is **refused
119
+ explicitly** rather than swapped for another vendor, and an undetected local service returns `None`
120
+ with a WARN instead of falling back to the cloud (D-124). The local path is covered by unit tests
121
+ with a stub engine; it has **not** been run against a live Ollama/LM Studio.
122
+ - **A search that did not use the vector leg now says so, and `--mmr` can no longer crash on that
123
+ path.** `search()` used to wrap its whole vector path in a bare `except: pass`, so *any* failure
124
+ (an embedding upstream error, numpy missing, a broken `meta.json`) silently became a keyword-only
125
+ search while the envelope still reported `hybrid: true` - two very different quality levels that
126
+ looked identical in the output, and the reason every "faster/slower, better/worse" comparison could
127
+ land on a different code path without anyone noticing. The response now carries
128
+ `vector: {used, fallback}`, where `fallback` names the actual exception (or "embedding empty/all
129
+ zero"). Two further defects came out of the same read: `vectors` was assigned **only inside** the
130
+ `try`, so a failure before `np.load` (e.g. `require_numpy()` raising) made the fallback's
131
+ `mmr_select(..., vectors, ...)` throw `UnboundLocalError` under `--mmr` - now initialized to `None`
132
+ and guarded; and `main()` parsed the 13.5 MB `meta.json` a **second** time for `line_counts()`
133
+ (~125 ms/query, 1.2% of a search) - the counts are now computed from the meta `search()` already
134
+ has. The bare `except` also swallowed `KeyboardInterrupt`, which made cancelling a long run
135
+ unreliable on that path. The rerank state gained a **`transport`** field alongside `provider`,
136
+ because `provider: typesafe` names the *contract* that ran, not who served it - with
137
+ `judgeProvider=chat` the Jev contract is satisfied by DeepSeek, so the old field read as "the native
138
+ API worked" (it did not; the other half of the same batch fell back to the LLM path). Eight tests
139
+ pin the fallback behaviour, and the misleading-first-test episode is recorded in D-122: a
140
+ regression test that failed *after* `vectors` was assigned stayed green when the initialization was
141
+ removed, and a mutation check is what exposed it.
142
+
143
+
144
+ - **A third retrieval leg answers a question the other two cannot: "which account wrote this exact
145
+ phrase, and when?"** `search --phrase` locates a string **verbatim** using SQLite's bundled
146
+ **FTS5 with the `trigram` tokenizer** (no new dependency), returning hits with the matched run
147
+ highlighted plus `groups` counting them by account × date. It is a *different question*, not a
148
+ better ranking: BM25 scores terms and vectors score meaning, so neither can answer a code name
149
+ (`std::vector`), a proper noun, a policy phrase or a quotation check - and folding "the exact
150
+ string is absent" into a similarity ranking is a failure the reader cannot see. Four measured facts
151
+ set the shape: the default `unicode61` tokenizer is **useless for Chinese** (whole sentences become
152
+ one token - `沙箱逃逸` matched **0**); `trigram` matches **contiguous substrings, not a term AND**
153
+ (`光伏补贴政策` only hits the verbatim occurrence, while `补贴政策` also hits "的补贴政策" - which is
154
+ exactly what this leg is for, and exactly why it cannot replace the keyword leg, since
155
+ `光伏补贴政策` returns **0 of 10,759** articles); it has a **3-character floor**, so a short query
156
+ returns an explicit `tooShort` instead of "0 hits" (saying "cannot search" to mean "absent" is this
157
+ leg's worst failure); and query punctuation is parsed as **syntax** (`PM2.5` was a syntax error,
158
+ `GPT-4` a "no such column"), so the whole string is quoted as a literal (inner quotes doubled) -
159
+ after which `PM2.5` / `C++` / `GPT-4` all work. On this corpus: `沙箱逃逸` 2 hits across two
160
+ accounts, `PM2.5` **58** (including the chat line), `腾讯混元` 7, `分布式光伏` 1; `std::vector` 0.
161
+ **One table for the corpus, not one per article** - a table per article would mean ~96,000 tables
162
+ with FTS5's shadow tables, `bm25()`'s corpus-level statistics degenerating to one row each, and the
163
+ inverted index's only advantage thrown away. The index is **derived** from the semantic index's
164
+ `meta.json` (a second record enumeration would drift, silently). Build with
165
+ `search-index --phrase`: **local, free, no key, no confirmation gate** (that gate exists for
166
+ egress) - the CLI's preview says `mayUseCloudEmbedding: false` so this is not mistaken for a
167
+ vector call.
168
+
169
+
170
+ - **A Jev-like decision model that works from this region is one config line away.** `judgeProvider` now
171
+ selects among three transports for the *whole* decision layer (the daily's topic/relevance/inclusion
172
+ judgement, search reranking, reply debt, drafted replies and route-card term selection all share one
173
+ client): `native` (unchanged default), **`chat`** - the conversational model already configured for
174
+ summaries (`aiBaseUrl` relay / `aiModel` / `deepseekApiKey`), so it needs **no new vendor and no new
175
+ key** - and `openrouter` (a new encrypted `openrouterApiKey` plus `openrouterJudgeModel`, default
176
+ `typesafe/jev-router`). The native endpoint is region-blocked here (**HTTP 451**), and the failure shape
177
+ is silence rather than an error - 0 of 12,542 articles over the last 40 complete days carry a
178
+ `relevanceScore` - so the point of the switch is to route around a block, not to add a vendor.
179
+ Measured with the same prompt and the same validators against one real article: **`deepseek-chat`
180
+ passes in 1.0 s** (1,649 prompt + 69 completion tokens, cost on the order of ¥1/day for 500 articles),
181
+ `deepseek-reasoner` in 7.2 s, `qwen-plus` in 2.3 s.
182
+ **A passing contract is not the same judgement**: `deepseek-chat` scored that article relevance 1 with
183
+ an inclusion probability of 0.15 while `qwen-plus` scored it 2 and 0.85 - and the report's thresholds
184
+ were set against Jev, so switching judges means switching rulers. That is why the raw scores and
185
+ `usage.model` are stored; it also immediately paid off, since the served model for a `deepseek-chat`
186
+ request came back as **`deepseek-flash`**.
187
+
188
+
189
+ - **The decision model has a second transport, the region block is now a measured fact rather than a
190
+ guess, and the switch that uses it ships off.** `api.typesafe.ai` answers **HTTP 451 "not available in
191
+ your region"** from this machine, and the failure shape is silence rather than an error: of 12,542
192
+ articles across the last 40 complete days, **0** carry a `relevanceScore` - and the same client serves the
193
+ reranker, reply debt, drafted replies and route-card term selection, so the whole layer degrades together.
194
+ New config: `judgeProvider` (`native` default / `openrouter`), `openrouterApiKey` (encrypted at rest) and
195
+ `openrouterJudgeModel` (default `typesafe/jev-router`). One switch replaces the layer, which is exactly why
196
+ **the default stays `native`**.
197
+ **The region filter is per model, not per site** (measured): `typesafe/jev-router` and `:free` models pass
198
+ from here, while `openai/gpt-4o-mini` returns **403 "This model is not available in your region."** - so a
199
+ model swap has to be re-tested on the machine that will use it.
200
+ The contract moves to our side (the native endpoint returns structured answers; a chat model has to be
201
+ *asked* for them), so the prompt and four validators live in `jev_client.py` and drift raises `JevError`
202
+ instead of producing a plausible-looking default - including the two shapes that used to fail *quietly*:
203
+ `noul` arriving as a boolean (once read as "no" for every question) and `score` arriving as a string
204
+ (which raised `ValueError`, something the callers' `except JevError` could not catch). Cost is measured:
205
+ **$0.014** for one article with four topic choices on a call the router sent to `x-ai/grok-4.7` (1,535
206
+ reasoning tokens), and since the router chooses a different upstream per call, the worst case is what has
207
+ to be budgeted (**~$7 for a 500-article day**); `max_tokens` does **not** cap it. Four failures now say
208
+ four different things because they need four different fixes: **402** = credits, **403 + "region"** =
209
+ change the model (changing the key does nothing), **401** = auth, **empty content** = reasoning ate the
210
+ budget. **Off by default, and not usable on the key tested here**: `/credits` reports `total_credits: 0`
211
+ and calls are refused with 402.
212
+
213
+ - **The daily report can now be scheduled to run itself, and it knows what "yesterday" means.**
214
+ `daily` (and the `pipeline.py` it drives) gained `--yesterday`. A scheduled run has to be *the
215
+ previous day*, not "today": WeChat accounts keep publishing until midnight, so a same-evening run
216
+ silently ships a truncated day that nobody ever goes back to fix - while "yesterday" is
217
+ **time-insensitive** (any wake-up after midnight reads the same complete data, so a machine that
218
+ was asleep only needs to be allowed to start late, not have its date re-derived). Passing both
219
+ `--date` and `--yesterday` is an error rather than a silent pick, because then "which day" has no
220
+ single answer. `OPERATIONS.md` now carries a corrected recipe for the scheduled task - it uses
221
+ `Register-ScheduledTask` with `-StartWhenAvailable`, because `schtasks /create` cannot express
222
+ "start late if you missed it", and without that a sleeping machine means a **silently** missing
223
+ day.
224
+
225
+ - **`daily --prefetch-bodies [n]` (default off) fetches the day's article bodies concurrently
226
+ *before* the fetch phase.** That phase is serial and sleeps 8-12 s for every article that misses
227
+ the body cache - deliberate throttling, and on the order of an hour for a 450-article day - while
228
+ a **cache hit neither connects nor sleeps**. Warming the cache first turns the fetch phase into
229
+ cache hits. The **number of requests to upstream is unchanged**; only the window they are spread
230
+ over changes, which is exactly why this is opt-in rather than the new default. Measured on the
231
+ first real run (2026-10-16, a 523-article day): **516 bodies warmed in 10 minutes** and the whole
232
+ `biz_daily` step took **15m49s**, where the same articles serially with throttling take over an
233
+ hour. The scheduled task uses it, so a report that used to hold the machine for an hour finishes
234
+ in a fraction of that. (Related: the index does **not** read this cache - measured no gain, see
235
+ `docs/DECISIONS.md` D-118 - so the cache serves the display path (`search --with-text`) and this
236
+ speed-up only.)
237
+
238
+ ## 1.12.0
239
+
240
+ - **One word per question, across every artifact that describes exported media.** The auxiliary `*_media.json`
241
+ called a document entry `exported` when it meant "the local copy was **found**", while `manifest.json` used
242
+ `exported` for "it is **in the assets directory**" - two different questions wearing one word, and a copy
243
+ failure would have made "已导出" false. Now: the report answers *locatable in the source data* (`found` /
244
+ `missing`), the manifest answers *materialized in the export directory* (`written` = what is there now,
245
+ `copied` = what this run added, `unavailable` = not written, whether because the file is absent, oversized,
246
+ or the copy itself failed). `manifest.documents` gained the clearer `written` (it was `exported`) and the
247
+ CLI's JSON for all three branches (html / md / `--media`) is now produced by **one** function
248
+ (`media_json_summary`) instead of three hand-written blocks - so the formats cannot describe the same fact
249
+ differently, and the summary carries the report's file name everywhere (previously only the HTML branch did).
250
+ Pinned by tests: a file whose copy fails reports `found` in the report and `unavailable` in the manifest
251
+ (never "exported"), and the summary is asserted to carry counts, the report name, and **no absolute path**.
252
+
253
+ - **The article half of the semantic index was embedding YAML metadata, not article text.** `collect_articles`
254
+ found the body by looking for a `## 正文` marker; measured on 2026-10-15, **none** of the 10,759 daily `.md` files
255
+ in the default window contains it (the real shape is `frontmatter → # title → > source → --- → ## AI 摘要 →
256
+ --- → repeated title → body`), so every record silently fell back to `content[:1000]` - the frontmatter. Sampled
257
+ 300 of them: 100% contain `title:`, median CJK ratio **0.16**; the only content signal left was the title, which
258
+ is what "the article line ranks by title" actually meant. The extractor now reads the real structure (median CJK
259
+ ratio 0.16 → **0.80**, 299/300 files changed) and a test fails if it is reverted to the old fallback. Note that
260
+ an existing index does **not** pick this up by itself: an incremental build embeds new ids only, so it takes
261
+ `search-index --full`.
262
+ - **Building the index can be interrupted without losing work already paid for, and it says where it is.** A full
263
+ rebuild of ~16k records takes over twenty minutes while the CLI gave its subprocess **five** - so `--full` had
264
+ never completed through the CLI at all, and since nothing was written until the very end, a timeout threw away
265
+ the whole run. It now flushes every 100 batches (vectors first, then `meta.json`, each through a temporary file
266
+ and `replace`), so a killed run leaves a **valid** partial index and a plain re-run continues from there, because
267
+ the flushed ids are already in `meta.json`. Batch failures (measured: `IncompleteRead` from the embedding
268
+ endpoint) are now **excluded** instead of being written as all-zero vectors - such ids would otherwise sit in
269
+ `meta` forever, since an incremental build only ever embeds new ones - and the count is reported as
270
+ `embedFailed`. Progress lines (`[索引] 已嵌入 N/M`) go to stderr.
271
+ - **A mismatched index no longer degrades into "nothing found".** When `vectors.npy` holds more rows than
272
+ `meta.json` (a build killed between the two writes, or files edited by hand), the top-k indices ran past the end
273
+ of `meta` and the bare `except` in `search()` turned that `IndexError` into a silent fallback to keyword search -
274
+ a real query that presents as "no hits". The two are now truncated to the shorter one, which costs a few results
275
+ instead of the whole vector path.
276
+
277
+ - **`search --hybrid`: an opt-in second ranker, off by default because it was measured not to earn it.** The new
278
+ flag fuses the vector ranking with a fully local BM25 ranking (Chinese is tokenised into **character bigrams**,
279
+ so no segmenter and no word list; one pass over the indexed texts, no extra index file, ~31 ms for 16k records)
280
+ using Reciprocal Rank Fusion. It fixes one concrete failure - the rarest word in a question being overridden by
281
+ the most frequent one: `卫星遥感反演气溶胶光学厚度` ranks the satellite-based PM2.5 paper 3rd on vectors alone
282
+ and 2nd with the flag. But measured on the same day, it is **not better** on four of five realistic questions
283
+ (it pushed the correct first hit out of the top five for `有没有讲遥感或卫星数据用在空气质量监测上的文章`, and
284
+ `后向轨迹 HYSPLIT 怎么用` was dragged off by function words), and on 16 verbatim-fragment queries BM25 alone
285
+ scored 16/16 against the fusion's 12/16 - RRF gives both sides equal voting power, and Chinese function words turn
286
+ BM25 into noise. So the default stays as measured-better and the flag is for questions that really do contain a
287
+ rare term. Note it also makes the printed `score` (cosine) non-monotonic, because the order now comes from the
288
+ fused ranks. The decision and both A/B tables are in D-110; making it a default would first need idf-gated
289
+ voting and a re-measurement on the same harness.
290
+
291
+ - **`search --from <account>`: filter by source account, because that is an attribute query and vectors cannot
292
+ answer it.** Measured on the 2026-09-12..10-13 window (10,759 articles, 413 accounts): searching the account name
293
+ itself returns only 0-3 of the top 10 from that account for 红星新闻 (908 articles), 政事儿 (632) and 新京报 (529),
294
+ while the account name is present in **every** one of its records' `id` (the daily names files
295
+ `<account>-<title>.md`). The flag filters on that id as a substring (so `--from 红星` works), applies to the article
296
+ line only - a chat message has no source account - respects the same mask as the keyword side when `--hybrid` is on,
297
+ and says "the index has no records for this account" rather than returning an empty list silently. It gives the
298
+ right pool, not a better order within it: ranking inside an account is still semantic nearest-neighbour, so a
299
+ general-news account can return its own unrelated stories for a topical query.
300
+
301
+ - **A recall/precision evaluation harness now exists, and it immediately paid for itself: gated keyword fusion is
302
+ the new default.** `scripts/recall_eval.py` measures `search` with the standard metric set (Recall@1/5/10, MRR@10,
303
+ nDCG@10, Hit@1) plus a **coverage** count, and builds its ground truth at run time (a uniqueness-checked 12-character
304
+ fragment from the middle of a record's own text, and its title / first 30 characters) so no corpus text is committed;
305
+ hand-written questions go in an out-of-repo TSV (`question<TAB>anchor<TAB>line`). It requires `--yes` because each
306
+ question costs an embedding and leaves the machine, and it writes nothing. With that harness, the keyword side of
307
+ `--hybrid` gained two gates - only documents matching a **rare** term (df/N <= 5%) vote, their BM25 score must be
308
+ within 0.5x of the best, and a query with no rare term makes the keyword side **abstain** - and on the same three
309
+ batteries (40 fragment + 40 title + 12 hand-written questions) that turns the ungated version's zero gain into:
310
+ fragment recall@10 0.91 -> **1.00**, recall@5 0.89 -> 0.97, hit@1 0.60 -> **0.80**, title hit@1 0.83 -> 0.86, and
311
+ no change on the question battery. Nothing got worse, so **fusion is on by default** (`--no-hybrid` restores plain
312
+ vector order, and note the printed `score`, a cosine, is no longer monotonic). The ungated variant scoring exactly
313
+ like pure vectors is what explains D-110's earlier "harmful" measurement: the problem was function words voting,
314
+ not fusion. Threshold sensitivity is flat (0.02/0.7 and 0.10/0.3 bracket the same result), so the value is not
315
+ load-bearing; it must not be re-tuned on this battery.
316
+
317
+ - **The rerank half of the pipeline works again - it now falls back to the configured chat model, and its state is
318
+ visible in the output.** Rerank was never *running* on this machine: the Jev/Typesafe contract returns
319
+ `HTTP 451 not available in your region`, and the failure was only a stderr WARN, which the CLI does not forward -
320
+ so a search reported nothing and looked like "fine". `rerank_dispatch` now tries the contract first and, if that
321
+ client fails or is absent, reranks with the configured OpenAI-compatible chat model; both failing keeps the original
322
+ order, and `RERANK_STATE` (`provider` / `degraded` / `error` / `scored`) travels into the JSON so "not reranked" can
323
+ no longer be mistaken for "reranked". Passages are cleansed before they are sent (bold markers, 公众号 boilerplate,
324
+ bylines, image/link syntax), because the excerpt is only 300 characters. Measured on the same 40 questions: hit@1
325
+ 0.55 -> **0.65**, recall@5 0.65 -> **0.80**, MRR@10 0.59 -> **0.72**, nDCG@10 0.63 -> **0.74**, with the weakest
326
+ cell (verbatim fragment on the article line) going recall@5 0.30 -> **0.70** - in the same range the literature
327
+ reports for retrieve-and-rerank (MS MARCO nDCG@10 0.396 dense -> 0.452 with a reranker; a public example puts
328
+ hybrid-RRF 32.95 -> 36.16 with rerank). Two consequences worth knowing: **every search now spends one model call**
329
+ (~2-3k tokens, ~¥0.004, plus a few seconds) and the assistant, MCP and `chat` share this path, so `--no-rerank`
330
+ is the escape; and one regression is recorded rather than smoothed over - title/article recall@5 slipped 1.00 to
331
+ 0.90 (one case at n=10), whose fix (bigger candidate pool, or protecting the retrieval-side top hit) is not done.
332
+
333
+ - **The rerank candidate pool is now 100 by default, because the pool was the biggest lever all along.** Rerank can
334
+ only reorder what retrieval hands it: with a pool of 20, a correct document ranked 21st by the hybrid ranking could
335
+ never appear. Measured on the same 40 questions with only the pool changed - 20 / 50 / 100 / 200 gives recall@10
336
+ **0.82 / 0.88 / 0.95 / 0.95**, MRR@10 0.72 / 0.75 / **0.80** / 0.82, nDCG@10 0.75 / 0.78 / **0.83** / 0.85, and in
337
+ the weakest cell (verbatim fragment on the article line) recall@10 0.70 / 0.80 / **1.00** / 1.00 - so 100 is the new
338
+ default and 200 buys nothing measurable. Against published production thresholds (Recall@10 >= 0.90, MRR >= 0.75)
339
+ this now clears both. The pool is adjustable per query (`--rerank-pool`, CLI and script; when the flag is absent
340
+ nothing is forwarded, so the default lives in exactly one place - the first version parsed the commander option
341
+ unconditionally and **made a missing flag a fatal invalid argument**), and the cost is stated plainly: the rerank
342
+ prompt grows from ~2.5k to ~20k tokens (~¥0.01 per search, a few seconds). Two corrections to the previous entry:
343
+ the title/article recall@5 "regression" was **noise** (rerank is not perfectly deterministic even at temperature 0,
344
+ and each cell holds 10 questions, so one case is 0.10) - it re-measures at 1.00; and per-cell numbers at n=10 must
345
+ not be used to judge a single change.
346
+
347
+ - **Results are collapsed by identical text now, because multi-account reposts were eating slots.** Measured on the
348
+ current defaults (rerank with a pool of 100): **7%** of 100 top-5 slots held a duplicate of another item in the same
349
+ result list, and **6 of 20** questions showed one - e.g. the 环境监测新规 query had a five-department notice carried
350
+ by three accounts plus a meeting report carried by three more, so four slots went to two pieces of content. The
351
+ first (best-ranked) copy is kept and the others are counted into a new `duplicateCount` field, so the fact that
352
+ several accounts carried it stays visible instead of silently shrinking the list; `--no-dedup` returns the raw
353
+ list. This deliberately happens at presentation time and **not at index time**: the index keeps every copy because
354
+ `--from <account>` resolves an account through the record's `id`, and dropping copies there would make per-account
355
+ lookups lose records. It changes no recall metric (the ground truth is content) - only how much distinct material
356
+ one result list actually carries.
357
+
358
+ - **`search --with-text` returns the article itself, not the excerpt it was matched on.** What a search used to hand
359
+ back was the title plus the ~500-character slice that the embedding was computed from - which is not the content
360
+ anyone wants. The content is already local: the record's `id` carries the daily markdown's relative path, and the
361
+ fetch cache (`output/.cache/fetch/<md5(url)>.md`) holds the **full** body. The flag adds `fullText` (readable: bold
362
+ markers, image syntax and 公众号 boilerplate lines removed, paragraphs kept - deliberately different from the
363
+ 300-character, whitespace-collapsed excerpt the reranker receives), `sourceFile` (always given, so the raw file can
364
+ be compared against what was cleaned) and `url`; the longer of the daily markdown and the cache wins. Chat records
365
+ need none of this - the indexed text *is* the whole message. It is **off by default** for a context reason rather
366
+ than a cost one: the assistant, MCP and RAG share this `search()`, and ten results at 4,000 characters would push
367
+ ~20k characters into those paths. Unreadable records return `fullText: null` with `sourceFile` still set (never a
368
+ silent empty string). Measured on the demo query, the first hit goes from a 500-character excerpt to 1,723
369
+ characters of real content (citation, three highlights, abstract, and the results paragraph: R² 0.87/0.86/0.83,
370
+ AOD feature importance 40.63%); before image syntax was stripped it was 3,860 characters, ~2,100 of which was one
371
+ line of `![](https://mmbiz.qpic.cn/…)`.
372
+
373
+ - **`--fetch-missing` fills in a thin body by fetching the article, through the daily pipeline's own cache.**
374
+ Some daily markdown files are stubs (a "好文回顾" record held 201 characters), and `--with-text` could only hand
375
+ back that stub. With this flag, any record whose local body is under 300 characters **and** that carries a `url`
376
+ is fetched once; the result replaces `fullText`, is marked `fetchedFromUrl`, and is written to the **same** cache
377
+ the daily and backfill use (`output/.cache/fetch/<md5(url)>.md`, success-only, cache-before-network), so whichever
378
+ path fetches it, the other gets it free next time. It is off by default (a search should not generate upstream
379
+ requests on its own), capped (3 records unless a number is given), and only touches thin bodies - measured on one
380
+ query, a 201-character stub became 965 characters of abstract and findings while a 3,020-character neighbour was
381
+ left alone. Failures keep the local text and are reported rather than blanking the field. It deliberately does
382
+ **not** sleep between fetches the way the daily's batch path sleeps 8-12 seconds: that is politeness for hundreds
383
+ of articles, this is three on demand. Same caveat as the daily: the cache is the version fetched at that time, so a
384
+ revised source article will not be reflected.
385
+
386
+ - **MMR redundancy removal was built, measured, and left off by default - it removes useful material here.** The
387
+ new `search --mmr` flag re-orders the final results with Maximal Marginal Relevance (lambda 0.7, the value WeKnora
388
+ uses) and the evaluation harness grew a **diversity metric** (mean pairwise cosine inside the returned top-k). It
389
+ does what it says on the ranking side: diversity goes from 0.627 to 0.559 with recall@10 unchanged at 0.95. But
390
+ judged precision (same criterion and judge as the precision baseline: 10 questions, top-5 each) **drops** - strict
391
+ relevant 40% -> 30%, loose 68% -> 64% - and recall@5 slips 0.93 -> 0.90. The reason is worth keeping: in this
392
+ corpus the "duplicates" are mostly **several accounts carrying the same story**, i.e. multi-source confirmation
393
+ rather than noise, so pushing redundancy down starts deleting the very items that answer the question. The cheap
394
+ half of that problem is already handled by default same-text collapsing (D-114), which costs nothing and loses no
395
+ information. Tuning lambda is not the fix (larger just degenerates to plain relevance, smaller deletes more): the
396
+ direction is wrong, not the weight.
397
+
20
398
  ## 1.11.1
21
399
 
22
400
  - **Five defects in 1.11.0's document export, all found by looking at a real export instead of the code.**