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/docs/DECISIONS.md CHANGED
@@ -1406,6 +1406,656 @@ which is what the gate is for.
1406
1406
  semantics are not reliable here), as is any automatic labelling: there is no gold standard for "is this
1407
1407
  draft right", so the feature records what it judged and says so rather than claiming calibration.
1408
1408
 
1409
+ ## D-126: 排障输出必须**判定过**且**可整段复制**(`check --report`),脱敏是硬约束
1410
+
1411
+ **Status:** Active(起因:同一个根因在 2026-08~10 被报成了四种不同问题 —— issue #3「无法通过步骤4获取key」、
1412
+ #9「密钥解不开了」、#10「读不到近期记录」,以及 2026-10-18 那封「sessions 找不到会话」的邮件)
1413
+
1414
+ **决定。** `weflow-cli check --report` 输出一段给人(和 AI)读的文本,包含环境、微信客户端、配置三项事实,
1415
+ 以及**结论**:每条结论是「卡在哪一步 + 下一步运行什么」。三条约束:
1416
+
1417
+ 1. **判定,不是罗列。** 只把 `sqlcipher3: false` 打出来,用户仍然不知道这意味着什么;报告必须说
1418
+ 「缺 sqlcipher3,运行 `pip install sqlcipher3`」,并且**不要把"缺 sqlcipher3"报成"Python 不可用"**
1419
+ (那会让人去重装 Python,问题不变 —— 有测试钉住这两条不能混)。
1420
+ 2. **脱敏是硬约束。** 报告里出现的东西只有:布尔状态、版本号、路径(用户名换成 `<用户>`)、
1421
+ 命令名、依赖名。**密钥与口令的值连读都不读**(只传 `messageKeyConfigured: boolean`),
1422
+ 聊天内容从不进入;`wxid` 与密钥形态字符串由 `redactForReport` 兜底替换。原因是这份文本的
1423
+ 目的地是公开 issue / 第三方 AI —— AGENTS.md 明令禁止把这些放进去。
1424
+ 3. **纯函数生成。** `renderDiagnosticReport(input)` 不读文件、不碰配置,所以「结论对不对」「脱敏漏没漏」
1425
+ 都能用单元测试钉;`--json`(给机器)与 `--report`(给人)共用**同一份探测结果**,避免两个分支
1426
+ 出现「JSON 说可用、报告说不可用」。
1427
+
1428
+ **没做的(明写)**:不自动上传、不联网、不写入文件;不做通用 PII 扫描(过度打码会把
1429
+ "路径里有中文目录"这种真线索删掉)。报告不会替代日志 —— 具体命令的报错原文仍要用户附上,
1430
+ 报告末尾也是这么写的。
1431
+
1432
+ ## D-125: 运行时版本的第二观测:命令行没 `--user-lib-dir` 时看**实际映射的 `Weixin.dll`**
1433
+
1434
+ **Status:** Active(2026-10-18 本机实测:4.1.15.13 → 4.1.15.53 后主进程不再带该参数)
1435
+
1436
+ **Decision.** D-107 的判据保留(命令行 `--user-lib-dir` 是**首选**:那是客户端自己说的),
1437
+ 但**它不再是唯一来源**。命令行取不到时,取 `Weixin.exe` 进程实际映射的 **`Weixin.dll` 所在目录**;
1438
+ 两者都没有 ⇒ `version: null` + `note`,仍然**不**拿"磁盘上只有一个版本目录"去推断。
1439
+ `note` 必须写清版本来自哪个观测(`命令行里没有 --user-lib-dir,版本取自进程实际加载的 Weixin.dll`)。
1440
+
1441
+ **Reason.【实测】** 本机升到 **4.1.15.53** 后,`Weixin.exe` 主进程的命令行**只有一个可执行路径**
1442
+ (`"D:\WeiXin\Weixin.exe"`,没有任何开关),而 4.1.15.13 时至少有一个进程带 `--user-lib-dir`。
1443
+ 结果是 D-107 的判据在**新版本上恒返回"版本未知"**——`check` 看不出用户装的是哪一版,
1444
+ 而这正是它存在的理由(排障第一个要问的)。模块表给出的是同一件事的**另一种观测**:
1445
+ `Weixin.dll` 此刻确实映射在 `D:\WeiXin\4.1.15.53\Weixin.dll`。改后 `check --json` 报
1446
+ `version: 4.1.15.53`、`loadedDirs: [D:\WeiXin\4.1.15.53]`,并附一句来源说明。
1447
+
1448
+ **两条判据为什么要分开记来源。** 命令行与模块表量的是**同一个量**,不是两个独立证据:
1449
+ 命令行说 A、模块表也说 A 时,把它们混在一起去重会得到"两个目录指向不同位置"的假象,
1450
+ 版本号反而会丢。所以顺序是**先①后②**,且只在①为空时才看②;`note` 记录实际用的是哪个。
1451
+
1452
+ **边界(不猜的地方一个没少)。**
1453
+ - 模块表要**同用户、活进程**才读得到;读不到(权限/进程刚起)就是 `null`,不退回磁盘推断。
1454
+ - 模块表出现**多个**目录时照样不挑一个报版本(与命令行那条同一规则)。
1455
+ - 仍不读 `Weixin.exe` 里硬编码的版本字面量(D-107 的理由没变)。
1456
+ - 旁证一条,**不拿来推版本**:`--client_version` 的 token 从 `0xF2541F0D`(.13)变成
1457
+ `0xF2541F35`(.53)。这与 PROJECT_STATE 里 2026-10-11 解出的**低 16 位**规则一致
1458
+ (byte3 = 第四段 `0x35`=53;byte2 − 0x10 = 第三段 `0x1F−0x10`=15),是第三个吻合的样本;
1459
+ 但高 16 位仍是 `F254`、三个样本都是 `4.1.x`,**主次版本分不开**,所以代码照旧不拿 token
1460
+ 推版本号(模块表已经直接给出目录名,没有理由再走一条半解码的路)。
1461
+
1462
+ ## D-124: `judgeProvider=chat` 必须尊重 `aiEngine` —— 本地引擎的用户不许被静默送出境外
1463
+
1464
+ **Status:** Active(2026-10-18;7 条单测覆盖分派,**未**对真实 Ollama/LM Studio 跑过端到端)
1465
+
1466
+ **Decision.** `chat` 通路先读 `aiEngine` 再决定往哪发:
1467
+ `local`/`ollama` → `_utils.detect_local_engine()` 找到的本地服务(`provider='chat-local'`);
1468
+ `claude` → **明确拒绝**(这条通路只支持 OpenAI 兼容端点,要用云端判断请改 `judgeProvider=openrouter`);
1469
+ `deepseek` 或未配置 → 维持原样(`aiBaseUrl` 中转站或直连 DeepSeek)。
1470
+ **本地服务检测不到时返回 `None`,绝不退回云端**,只打一行 WARN 说明这批判断会走 LLM 路径。
1471
+
1472
+ **Reason.** D-120 引入 `judgeProvider=chat` 时,那条分支无条件读 `deepseekApiKey`。
1473
+ 它的语义是"用**配置里的**对话模型",而"配置里的"到底指谁只有 `aiEngine` 说得清。
1474
+ 所以对 `aiEngine=local` 的用户,一开 `judgeProvider=chat` 就把文章正文发去了 DeepSeek ——
1475
+ **界面上什么都没变**,这不是多花点钱,是用户明确选了不出境之后又被送出境。
1476
+ 这与仓库既有的边界("云端 AI 要保留显式权限边界")冲突,属于必须改的缺陷而不是偏好。
1477
+
1478
+ **Consequences / limits.**
1479
+ - 本地引擎的 `chat()` 只回字符串、没有 usage:`ChatJevClient` 在 `engine` 路径上把 `usage.model`
1480
+ 记为引擎自己的 `model`,契约校验(`_check_choice`/`_check_score`)与云端逐字同一条。
1481
+ - `claude` 不自动换成 `openrouter`:换厂商要用户自己按 key 与地区限制决定(D-120 的实测结论是
1482
+ OpenRouter 的地区限制逐模型生效,不能替用户默认)。
1483
+ - 单测用假 engine 验证分派与契约;**真实本地服务的时延与 JSON 稳定性未测**,
1484
+ 所以 OPERATIONS 里写的是"可用",不写"已验证质量"。
1485
+
1486
+ ## D-123: 重排的池子**不降级**(干净 A/B 否掉了"用池 20 换 5 倍速度"),并记一次混淆教训
1487
+
1488
+ **Status:** Active(2026-10-18 实测:五组 × 40 题;每组同题同种子)
1489
+
1490
+ **Decision.** `RERANK_CANDIDATES` 保持 **100**。重排那句 LLM 调用是单次查询 10 秒里的 ~9 秒,
1491
+ 但它是全流程**最大的质量杠杆**,把那 9 秒当作"可优化开销"是错的。
1492
+
1493
+ **Reason.【实测】** 五组结果(同 40 题、同种子):
1494
+
1495
+ | 组 | 池 | 重排机制 | hit@1 | recall@5 | recall@10 | MRR | nDCG |
1496
+ | --- | --- | --- | --- | --- | --- | --- | --- |
1497
+ | A | — | **不重排** | 0.550 | 0.650 | 0.750 | 0.594 | 0.630 |
1498
+ | D | 20 | llm | 0.675 | 0.800 | 0.825 | 0.733 | 0.756 |
1499
+ | **E** | **100** | llm | **0.825** | **0.925** | **0.950** | **0.863** | **0.884** |
1500
+ | B | 20 | llm(契约先失败) | 0.675 | 0.775 | 0.825 | 0.726 | 0.750 |
1501
+ | C | 100 | 契约(DeepSeek 满足) | 0.800 | 0.925 | 0.950 | 0.849 | 0.873 |
1502
+
1503
+ - **重排本身值很多**:A → E 是 hit@1 0.55 → 0.825、recall@5 0.65 → 0.925、nDCG 0.63 → 0.884。
1504
+ - **同一机制下池 100 全面胜出**(D → E:+0.15 / +0.125 / +0.128),所以**不能拿池子换速度**。
1505
+ - **B≈D、C≈E**:两种重排机制(noul 契约 / JSON 打分)在**同池**下几乎等价 —— 决定因素只有池子。
1506
+
1507
+ **这次差点犯的错(值得单记):** 第一轮 A/B 里 B=`llm`、C=`typesafe`,我差点把 B→C 的差距
1508
+ 读成"池子从 20 到 100 的收益"。实际那个差距**同时混了两个变量**:C 的契约恰好被
1509
+ `judgeProvider=chat` 的 DeepSeek 满足(`provider` 字段写的是契约名,不是谁在服务),而 B 的契约
1510
+ 概率和 0.94 被判失败、退了 llm。
1511
+ ⇒ **对照实验里,先把机制钉住再比参数**;状态字段要能区分"意图"与"事实"(`transport` 因此而生,见 D-122)。
1512
+ 另外我此前对用户说过"池 100 下重排没有可测增益"——那是把"契约段失败的 WARN"当成了"没重排",
1513
+ **同一类误读**;本次 A vs E 证明重排的增益很大。
1514
+
1515
+ **Consequences.**
1516
+ - 查询延迟(~10 s)以重排为主,**这是买质量的钱,不要动**。真要提速只能换更快的模型/更小的提示
1517
+ (清洗摘录已做过),而不是砍候选。
1518
+ - `--rerank-pool` 仍可手动调小(离线批处理、赶时间时),但**默认值不许改回 20**;
1519
+ `PoolTests` 钉着默认值,改它要带新数据。
1520
+ - 记录的口径更新:`OPERATIONS.md` 的"重排效果"一节换成上表(旧数字是池 20 时代,
1521
+ "hit@1 0.55 → 0.65"那组已经不适用)。
1522
+
1523
+ ## D-122: 检索的失败必须**显式**:`vector.used` / `vector.fallback` 进返回,`--mmr` 不再能崩
1524
+
1525
+ **Status:** Active(2026-10-18 代码审阅时读出来的两处,当天修 + 8 条测试 + 变异检查;全量 1067 绿)
1526
+
1527
+ **Decision.** `search()` 里那个**裸 `except: pass`** 换成 `except Exception as error`,并把
1528
+ "这次到底走没走向量、没走是什么原因"写进返回的 `vector` 字段(`{used, fallback}`);
1529
+ `vectors` 在 `try` 之前初始化为 `None`,fallback 里的 MMR 判空;每线条数在 `search()` 里
1530
+ **顺手算一次**(`_counts_from_meta`),`main()` 不再重复解析 meta。
1531
+
1532
+ **Reason.** 三件事是一起读出来的,性质不同:
1533
+
1534
+ 1. **静默降级是最贵的一类失效。** 向量路径任何异常(embedding 上游失败、numpy 不可用、meta 坏掉)
1535
+ 都会被那个裸 `except` 变成"只搜关键词",而返回的 envelope 里照样写着 `hybrid: true` ——
1536
+ **两种质量完全不同的结果在输出里长得一模一样**。这还让所有"快慢/好坏"的对比都可能落在
1537
+ 不同路径上(2026-10-18 我在性能普查里就差点基于它下结论)。
1538
+ 2. **一个真 bug**:`vectors` **只在 `try` 里赋值**,而 fallback 里的 `mmr_select(results, vectors, …)`
1539
+ 用它。异常发生在 `np.load` 之前时(`require_numpy()` 就炸),`--mmr` 会 **UnboundLocalError**。
1540
+ 3. **一处 1.2% 的浪费 + 一个漂移风险**:`search()` 已经解析了 meta,`main()` 又调 `line_counts()`
1541
+ 解析一遍(实测 69 + 56 ms)。两份解析同一份文件迟早会漂,而漂的表现是"条数对不上但谁都不报错"。
1542
+
1543
+ **Consequences.**
1544
+ - 裸 `except` 还会吞掉 `KeyboardInterrupt` —— 一个跑 30 分钟以上的日报里,Ctrl+C 可能不生效。
1545
+ 这次只改了最要紧的这一处,**其余 78 处没动**(数量上够做一条静态守卫,见 D-123 之后再说)。
1546
+ - **`--mmr` 在向量失败时现在直接跳过 MMR**(它需要矩阵),而不是崩;`fallback` 里会写明原因。
1547
+ - 测试纪律(这次最值得记的一条):第一版回归测试让 `get_embeddings` 抛错——**那时 `vectors` 已经赋过值**,
1548
+ 把 `vectors = None` 拿掉测试照样全绿。**变异检查当场证明了它是"绿着但没盯住东西"**,
1549
+ 于是补了一条在 `require_numpy()` 处失败的用例,再变异就红了。凡是要钉"未绑定/未初始化"这类问题,
1550
+ 失败点必须落在**赋值之前**。
1551
+ - `vector` 是**只增字段**,`hybrid` 的含义不变("融合开关是开的"),两者一起读才能判断这次的真实路径。
1552
+ - **`rerank.transport` 是同一类修正**:`provider` 只说明"走的是 Jev 那套契约",不说明**谁在服务**。
1553
+ 2026-10-18 的 A/B 里就撞上了:`judgeProvider=chat` 时契约由 DeepSeek 满足,状态里却写着
1554
+ `provider=typesafe`,差一点被读成"原生通了"(同一批查询的另一半退了 `llm`)。
1555
+
1556
+ ⇒ 凡是"状态里记了某件事"的字段,要问一句**它记的是意图还是事实**;记意图的字段在换通路之后
1557
+ 会安静地撒谎(同 D-034 记 `last_model` 的动机:实际服务的模型要与请求的别名分开)。
1558
+
1559
+ ## D-121: 第三条腿是"原样定位"(FTS5/trigram),不是又一个相似度打分
1560
+
1561
+ **Status:** Active(2026-10-17 本机实测:8 次探针定形状;真语料 15,995 条建索引 10.7 s / 96.9 MB;
1562
+ 6 条真查询;离线测试 19 条)
1563
+
1564
+ **Decision.** 新增 `search --phrase "<文本>"` 与 `search-index --phrase`。用 SQLite **FTS5 + `trigram`**
1565
+ 建一条**精确短语**索引(`output/.phrase_index/phrase.sqlite`),查询按**字面量**匹配,结果按
1566
+ (账号/会话 × 日期)分组给出——即"**哪个号在哪天原样写过这句话**"。索引**从 `meta.json` 派生**
1567
+ (语义索引的记录清单 + `load_record_text` 取读者看到的那份正文),不自己重收集语料。
1568
+
1569
+ **Reason.【实测】前两条腿都是"相似",答不了"原样"**:bigram+BM25 按词项计分、向量按语义近似。
1570
+ 专有名词、代码名(`std::vector`)、政策原文、引文核对这类问句要的是**原样**,把"查不到原样"
1571
+ 混进相似度排序里,用户看不出区别——那是比排错更坏的失效方式。四个实测结论决定了形状:
1572
+
1573
+ 1. **默认分词 `unicode61` 对中文完全不可用**(整句一个 token:`沙箱逃逸` 命中 **0**)⇒ 必须 `trigram`。
1574
+ 2. **trigram 是连续子串,不是词项 AND**:`光伏补贴政策` 只命中**原样连续**的那篇,而
1575
+ `补贴政策` 命中"的补贴政策""补贴政策解读"。这正是"原样定位"要的语义,**也正是它不能替关键词腿的原因**
1576
+ ——那条腿要的是"分开出现也能排上分"(实测 `光伏补贴政策` 在 1 万篇语料上是 0 命中)。
1577
+ 3. **trigram 有 3 字下限**:`光伏`(2 字)恒空 ⇒ 短查询必须**明说"查不了"**,绝不能回报"0 命中"
1578
+ (把"查不了"说成"没有"是这条腿最坏的失效方式)。
1579
+ 4. **查询里的标点会被当语法**:`PM2.5` → `syntax error near "."`、`GPT-4` → `no such column: 4`。
1580
+ 整串加引号当字面量(内部 `"` 翻倍)后,`PM2.5` / `C++` / `GPT-4` 全部正常,且 ASCII 大小写不敏感。
1581
+
1582
+ **"每篇文章一个表"被否掉(同一次实测)**:15,995 条 × FTS5 每表约 5 张影子表 ≈ **9.6 万张表**;
1583
+ 更要紧的是 `bm25()` 的**语料级统计**(df / avgdl)在单文档表上退化 —— 每个表只有 1 行,
1584
+ 排名失去意义;查询还得 N 次 MATCH 再自己合并名次,等于把倒排索引唯一的好处丢掉。
1585
+ 正解是**一条表装整个语料**(`rowid` 挂我们的记录 id,另存 line/label/date/title 几个 UNINDEXED 列),
1586
+ 这也是参考项目自己的做法(它的 architecture.md 写的就是一条 FTS 索引)。
1587
+
1588
+ **为什么从 `meta.json` 派生**:再写一套记录枚举,两个索引的语料迟早分叉,而"两个索引覆盖的不是同一批"
1589
+ 这种错**不会报**,只会让结果看起来少。代价是它依赖语义索引存在(没有就报错并给命令)。
1590
+
1591
+ **真语料实测**(15,995 条):`沙箱逃逸` 2 条(跨两个号)、`PM2.5` **58 条**(含聊天线,
1592
+ "孙天乐 2026-10-13 16:53 …那个 PM2.5 的")、`腾讯混元` 7 条(跨 7 个号)、`分布式光伏` 1 条;
1593
+ `std::vector` 与 `光伏补贴政策` 都是 **0 条** —— 与"原样"的预期一致(语料里真没这么写过)。
1594
+
1595
+ **Consequences.**
1596
+ - **纯本地**:不联网、不花钱、**不需要 key、不需要 `--yes`**(确认门是为出境设的)。CLI 预览里
1597
+ 写明了 `mayUseCloudEmbedding: false` —— 别让人以为"本地检索也要花 vector 的钱"。
1598
+ - 索引是**派生**产物(96.9 MB / 全量重建 10.7 s),**它自己不排期**:语义索引变了(新文章入库)
1599
+ 它就得跟着重建。这与"索引新鲜度"是同一个未决问题,不是两个。
1600
+ - `--from` 与向量那条路**同一口径**(文件名子串):所以命中的 `label` 可能与筛选词不同
1601
+ (号名常出现在标题里)。要精确按号,同一条 WHERE 里已经包含 `label` 精确匹配。
1602
+ - **明说边界:查不到原样 ≠ 没写过**(同义改写、全角/半角、正文里的断行都会漏)。这条腿回答
1603
+ "原样",语义那两条腿回答"意思上"——三者的分工要写在使用处,而不是让用户自己猜。
1604
+ - **未测的已知限制**:全角/半角不互认(`PM2.5` 查不到 `PM2.5`);大小写折叠只对 ASCII。
1605
+
1606
+ ## D-120: 决策模型的三条通路:原生 / 国内对话模型 / OpenRouter —— 默认原生,国内那条是给被地区封锁的机器的
1607
+
1608
+ **Status:** Active(2026-10-17 本机实测:key 有效性、8 次真实调用、契约用仓库自己的 `_check_choice`
1609
+ 验过;离线测试 34 条;`judgeProvider=chat` 已在本机打开)
1610
+
1611
+ **Decision.** 新增配置 `judgeProvider`,取值三条:
1612
+
1613
+ | 取值 | 走哪 | 需要什么 | 本机可用性【实测】 |
1614
+ | --- | --- | --- | --- |
1615
+ | `native`(**默认**) | `api.typesafe.ai` 的 `systemone` | `typesafeApiKey` | ❌ **HTTP 451 地区封锁** |
1616
+ | **`chat`**(推荐给本机) | 配置里**已有的**对话模型:`aiBaseUrl` 中转站 / `aiModel` / `deepseekApiKey` | 无需新厂商、新 key | ✅ `deepseek-chat` **1.0 s/篇**、契约通过 |
1617
+ | `openrouter` | OpenRouter 的 chat/completions | `openrouterApiKey` + `openrouterJudgeModel` | ⚠️ 契约通过但**该账号 0 额度**,调用被 402 拒 |
1618
+
1619
+ 外加 `openrouterApiKey`(加密落盘)与 `openrouterJudgeModel`(默认 `typesafe/jev-router`)。
1620
+ `create_client()` 按 `judgeProvider` 分派,两条 chat 路共用同一个 `ChatJevClient`(`base_url` 可换),
1621
+ 它与 `JevClient` 是**同一个鸭子类型**,所以**一处开关替掉整层**:日报判断、检索重排、草稿回复、
1622
+ 欠话台账、路由选词共用它。**默认 `native`,行为与引入之前完全一致。**
1623
+
1624
+ **Reason.【实测】**
1625
+ - **原生这条路在本机是死的**:`api.typesafe.ai` 返回 HTTP 451 "not available in your region"。
1626
+ 症状不是报错而是**静默退化**:最近 40 个完整天 12,542 篇里 **0 篇**带 `relevanceScore`、
1627
+ `decisionModel` 全空;同层的重排/欠话/草稿跟着一起退化。
1628
+ - **"中国地区可用的类 Jev"就在配置里**:拿同一份提示词与同一套校验问三个国内模型,**契约全过**——
1629
+ `deepseek-chat`(1.0 s、1649/69 tokens)、`deepseek-reasoner`(7.2 s)、`qwen-plus`(2.3 s)。
1630
+ `qwen-turbo` 撞的是账号速率限制(429),不是地区。所以这条路**不需要新厂商、新 key**,
1631
+ 一句话 `config set judgeProvider chat` 就打开;成本按 tokens 估是 **¥1/天量级**(500 篇,见下)。
1632
+ - **⚠️ 契约一致 ≠ 判断一致**:同一篇文章 `deepseek-chat` 给 relevance=1 / 收录概率 0.15,
1633
+ `qwen-plus` 给 **2 / 0.85**。下游阈值(0.5/1.5、0.50)是对着 Jev 拍的,所以换判断器要当成
1634
+ **换了一把尺子**——这也是 `relevanceScore`/`includeScore`/`usage.model` 都要落盘的用处。
1635
+ - **实际服务的模型可能与请求的别名不同【实测】**:请求 `deepseek-chat`,`usage.model` 回的是
1636
+ **`deepseek-flash`**。这正是 D-034 那条"落盘实际服务的模型"在起作用——不然"线上跑的不是测过的那个"
1637
+ 永远不可见。
1638
+ - **地区限制是逐模型的 —— "能调一个"不等于"能调所有"**:OpenRouter 上
1639
+ `typesafe/jev-router` 与 `:free` 模型能通,而 `openai/gpt-4o-mini` 返回
1640
+ **403 "This model is not available in your region."**(失败在路由的 "Gate Endpoints with Geo Restrictions")。
1641
+ 换模型必须在本机重测一次——这条规矩是被那次 403 换来的。
1642
+ - **契约改由我们自己保证,这是换通路真正的代价**:原生是服务端直接给结构化答案
1643
+ (`{probabilities, choice}` / `{score}` / `{noul}`),chat 模型要**被要求**才会给,所以提示词写死
1644
+ 输出形状、客户端逐条验。四条概率校验复用原生那套 `_check_choice`,另加 `_check_score`
1645
+ 与 `_check_noul`——**noul 是概率不是布尔**,这个坑仓库踩过(把浮点当布尔读,每问都读成"否")。
1646
+ - **OpenRouter 的成本【实测】与路由的不确定性**:一次调用路由到 `x-ai/grok-4.7`,花 **$0.014**,
1647
+ 其中 `reasoning_tokens` **1535**(比正文还长);另一次路由到 `z-ai/glm-5.3-flash`。
1648
+ **路由到谁每次可能不同**,所以只能按最坏估:**一天 500 篇 ≈ $7/天**,
1649
+ 而且 `max_tokens` **管不住**它(实测设 600、实际返回 1598)。
1650
+ - **那个 OpenRouter key 的账号没有额度【实测】**:`/credits` 报 `total_credits: 0`,请求被 402 拒
1651
+ ("you can only afford 6666 tokens")。所以 **OpenRouter 那条现在打不通**,接好了但没打开。
1652
+
1653
+ **Consequences.**
1654
+ - 一个开关同时改五处消费者,所以默认必须是 `native`;打开前要知道成本量级
1655
+ (`chat` ≈ ¥1/天,`openrouter` 最坏 ≈ $7/天)。
1656
+ - 四种失败的处置完全不同,所以报错分开说:**402=额度**、**403+"region"=地区(换模型才有用,不是换 key)**、
1657
+ **401=鉴权**、**空正文=推理吃光预算(调大 `OPENROUTER_MAX_TOKENS`)**。
1658
+ 混在一起会让人去换 key,而那条路上换 key 一点用都没有。
1659
+ - 契约漂移**当场抛** `JevError`(不放行、不给默认值):失效方式若是"形状变了但看着正常",
1660
+ 下游读到的就是错的判断。
1661
+ - **不许做的推断**:不能说任两条通路的判断一致——原生在本机调不通**无法对拍**,而两条 chat 路之间
1662
+ 实测就已经不一致(见上)。能声明的只有"**契约成立、分数落盘、谁判的可查**"。
1663
+ - **判断质量仍未定标**:换了尺子之后,"这个 relevance 值对不对"没有金标准可校。要做的是
1664
+ `quality_eval.py sample` 抽 50 篇人工标一次(欠着的旧账),而不是拿三家模型互相投票当答案。
1665
+ - 另一条教训(与代码无关但值得留):那封自称给 $25 额度的推广邮件里,产品(Drex / nace.ai)
1666
+ 在 OpenRouter 的 **466 个模型里不存在**,账号里也**没有**积分。核实靠官方接口,不靠邮件。
1667
+
1668
+ ## D-119: 日报的排期形状:跑"昨天"、允许补跑、抓之前并发预热正文缓存
1669
+
1670
+ **Status:** Active(2026-10-16 落地;本机任务 `WeFlow Daily` 每日 08:20 跑 `pipeline.py --yesterday
1671
+ --prefetch-bodies`,并用 `Start-ScheduledTask` 实跑过一次)
1672
+
1673
+ **Decision.** 四件事一起定,它们是同一个问题的四个面:
1674
+ 1. 计划任务跑**昨天**(新旗标 `--yesterday`),不是"今天";
1675
+ 2. 用 `Register-ScheduledTask` + `-StartWhenAvailable` 注册,**不用** `schtasks /create`;
1676
+ 3. 抓取之前先**并发预热正文缓存**(新旗标 `--prefetch-bodies [n]`,**默认关**);
1677
+ 4. 任务的动作指向 `scripts/pipeline.py`,**不指向** `weflow-cli` 这个 npm 包。
1678
+
1679
+ **Reason.**
1680
+ - **根因是"没人跑",所以这不是优化而是补缺。** 【实测】本机 2026-10-16 复核 372 个日期目录:
1681
+ **13 天有网页/图片**(`08-24`~`09-05` 的 12 天 + 本次刚产出的 `10-15`)+ **358 天只有 `.articles.json`**。
1682
+ 即**完整日报这条线从 2026-09-05 起暗了 40 天**:`10-07`~`10-13` 那些"看着有"的天都是回填形态
1683
+ (README 自己写着"未下载图片、未调用模型",没有 `index.html`),而 `10-14`~`10-16` 连目录都不存在。
1684
+ 计划任务里除了两个 watcher(且都已 Disabled)没有任何跑日报的任务——与 D-011 那条"不跑就不会发生"
1685
+ 完全一致。**这里更正一处旧口径**:09-28 曾被当成"最后完整的一天",其实它也是回填形态;
1686
+ "有 `.articles.json` + README"正是 **D-060** 警告过的"文件存在 ≠ 这天完整"。
1687
+ - **为什么是"昨天"**:公众号发到 24:00 才停,所以**当晚跑的那一天永远不全**,而且以后没人回头补它;
1688
+ 反过来"跑昨天"这个动作**与时刻无关** —— 00:00 之后任何时间跑,读到的都是同一天的完整数据。
1689
+ 于是最常见的漏跑(机器在睡觉)只要任务允许补跑,醒来补上就**结果完全相同**。
1690
+ 【实测】22:26 手动触发 `--yesterday`,它产出的是 `2026-10-15`。
1691
+ - **为什么不是 `schtasks /create`**:它无法表达"错过就尽量补跑"(那在任务 XML 里是
1692
+ `<StartWhenAvailable>`)。没有它,机器 08:20 在睡觉 = 这天**静默**没有日报。本机现成的
1693
+ 反例就在眼前:`PilgrimIntelDaily` 是 `StartWhenAvailable=True` 且 `LastTaskResult=0`,
1694
+ 而另两条任务的 `LastTaskResult=2`(**找不到文件**)—— 仓库那次搬迁之后没人看过它们的上次结果。
1695
+ - **为什么指向 `pipeline.py` 而不是 CLI**:npm 全局那个包是**发布版**、滞后于仓库;更要紧的是
1696
+ 它一旦是构建产物,"改完代码没 `npm run build`"就变成"任务跑的还是旧逻辑",属于静默失败。
1697
+ python 层没有构建步骤,是同一份源码。
1698
+ - **并发预热的取舍**:抓取阶段串行且未命中缓存时 `sleep` 8~12 秒(节流是为了少打扰上游),
1699
+ 450 篇约 75 分钟;而**命中缓存既不联网也不 sleep**。预热把这段换成"先按 8 并发抓一遍",
1700
+ 对上游的**请求次数完全不变**,只是从"一个多小时里慢慢发"变成"更短时间里发完"(瞬时并发更高)。
1701
+ 正因为它改变了"日报故意串行节流"这条既有边界(D-108 记过),所以**默认关、只能显式打开**。
1702
+ 【实测 2026-10-16 首次真跑(523 篇的那天)】预热 **516 篇 10 分钟**(前 6 分钟约 75 篇/分钟,
1703
+ 最后 8 篇慢的拖了 4 分钟),整天 `biz_daily` **15 分 49 秒**(22:26:21 → 22:42:10);同样篇数
1704
+ 串行 + 节流要一个多小时。也就是说这一步的收益是**约 90 分钟 → 16 分钟**。
1705
+ - **为什么不用 `backfill_articles --prefetch-all` 来预热**:那个开关会**产出同一天的文件**
1706
+ (md/`.articles.json`/README),与 `daily` 抢同名,而两条路径的产物形态并不相同。写过、测过、
1707
+ **撤掉了**:预热这事该由"要用这份缓存的那一方"自己做(`biz_daily.prefetch_bodies`),
1708
+ 否则就是给一个不冲突的需求加一个会冲突的工具。
1709
+ - **别把缓存读成检索收益**:让索引吃完整正文实测**没有增益**(D-118),这份缓存只服务展示路径
1710
+ (`search --with-text`)与日报自己的耗时。
1711
+
1712
+ **Consequences.**
1713
+ - 任务动作里**没有** `--yes`(确认门在 CLI 层,python 层没有)。也就是说**能改这个仓库的人**
1714
+ 就能让日报在 08:20 跑起来并调云端模型:单用户机器上可接受,换机器要重新评估。
1715
+ - 与 D-011 不冲突:那条兜底是"不带日期时先补昨天",`--yesterday` 是显式指定,不走兜底。
1716
+ - `--date` 与 `--yesterday` 同时给会**当场报错退出**,而不是静默选一个(两个答案并存时"哪一天"
1717
+ 没有唯一解)。
1718
+ - 排期这一节的运行方法写在 `OPERATIONS.md`「让它每天自己跑」,含怎么读 `LastTaskResult`
1719
+ (`267009` = `0x41301` = **正在运行**,不是错误)。
1720
+
1721
+ ## D-118: 索引**不**吃完整正文 —— 我提过改,量完撤回了(自造题裁判不了这件事)
1722
+
1723
+ **Status:** Active(2026-10-16 实测;102 条自造题 + 一次 40 篇的离线重嵌)
1724
+
1725
+ **Decision.** 建索引继续吃**日报 md 那份 500 字摘录**(`collect_articles` 里 `body[:500]`);
1726
+ 正文缓存只服务**展示路径**(`--with-text` / `--fetch-missing`)。`best_article_body` 这个"取更长那份"
1727
+ 的函数**只给展示路径用**,索引不碰它 —— 并有一条**反向**测试(`IndexBodyTests`)钉住这件事。
1728
+
1729
+ **Reason 【实测】: 加厚正文对检索没有可测的增益。**
1730
+ - 背景:日报 md 里正文中位 **259 汉字**,而日报**自己抓到的**完整正文缓存里是 **2,063 汉字**;
1731
+ 索引嵌的是前者。于是很自然地怀疑"素材薄 ⇒ 检索差",并打算重建一次索引、让单条预算从 500 提到 2,000。
1732
+ - 做法(不动索引、不花钱重建):挑出"当前文本薄、但本地已有更厚缓存"的文章,只用完整正文**重嵌一遍**
1733
+ (旧中位 229 字 → 新中位 2,020 字),在**同一批目标**上比新旧名次(102 条题)。
1734
+ - 结果:唯一**中立**的一组(标题 —— 两份文本里都有它)**持平**:recall@1/@5/@10 = 35/39/40 → 33/40/40。
1735
+ 两组碎片题各自跟着**自己的偏置**走、方向相反:碎片取自薄文本时 16/21/29 → **7/16/18**(大跌),
1736
+ 取自厚文本时 2/4/8 → **4/7/8**(微升)。逐题 17 好 / 31 差 / 54 平。
1737
+ - **方法上的结论比数字本身重要**:题是从**被索引的那份文本**里抠出来的,所以**自造题裁判不了"索引吃哪份文本"**
1738
+ —— 换了索引文本,题也跟着换。要裁判它需要一份与索引无关的手写问题(`--queries-file` 已经支持),
1739
+ 而且问题要问到"正文里才有、标题和摘要里没有"的东西。这个电池**这次没做**。
1740
+ - 【未知】因此**不能**说"完整正文对检索有害";能说的只是"**没有可测的增益**",
1741
+ 而它不足以为此付一次完整重建(≈¥2.8、20 分钟)外加索引内部不再一致(有缓存的厚、没缓存的薄)。
1742
+
1743
+ **Consequences.**
1744
+ - 批量预填正文缓存(`backfill_articles --prefetch-all`)**不再为索引而做**。为展示而预填是另一回事,
1745
+ 但 9,163 篇 × 约 4 小时买不到索引收益,所以**不做全量预填**;展示走 `--fetch-missing` 按需抓一次 + 永久缓存。
1746
+ - 撤销 2026-10-16 我提出的"让索引用完整正文"这条建议(同 D-110 那次"砍综合新闻号",
1747
+ 也是我先提、量完自己撤回)。D-109/D-111 里的召回数字都是在**薄正文**索引上量的,**不需要重测**。
1748
+ - `IndexBodyTests` 反向钉住这条:它红了 = 有人又改了索引吃哪份文本,那时必须带上新的证据(手写问题电池)。
1749
+
1750
+ ## D-117: MMR 做了,但**默认关** —— 它压掉的"重复"里有相当一部分是有用的
1751
+
1752
+ **Status:** Active(2026-10-16 实测;同批 40 题看排序,另 10 题 × 5 条走判官看准确率)
1753
+
1754
+ **Decision.** 加 `--mmr`(Maximal Marginal Relevance,**λ=0.7**,照 WeKnora 的取值)与配套的多样性指标,
1755
+ 但**默认关**。理由是量出来的,而且方向与预期相反。
1756
+
1757
+ **Reason: 在这份语料上,"多样性"与"有用"不是一回事。**
1758
+ - 排序侧(同批 40 题、池 100):多样性确实改善 —— 最终 top-10 两两余弦均值 **0.627 → 0.559**;
1759
+ recall@10 不变(0.95),hit@1 0.72 → 0.75(噪声量级),**recall@5 0.93 → 0.90**(一格一个用例)。
1760
+ - **准确率侧(判官,10 题 × top-5 = 50 条):掉了。** 严格相关 **40% → 30%**,宽松(含 partial)**68% → 64%**。
1761
+ - 为什么:这份语料里的"重复"常常是**多个公众号转载同一件事**(多源确认,不是噪声)—— MMR 压掉的正是
1762
+ 这些条目,而判官把它们算作相关。所以它治不了"top-5 里邻居太多"这个抱怨:那个问题的另一面是
1763
+ "同一件事被几个号发过",而**那一半已经由"同文本折叠"(D-114)吃掉了**(折叠是免费的、不损失信息)。
1764
+ 再往前压就得开始删有用的东西 —— 实测正是如此。
1765
+
1766
+ **Consequences.**
1767
+ - `--mmr` 与 `--no-dedup` 是两个层次:折叠同文本(默认开)、压近似(默认关)。
1768
+ - λ 不是这里的问题:λ 调大就退化成纯相关性(等于不 MMR),调小只会删得更多。**方向不对,不是权重不对**,
1769
+ 所以没有"再调一调"的余地 —— 要它有用,得先有"同一件事的不同角度值得区分"的证据,而这份语料里没有。
1770
+ - 判官口径本身有边界:它逐条独立判、看不到重复,所以"多源确认"在它眼里和"一条新信息"等价。
1771
+ 这个偏差对 MMR 不利,但也**不改变结论的方向**(宽松口径同样掉)。
1772
+
1773
+ ## D-116: `--fetch-missing` 按 url 补正文,复用日报的缓存与判据
1774
+
1775
+ **Status:** Active(2026-10-16 实测:`XGBoost-SHAP 陕西 PM2.5` 的第一条 201 字 → **965 字**)
1776
+
1777
+ **Decision.** `--with-text` 之外再加 `--fetch-missing [N]`(默认关,`const=3` = 不写字数时最多抓 3 条):
1778
+ **本地正文短于 300 字、且有 `url`** 的那些,按 url 抓一次;抓到的写回 `fullText` 并标 `fetchedFromUrl`。
1779
+ 抓取**复用日报那套** `biz_daily.fetch_article_cached`(同一份缓存 `output/.cache/fetch/<md5(url)>.md`、
1780
+ 只缓存成功、"先看缓存再上网"),不另写一份。抓失败就保留本地那份,并把条数报出来。
1781
+
1782
+ **Reason: 薄的正文不该逼用户自己放弃,但也不该默认去打扰上游。** 实测那条的日报 md 只有 201 字
1783
+ (好文回顾类文章 md 本身就短),抓回来是完整摘要与结论(2013—2022 年陕西 PM2.5 年均浓度从 56.02 µg/m³ 降下来、
1784
+ 主控因子按高程/相对湿度/温度/人口密度排序)。三条边界是**故意的**:
1785
+ - **默认关**:检索不该默认产生对上游的请求(`docs/NEXT_ACTIONS.md` 点过名的规矩);
1786
+ - **有上限**:一次最多抓 3 条,避免"一次检索打十个请求"。与日报的 8–12 秒批量节流不同 ——
1787
+ 那是几百篇的礼貌,这是几条的按需,**这个差异是刻意的**,不是漏了节流;
1788
+ - **只抓薄的**:本地已经 3,020 字的那条在这趟里没被碰(实测验过)。
1789
+
1790
+ **Consequences.**
1791
+ - 同一份缓存被日报/回填/检索共用:检索抓过的,日报重跑时直接命中;反过来也一样。
1792
+ - 与日报一样,抓来的正文只有"当时那一版"——源站改稿不会反映到缓存里(要新就删那条缓存重抓)。
1793
+ - 顺手把"预览时标签不可点/微信扫一扫/关注该公众号"这类尾巴补进了整行套话清单:抓回来的那篇结尾挂着它们。
1794
+ 清单只删**整行**,不动正文句子。
1795
+
1796
+ ## D-115: `search --with-text` 返回**正文**,而不是"用来匹配的那段摘录"
1797
+
1798
+ **Status:** Active(2026-10-16 用户指出:只返回标题/分数/摘录"没什么具体意思",要具体到某篇的内容)
1799
+
1800
+ **Decision.** `search` 加 `--with-text [N]`(N = 每条最多多少字,默认 4000,`0` = 不开)。打开后每条结果多三个字段:
1801
+ `fullText`(**能读的正文**)、`sourceFile`(本地日报 md 的路径)、`url`(原文链接)。正文有两处来源,取**更长**的那份:
1802
+ - 日报 md:`output/biz-daily/<id 里那段相对路径>`,正文用同一套 `extract_article_body` 取;
1803
+ - **正文缓存**:`output/.cache/fetch/<md5(url)>.md` —— 日报/回填抓来的完整正文,通常比 md 更全。
1804
+ 聊天记录没有这两层:**索引里那段本来就是消息全文**。
1805
+
1806
+ **默认不开的理由是上下文不是小气**:助手、MCP、RAG 走的是同一个 `search()`,10 条 × 4000 字会把这些通路
1807
+ 一次撑进 2 万字;要看正文就显式开(`--top-k 3 --with-text` 是最常用的组合)。
1808
+
1809
+ **读不出来要说出来。** `load_record_text` 对读不到的文件返回 `None`(不是空串),`attach_full_text` 把条数
1810
+ 数出来;`sourceFile` 仍然给出路径 —— 用户能自己去开那个文件,而不是以为"这篇没有正文"。
1811
+
1812
+ **`readable_body` 与重排那份摘录的区别(别混)**:`cleanse_passage` 是喂模型的(压成一整行、截 300 字);
1813
+ `readable_body` 是给人看的(去加粗标记、去图片语法、把链接留成文字、删"点击蓝字/在小说阅读器读本章"这类
1814
+ **整行**套话),但**保留段落**。原始文件路径在 `sourceFile` 里,想核对"有没有被我改过"就打开它对比。
1815
+
1816
+ **实测(`用卫星遥感估算 PM2.5` 的第一条)**:日报 md 11.7 KB → 正文 1,393 汉字、正文缓存 1,462 汉字;
1817
+ 整理后 `fullText` 1,723 字,含标题、作者期刊、引用格式、三条亮点、导读、**研究结论**(三重交叉验证
1818
+ R² 0.87/0.86/0.83、AOD 特征重要性 40.63%)与相关文章列表。去图片语法前那一版是 3,860 字,其中约 2,100 字
1819
+ 是 `![](https://mmbiz.qpic.cn/…)` 拼成的一整行 —— 所以必须去。
1820
+
1821
+ ## D-114: 结果侧折叠同文本转载(默认开),而索引侧**不**去重
1822
+
1823
+ **Status:** Active(2026-10-16 实测:20 条问句的 top-5 里 7% 是同文本转载,6/20 条问句的 top-5 出现过重复)
1824
+
1825
+ **Decision.** 取出结果时按**正文文本**折叠:同一段文本只留排名最高的那条,其余按条数记进
1826
+ `duplicateCount`(用户该知道"同一篇被几个号发过",而不是以为丢了什么);`--no-dedup` 关掉。空文本的
1827
+ 记录各算各的(空串之间不是"同一段")。
1828
+
1829
+ **Reason: 转载占的是名额,不是信息;但索引侧不能去重。** 实测两次都指向同一件事:
1830
+ - 2026-10-15 那次量到索引里 15,995 条只有 15,324 段不同文本(671 条重复);
1831
+ - 2026-10-16 在新默认(重排 + 池 100)下重测:100 个 top-5 名额里有 **7 个**是重复文本,**6/20 条问句**
1832
+ 的 top-5 出现过重复(例如"环境监测新规"那句,五部门通稿被 3 个号发、推进会也被 3 个号发 → 一次检索
1833
+ 里 4 个名额是副本)。
1834
+ 而**不在建索引时去重**的理由是具体的:`--from <公众号>`(D-109 之后那条属性筛)靠的是 `id` 里的文件名,
1835
+ 把被折叠的账号从索引里删掉,按号查就会缺记录。所以:**索引留着全部,折叠只发生在展示**。
1836
+ 所以这一条**不改变 recall 指标**(ground truth 是内容,副本折叠不影响首条的 rank),只让"实际看到的东西"
1837
+ 变多。这也是为什么它是纯展示层改动、不需要重建索引。
1838
+
1839
+ **Consequences.**
1840
+ - 每条结果多一个 `duplicateCount` 字段(只增字段,不改既有字段语义);CLI JSON 里也带 `dedup` 开关状态。
1841
+ - 与 `--dedup`(评测台的那个)**不是一回事**:评测台的 `--dedup` 是**排名前**折叠,用来量"去重后的名次";
1842
+ 生产这一条是**取出结果时**折叠。两者顺序不同、目的不同,别混。
1843
+ - 想回到"原样返回所有副本":`--no-dedup`。
1844
+
1845
+ ## D-113: 真正的杠杆是"重排能看到多少候选" —— 池子 20 → 100,并更正 D-112 那处倒退
1846
+
1847
+ **Status:** Active(2026-10-16 实测;同一批 40 题、同 seed,只改重排候选池)
1848
+
1849
+ **Decision.** 重排候选池(`RERANK_CANDIDATES`)默认从 **20 提到 100**,并把它做成可调:Python 侧
1850
+ `--rerank-pool`、CLI 侧同名旗标(**没传就不转发**,默认值只在 Python 侧定义一处)。曲线:
1851
+
1852
+ | 池子 | recall@10 | recall@5 | hit@1 | MRR@10 | nDCG@10 | 碎片/文章 recall@10(最弱那格) |
1853
+ | --- | --- | --- | --- | --- | --- | --- |
1854
+ | 20(旧默认) | 0.82 | 0.82 | 0.65 | 0.72 | 0.75 | 0.70 |
1855
+ | 50 | 0.88 | 0.88 | 0.68 | 0.75 | 0.78 | 0.80 |
1856
+ | **100(新默认)** | **0.95** | **0.93** | **0.72** | **0.80** | **0.83** | **1.00** |
1857
+ | 200 | 0.95 | 0.93 | 0.75 | 0.82 | 0.85 | 1.00 |
1858
+
1859
+ **Reason: 重排只能重排它拿到的那些。** 之前的提升假象是"融合 + 重排",而这两个都只在**召回交上来的候选**
1860
+ 里做文章:池子 20 意味着正确答案只要在融合排名 21 名开外就永远进不来。把池子放到 100 之后,recall@10
1861
+ 就到 **0.95**、MRR **0.80** —— 对照公开的生产参考阈值(Recall@10 ≥ 0.90、MRR ≥ 0.75),这一项**越过了**。
1862
+ 100 → 200 只剩噪声级增益(hit@1 +0.03、@10 不动),所以取 100,并且**不许**拿这一批题继续往上调。
1863
+
1864
+ **代价**:每次重排的提示从 ~2.5k tokens 涨到 ~20k(deepseek 系约 ¥0.01/次,几秒延迟)。
1865
+ 嫌贵就把池子调小(`--rerank-pool 20`),这比 `--no-rerank` 温和。
1866
+
1867
+ **更正 D-112 的一处记录。** D-112 记了"标题/文章那格 recall@5 从 1.00 掉到 0.90,一处倒退"。重跑同一批题后
1868
+ 那一格是 **1.00**(hit@1 也有 0.80/0.90 两次数值不同)——**那是噪声**:重排温度虽为 0,模型仍不完全确定,
1869
+ 而每格只有 10 条题,一条就是 0.10。结论不变("不能据此说重排有害"),但当时那句话说重了:**该格没有倒退**。
1870
+ 教训写在这里:n=10/格的分格指标不要用来判单点得失,要看整体与最弱格的方向。
1871
+
1872
+ **踩到的坑(值得记)**:CLI 侧先用 `parseCliInteger(opts.rerankPool, ...)` 把没传的选项也解析了一遍 ——
1873
+ commander 对未提供的选项给 `undefined`,于是**用户不传这个旗标就会被自己的校验判成非法参数**
1874
+ (`rerank-pool 必须是 5-500 的整数`,搜索直接不工作)。改成"没传就不转发"。这也说明:**默认值只该有一处**。
1875
+
1876
+ ## D-112: 把"召回 + 重排"的第二段点亮 —— 重排改用本机可用的模型,并给出降级状态
1877
+
1878
+ **Status:** Active(2026-10-15 实测:同一批题加上重排,hit@1 0.55→0.65、nDCG@10 0.63→0.74)
1879
+
1880
+ **Decision.** 重排这一段**恢复工作**,但换一个真正能连上的提供方:先按原契约(Jev/Typesafe)试,
1881
+ 失败或不可用则退到**配置里的对话模型**(OpenAI 兼容,本机是 deepseek 系);两条都失败就保持原序,
1882
+ 并把 `RERANK_STATE`(`provider` / `degraded` / `error` / `scored`)**带进 JSON 输出**。同时借 WeKnora
1883
+ 的做法,在送进模型前做**段落清洗**(去 `**加粗**`、公众号套话、署名行、图片/链接语法)——
1884
+ 候选摘录本来只有 300 字,不清洗会有一大半注意力花在噪声上。
1885
+
1886
+ **Reason: 主流管线是两段,而我们的第二段一直是黑的。** 本机 Typesafe 返 `HTTP 451 not available in your
1887
+ region`,于是"重排"只在 stderr 打一行 WARN(CLI 还不转发子进程 stderr)——查不到、也没生效。参考口径:
1888
+ - MS MARCO nDCG@10:BM25 **0.184** → 稠密 **0.396** → 稠密+重排 **0.452**;
1889
+ - 一个公开的 retrieve&rerank 例子(NanoNFCorpus):混合 RRF nDCG@10 **32.95** → 混合 RRF+重排 **36.16**,
1890
+ MRR 50.19 → 55.77;
1891
+ - 中文域:bge-reranker-large 把 Top-10 准确率从 80.7% 提到 **91.2%**;
1892
+ - 生产参考阈值(腾讯云):HitRate@3 ≥ 90%、Recall@10 ≥ 0.90、MRR ≥ 0.75、nDCG@5 ≥ 0.80。
1893
+ - 反面也记一笔:有文献报告重排器**会倒退**(jina-reranker-v2-base 在 BM25 形状的候选池上 −7.5pp),
1894
+ 所以"加了重排"不是无条件更好,必须用同一批题量。
1895
+
1896
+ 同一批题(40 条:碎片/标题各 20,seed 固定)实测:
1897
+
1898
+ | 组 | 指标 | 不重排 | **加重排** |
1899
+ | --- | --- | --- | --- |
1900
+ | 全部 | hit@1 | 0.55 | **0.65** |
1901
+ | 全部 | recall@5 | 0.65 | **0.80** |
1902
+ | 全部 | MRR@10 | 0.59 | **0.72** |
1903
+ | 全部 | nDCG@10 | 0.63 | **0.74** |
1904
+ | 碎片/文章(最弱那格) | recall@5 | 0.30 | **0.70** |
1905
+ | 碎片/文章 | MRR@10 | 0.32 | **0.53** |
1906
+ | 标题/聊天 | hit@1 | 0.60 | **0.80** |
1907
+ | 标题/文章 | recall@5 | 1.00 | 0.90 ⚠️ |
1908
+
1909
+ **一处倒退如实记下**:标题/文章那格 recall@5 从 1.00 掉到 0.90 —— 一个用例被重排挤出了前五(候选池 20,
1910
+ 重排认为别的更相关)。n=10 时一个用例就是 0.10,**不能据此说重排有害**,但也不能装作没发生:
1911
+ 如果后续在这格上重复出现,对策是"扩大候选池"或"保住召回侧的第 1 名",**这两条都还没做**。
1912
+
1913
+ **Consequences.**
1914
+ - **每次检索现在会多花一次模型调用**(约 20 条候选 × 300 字 ≈ 2–3k tokens,deepseek 系约 ¥0.004/次)
1915
+ 和几秒延迟;`search`、助手的 `search_semantic`、MCP 的 `search_knowledge`、`chat` 走的是同一个
1916
+ `search()`,所以它们**一起变了**。要省就 `--no-rerank`。
1917
+ - 重排仍然"不该让检索失败":没有 key、网络断、模型返回的编号不齐(这是重排最危险的失效方式)都
1918
+ **保持原序**并标 `degraded`;区别是现在这件事**看得见**了。
1919
+ - 借 WeKnora 的两条还没做:**重排后的 MMR 去冗余**(它用 λ=0.7)与**候选不足时迭代扩池**。
1920
+ MMR 与"单答案召回"指标是相冲的(它故意压掉相似项),要量的话得用"去重后的名次"或人工判据。
1921
+
1922
+ ## D-111: 召回有了评测台,门控后的关键词融合因此翻成默认
1923
+
1924
+ **Status:** Active(判据是 2026-10-15 用新评测台跑的三组 A/B;本条同时更正 D-110 的结论)
1925
+
1926
+ **Decision.** 两件事一起定:
1927
+ 1. **留一个能复跑的评测台** `scripts/recall_eval.py`。指标全部用业界标准口径,**不自创**:Recall@1/5/10、
1928
+ MRR@10、nDCG@10、Hit@1(中文规范《人工智能 检索增强生成系统测评规范》用的是"命中率"这个词),
1929
+ 外加**覆盖(coverage)**——这是这类语料特有的那一项:答案在库里根本不存在时任何排序都救不了,
1930
+ 把它算进召回率是在诬赖排序,所以每题先判"库里有没有"。标准答案来自两处:从记录**自己正文中段**
1931
+ 取的 12 字干净片段(`frag`,唯一性已校验)与标题/开头 30 字(`title`),都在运行时生成、**不把语料
1932
+ 写进仓库**;手写问句走 `--queries-file`(仓库外的 TSV:问题+锚串)。
1933
+ 对标过的同类做法:BEIR/MTEB(nDCG@10 为主指标)、C-MTEB(35 个中文数据集)、MS MARCO(MRR@10)、
1934
+ RAGAS(生成侧);最接近的线下实现是 PsyRAG-Lab(中文,BM25 vs dense vs RRF vs cross-encoder,
1935
+ 报 Hit@K/Recall@K/MRR/nDCG/MAP)——它也是**先比再定**而不是假定 RRF 有用。没有现成基准能覆盖
1936
+ "个人微信语料 + 文章/聊天双线 + 多号转载"这三件事,所以语料层我们自建,指标层不另立。
1937
+ 2. **关键词侧加门控后,把它翻成默认开**(`--no-hybrid` 关)。门控两条,缺一不可:只让**命中稀有词**
1938
+ (`df/N ≤ 5%`)的文档投票;并且它们的 BM25 分数要**接近最高分**(≥ 0.5×)。问句里一个稀有词都
1939
+ 没有("怎么用/有没有"这类)⇒ 关键词侧**整个弃权**。
1940
+
1941
+ **Reason: 让关键词侧起作用的是门控,不是融合本身。** 同一批题(frag 40 / title 40 / 手写问句 12):
1942
+
1943
+ | 排名方式 | 碎片 recall@10 | 碎片 recall@5 | 碎片 hit@1 | 标题 hit@1 | 手写问句 |
1944
+ | --- | --- | --- | --- | --- | --- |
1945
+ | 只用向量 | 0.91 | 0.89 | 0.60 | 0.83 | 0.75 |
1946
+ | 无门控 RRF(D-110 的版本) | 0.91 | 0.89 | 0.60 | 0.83 | 0.75 |
1947
+ | **门控 RRF(0.05 / 0.5,现行默认)** | **1.00** | **0.97** | **0.80** | **0.86** | 0.75 |
1948
+
1949
+ 无门控版在**同一批题上是零增益**——与纯向量一模一样;这从另一面解释了 D-110 为什么量出"有害":
1950
+ 问题的根源是虚词投票,不是融合这个动作。门控把它修好之后,三个台子**都不掉**,最弱那格(碎片召回,
1951
+ "文里那句话在哪篇")从 0.91/0.60 提到 1.00/0.80。阈值敏感性也量了:宽松 (0.10/0.3) 略弱、紧的
1952
+ (0.02/0.7) 略好,**平台是平的** ⇒ 这个数字不承重,取中间值,并且**不许**再拿这一批题去微调它
1953
+ (避免把偶然结果固化)。
1954
+
1955
+ **Consequences.**
1956
+ - `search` 的默认排序与 1.12.0 之前不同:`score`(余弦)**不再单调**,顺序来自融合名次。想回到纯向量:
1957
+ `--no-hybrid`。助手 / MCP / RAG 走的是同一个 `search()`,所以它们也一起变了。
1958
+ - 评测台要花钱(每题一次 embedding,十几 tokens)且**问句出境**,所以它和 `search-index` 一样要求
1959
+ `--yes`;它**不写任何文件**,报告只打到 stdout(JSON 走 `--json`)。
1960
+ - `--dedup`(同文本合并再排名)实测只值 +0.03 左右的 recall@10:多号转载确实吃名额(实测 50 条 top-5
1961
+ 里有 4 条是同文本),但不是主因。它的正确位置是**建索引时**(省向量、省名额),不是每次检索时做。
1962
+ - 这一条**不影响** D-110 记的"评测要把'没命中'与'库里没有'分开"那条纪律——评测台的 `coverage` 字段
1963
+ 就是把它落到代码里的样子;`HYSPLIT` 那类问句(库里 0 条记录)因此不会被算成排序失败。
1964
+
1965
+ ## D-110: 混合检索做成了开关而不是默认 —— 量出来它只在一种问句上赢
1966
+
1967
+ **Status:** Active(判据来自 2026-10-15 的两组 A/B:同一份索引、同一批查询,只换排名方式)
1968
+
1969
+ **Decision.** 保留"向量名次 + 本地 BM25 名次用 RRF 合成"这条路,但**默认关**,由 `search --hybrid` 打开
1970
+ (CLI 与 Python 两侧同名同义,默认值两边一致)。关键词那一侧完全本地:中文按**字符二元组**切词(没有分词器,
1971
+ 也就不需要词表,代价是虚词也成词——那交给 BM25 的 idf 自己去压),一遍扫过所有记录算分,**不建额外索引文件**,
1972
+ 所以不存在"关键词索引与正文不同步"这种失效。实测每次 31 ms(中位,16k 条)。
1973
+
1974
+ **Reason: 默认值要挑"多数问句更好"的那个,而融合只在少数问句上更好。**
1975
+ - 逐字碎片查询(16 条,从记录正文中段取 12 字原话):BM25 单独 **16/16**、向量单独 9/16、RRF 12/16。
1976
+ 关键词侧近乎满分,融合反被向量侧拖低。
1977
+ - 真实问句(5 条):RRF 在 **1 条**上明显更好 ——「卫星遥感反演气溶胶光学厚度」把目标(基于遥感的 PM2.5 反演)
1978
+ 从第 3 提到第 2;在另外 4 条上**都不优于**向量单独,其中 2 条更差 ——「有没有讲遥感或卫星数据用在空气质量
1979
+ 监测上的文章」的正确首条被挤出前五,「后向轨迹 HYSPLIT 怎么用」被"怎么用"这类虚词带偏。
1980
+ - 机理清楚:RRF 给两侧**同等票权**,而中文问句里的虚词会让 BM25 侧变成纯噪声。
1981
+
1982
+ **Consequences.**
1983
+ - 开 `--hybrid` 后输出的 `score`(余弦)**可能不再单调** —— 顺序来自 RRF,而 `score` 仍按原义报(没有改它的含义,
1984
+ 改的是顺序)。看排序别只看这一列。
1985
+ - 想在真实问句上默认开,先解决虚词投票(按 idf 设阈、只让稀有词投票),并用**同一套 A/B** 复核;
1986
+ 不许拿这 5 条查询调阈值 —— 那正是这个仓库点过名的"把偶然结果固化"。
1987
+ - 这条路不花钱、不出境:关键词侧全离线,查询嵌入那一次调用与原来完全相同。
1988
+
1989
+ ## D-109: 语义检索的 embedding 走阿里云,出境的是每条素材的截断片段
1990
+
1991
+ **Status:** Active(做法与成本来自 2026-10-15 的真机实测:按默认窗口建了一份 16,027 条的索引,并跑了真实检索)
1992
+
1993
+ **Decision.** 语义检索(`weflow-cli search` / `chat`、助手的 `search_semantic`)的向量由阿里云百炼
1994
+ (DashScope)的 `text-embedding-v4` 算,1024 维,密钥走 `dashscopeApiKey`(机器绑定加密保存)。**出境的是每条
1995
+ 素材的一小段纯文本**:文章 = 标题 + 正文前 500 字(`semantic_search.py` 的 `collect_articles`),聊天 = 消息内容
1996
+ 前 500 字(同文件的 `collect_chat_messages`)。**不出的**:数据库与密钥、图片与文件本体、文件路径、好友名/群名,
1997
+ 以及任何一条没被截进那 500 字的正文。索引与元数据都留在本地(`output/.semantic_index/{vectors.npy,meta.json}`),
1998
+ 查询时才把**这一次的问句**发出去一次。
1999
+
2000
+ **Reason: 本地没有能用的中文向量,而它必须是可选且看得见的。** 默认不建索引——没配 key 时
2001
+ `search`/`chat` 直接说缺 key,不拿空 key 去请求。写死 500 字是因为"全文出境"是另一档暴露面与另一档账单:
2002
+ 实测这份索引累计送了 **587 万字 ≈ 391 万 tokens ≈ ¥2.0**(估算按 1.5 字/token、¥0.514/百万 token),
2003
+ 而单次查询只送 12 tokens ≈ **¥0.00001**——真正贵的是建索引那一次,它只在窗口变化时才需要重跑。
2004
+
2005
+ **代价要一起写下来 —— 而且 2026-10-15 当天把主因查清了。** 当时"截断"只是可疑方向;同一天实测最近 30 个日期目录的
2006
+ 10,759 篇日报 md,**含 `## 正文` 的有 0 篇**(真实结构是 `frontmatter → # 标题 → > 来源 → --- → ## AI 摘要 → --- →
2007
+ 重复标题 → 正文`),而取正文的判据只有一句 `startswith('## 正文')`,于是每一篇都退回 `content[:1000]`:送去 embedding
2008
+ 的是 YAML 头(抽样 300 篇:100% 含 `title:`,汉字占比中位 **0.16**)。所以"文章线只能靠标题排序"的原因是**正文压根没进索引**,
2009
+ 不是截断。已改成按真实结构取正文(同一批 300 篇:汉字占比中位 0.16 → **0.80**,299/300 篇改变),并配了一条
2010
+ **会因"退回老写法"而变红**的测试(`test/semantic_article_body_test.py`,夹具照真实结构写、要断言的那句话放在第 1000 字之后)。
2011
+
2012
+ 还有两件**没做**,一起记在这里:
2013
+ - **要 `--full` 重建索引才生效**:增量建索引只嵌"新 id",提取器改了而老记录不动 —— 也就是说"修好了"和"索引里还是旧数据"
2014
+ 可以同时成立。这是本轮实测最容易误判的一处。
2015
+ - **素材本身的厚度**:日报 md 中位 912 字(汉字 259),而正文缓存是 2,063、Vault 笔记是 422。要不要给文章线换更厚的素材
2016
+ 是个独立取舍(更厚 = 更多出境与成本),本轮没动。
2017
+ 同一批实测还留下一个读数:**原始余弦分跨问句不可比**(同一个库上,一个问句的 0.58 不必比另一个问句的 0.50 更"像")。
2018
+
2019
+ **Consequences.**
2020
+ - 索引是**派生物**:删掉它只影响耗时与再次出境的成本,不影响任何原文。
2021
+ - 重排是**第二段出境**(候选片段发给决策模型)。本机实测 Typesafe 返回 **HTTP 451 "not available in your
2022
+ region"**,所以重排在这台机器上一直不生效、等同 `--no-rerank`。这是**已知边界**,不是"决定不重排"。
2023
+ - 想完全不出境只有不用语义检索:`vault search` 是纯本地关键词检索,不联网、不调模型。
2024
+ - 上面所有金额都是**估算**(token 按 1.5 字/token、单价取百炼公开价),不是账单读数。
2025
+
2026
+ ## D-108: 回填与日报共用一份正文缓存,但两条流水线仍然是两条
2027
+
2028
+ **Status:** Active(判据来自 2026-10-14 的真机实测:一天的回填把缓存从 57 个文件灌到 202 个)
2029
+
2030
+ **Decision.** 历史回填 `backfill_articles.py` 的正文抓取改为调用**日报自己那个** `biz_daily.fetch_article_cached`:
2031
+ 成功结果写进 `output/.cache/fetch/<md5(url)>.md`(与日报同一份、仍是"只缓存非空成功"),命中缓存时不联网;
2032
+ 两边都有 `--no-fetch-cache`,**同名同义**(跳过"读",成功的仍然写回,所以它同时是"刷新旧缓存")。两条流水线
2033
+ **本身不合并**:输出形态不同(纯文本 vs HTML+图片)、抓取策略不同(回填并发且不节流,日报串行且刻意 8–12 秒
2034
+ 节流以少打扰上游)——共用缓存 ≠ 共用流程。节流的 `sleep` 在日报的**调用循环**里、不在抓取函数里,所以命中
2035
+ 缓存的篇既不联网也不睡。
2036
+
2037
+ **Reason: 让"补历史"与"正式产出"少重抓一遍。** 实测 2026-10-13 那天:回填灌了 **145 条缓存**
2038
+ (57 → 202 个文件,与"尝试 152、成功 145"逐条对上),而这 145 篇正是回填认为"正文太短、必须联网取"的那批。
2039
+
2040
+ **但接力是部分的,这一点别读多。** 日报的 Phase 1 对**每篇有 url 的文章**都抓(它要完整正文来写摘要),
2041
+ 而回填只抓"本地正文短于 100 字"的那些、其余的连请求都不发。实测同一天:485 篇里**只有 145 篇在缓存里,
2042
+ 日报重跑仍需联网 340 篇**(按 8–12 秒节流约 55 分钟)。所以要"把这一天正式产出"仍然要付大部分抓取成本,
2043
+ 省下的是那 145 篇。反向那一半是成立的:日报跑过的天,回填重跑时不再打扰上游。
2044
+
2045
+ 想让它真的全命中,得让回填**也**去取那些本不需要取的正文——那是**增加对上游的请求**,而
2046
+ `docs/NEXT_ACTIONS.md` 明确说过不要"通过改变默认行为造成额外请求",所以默认不这么做;要做就做一个显式开关
2047
+ (例如 `--prefetch-all`),由使用者决定。**这项没做**,留在这里当已知边界。
2048
+
2049
+ **Consequences.**
2050
+ - 缓存只代表"曾经成功抓到过",**不代表正文是最新的**:源站改稿不会反映到缓存里。要刷新只能显式
2051
+ `--no-fetch-cache`;默认行为不为"可能过期"付费(这是 `docs/NEXT_ACTIONS.md` 点名的取舍)。
2052
+ - `output/.cache/fetch/` 从此是两条线共用的状态。删掉它只影响耗时(各自退回"要重新抓"),不影响产出内容。
2053
+ - **抓取失败与"正文本来就短"从此分开记**:`.articles.json` 多一个可选键 `fetchFailed`(该轮抓取失败的篇数),
2054
+ `day_done` 据此判那天**没做完** ⇒ 下次重跑只重试失败的篇,成功的那批走缓存。它与另两个标记
2055
+ (`topicFilter`、`truncated`)有一点不同:**不是 sticky 的**——一次零失败的全量跑说明(含上轮失败的)都补齐了,
2056
+ 标记就该消失,否则那天会被永远重做。两种情况的区别是有代价的:把它们混成一句"跳过 N 篇(正文太短)"时,
2057
+ 一次网络抖动会被写成"这几篇本来就没有正文",而那些篇再也不会被重试(同仓库量过:84 次失败里 28 次重试可得)。
2058
+
1409
2059
  ## D-107: 报"客户端运行时版本",判据是活进程,不是 exe 里的字面量
1410
2060
 
1411
2061
  **Status:** Active(做法来自一份**实测的启动器逆向报告**——报告在本仓库外,含真实的开发者用户名与
@@ -1413,7 +2063,12 @@ which is what the gate is for.
1413
2063
 
1414
2064
  **决定。** `check` / `health_check` 新增一项**微信客户端版本**【实测】:版本取自**活进程命令行的
1415
2065
  `--user-lib-dir`**(`check --json` 多一个 `wechatClient` 块:`running` / `version` / `loadedDirs` /
1416
- `note` / `unusableVersionDirs`)。三条判据写死:
2066
+ `note` / `unusableVersionDirs`)。
2067
+
2068
+ > **2026-10-18 修订(D-125):** 4.1.15.53 起主进程命令行**不再带** `--user-lib-dir`。命令行仍是首选,
2069
+ > 取不到时改看进程实际映射的 `Weixin.dll` 所在目录;两者都没有才报未知。下面三条判据不变。
2070
+
2071
+ 三条判据写死:
1417
2072
 
1418
2073
  1. **不读 `Weixin.exe` 里的版本字面量。**【实测·报告】它确实硬编码着、而且在本机**恰好与版本目录
1419
2074
  同名** —— 但它只是**兜底**(控制流显示只有主路径加载失败才会用到它;主路径读
@@ -2818,6 +3473,12 @@ infer completeness from the presence of `.articles.json`. An unknown `--topic` i
2818
3473
  opened, since a typo would otherwise select 0 articles and report "nothing to do" - indistinguishable from a day that
2819
3474
  genuinely had none.
2820
3475
 
3476
+ **A third marker joined them on 2026-10-14**: `fetchFailed` records a body fetch that failed *in that run*, so one
3477
+ network hiccup is no longer written down as "skipped N (body too short)" with the day still counted as done - which
3478
+ is how those articles used to disappear for good. Unlike `topicFilter` and `truncated` it is **not** sticky: a full
3479
+ run with zero failures proves the earlier ones were recovered, so the marker clears (and the cache makes that retry
3480
+ cheap). See D-108.
3481
+
2821
3482
  ## D-059: The ball's eyes follow the mouse - and that makes the cursor position a data flow, so it is written down
2822
3483
 
2823
3484
  **Status:** Active