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/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
@@ -410,6 +432,11 @@ weflow-cli config set dashscopeApiKey "sk-..."
410
432
  而不会拿一个空 key 去请求。`--api-key` 参数或 `DASHSCOPE_API_KEY` 环境变量仍然优先,
411
433
  临时用一次时更方便。
412
434
 
435
+ **这一步会把片段发到阿里云。** 建索引和每次检索都要走百炼的 embeddings 接口,送出去的是**每条素材的一小段纯文本**——
436
+ 文章是「标题 + 正文前 500 字」、聊天消息是**消息内容前 500 字**;数据库、密钥、图片、文件路径、好友名都不在里面。
437
+ 实测按默认窗口建一份 16,027 条的索引,累计送出约 587 万字(≈ ¥2,估算口径与 `search-index --dry-run` 相同);
438
+ 之后每查一次只送一个问句(十几个 token,不到一分钱)。要完全不联网检索就用 `vault search`。
439
+
413
440
  顺带一提,`favPassphrase`(解锁收藏与 biz 库的口令)现在也能这样设置了——
414
441
 
415
442
  ```powershell
@@ -432,6 +459,13 @@ weflow-cli search-index --days 3650 --article-days 365 --yes # 要"整段历
432
459
  - 默认值与从前**完全一致**(90 / 30)——加参数不是改代价,只是把原来写死、改不了的数字
433
460
  变成改得了的。想建"整份聊天记录"的知识库,就显式给一个大窗口。
434
461
  - **天数越多,发给向量服务的文本越多**,这是出境成本,所以确认语里会写明这次用的窗口。
462
+
463
+ **全量重建要二十分钟以上,而且现在可以被打断。** 实测 16,000 条左右(聊天 90 天 + 日报 30 天)约 20–25 分钟,
464
+ 比 CLI 给子进程的超时还长(原先只有 5 分钟,已改成 30 分钟)——所以它是**每 100 批往盘上落一次**的:中途被杀
465
+ 也只丢最后不到一千条,再跑一次 `search-index`(**不加 `--full`**)就从断点接着嵌,已经在盘上的 id 不会重复付费。
466
+ 跑起来后 stderr 每落一次盘打一行 `[索引] 已嵌入 N/M`。某批请求失败时(实测见过 `IncompleteRead`)那些记录
467
+ **不写进索引**(写进去的话它们会永久占位、再也不会被重嵌),数量在结果的 `embedFailed` 里,重跑即可补上。
468
+
435
469
  - 上限 36500 天(≈100 年);`0` 会被拒——0 天等于建出一个**空**索引,而它看起来像"建好了"。
436
470
 
437
471
  ### 知识库框架:一图看懂
@@ -593,6 +627,8 @@ weflow-cli wiki lint --json # 机器可读
593
627
  ```powershell
594
628
  weflow-cli search "PM2.5 对植物用水效率的影响"
595
629
  weflow-cli search "..." --no-rerank # 只按向量/关键词相似度,不调决策模型
630
+ weflow-cli search "HYSPLIT 后向轨迹" --no-hybrid # 只按向量排(默认融合本地关键词名次,见下)
631
+ weflow-cli search "暗访" --from 红星新闻 # 只查这个公众号(属性筛,不走语义)
596
632
  weflow-cli search "项目排期" --line chat # 只看聊天那条线
597
633
  weflow-cli search "..." --line all # 两条线一起(默认只搜文章线)
598
634
  ```
@@ -609,9 +645,184 @@ weflow-cli search "..." --line all # 两条线一起(默认只搜文
609
645
  "要 10 条只回 3 条"或者"另一条线的结果混进来还标着本线的名字"。
610
646
 
611
647
  - 返回里 `score` 仍是相似度(含义没变),重排的分数在 `rerankScore`。
648
+ - **返回里的 `vector` 字段告诉你这次到底走没走向量**:`{"used": true/false, "fallback": null/<原因>}`。
649
+ `used: false` 时 `fallback` 会写明原因(embedding 上游失败 / numpy 不可用 / embedding 为空或全零…)——
650
+ 这条以前没有,于是"只搜到关键词结果"和"关键词结果就长这样"在输出里一模一样。
651
+ `hybrid` 的含义**没变**("融合开关是开的"),两者要一起读。
612
652
  - 没配 `typesafeApiKey`、连接失败或服务过载(529)时,**原序返回并打一行 WARN**,
613
653
  检索本身不会因此失败。瞬时错误会自动重试最多 2 次。
654
+ - **本机实测重排是不生效的**(2026-10-15):Typesafe 返回 `HTTP 451: not available in your region`,
655
+ 所以顺序就是裸相似度,等同 `--no-rerank`。而这行 WARN 只有**直接跑 Python 脚本**时看得见——CLI 没转发
656
+ 子进程的 stderr,`search` 看起来一切正常。这是已知缺口,别把"没报错"读成"重排过了"。
614
657
  - 想确认重排有没有帮上忙:同一个查询跑一次带重排、一次 `--no-rerank` 对比顺序即可。
658
+ - **重排现在是真在工作**(2026-10-15 起):先试 Jev/Typesafe 契约,失败或不可用就退到**配置里的对话模型**
659
+ (本机能连上的那个)。两段都失败就保持原序,并把 `provider` / `degraded` / `error` / `scored` 写进 JSON 的
660
+ `rerank` 字段 —— 别把"没重排"读成"重排过了"。送进模型前会先**清洗段落**(去加粗标记、公众号套话、署名行、
661
+ 图片与链接语法),因为摘录只有 300 字。
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` 可以,默认别改。
674
+ - **默认会融合一次本地关键词名次**(门控 BM25 + RRF,2026-10-15 起;`--no-hybrid` 关掉)。它解决的是一种具体的失效:问句里
675
+ 最稀有的那个词被出现最多的词带走 —— 实测「卫星遥感反演气溶胶光学厚度」在纯向量下把"基于遥感的 PM2.5 反演"
676
+ 排到第 3,开了它到第 2。但它在**另外 4 条实测问句上都不优于纯向量**(「有没有讲遥感…」被挤出前五、
677
+ 「后向轨迹 HYSPLIT 怎么用」被"怎么用"这类虚词带偏),所以默认不关它是不划算的:**问句里确实有稀有词时才开**。
678
+ 两点要知道:关键词侧全本地、不联网、不花钱(16k 条约 31 ms);开了之后 `score`(余弦)可能不再单调,
679
+ 顺序来自融合名次而不是分数。
680
+
681
+ - **重排看多少条候选:`--rerank-pool`(默认 100)**。这是 2026-10-16 量出来的**最大杠杆**:重排只能重排
682
+ 它拿到的候选,池子 20 时正确答案只要在融合排名 21 名以外就永远进不来。同一批 40 题:池子 20 → 100 让
683
+ **recall@10 0.82 → 0.95、MRR@10 0.72 → 0.80、最弱的"碎片/文章"格 0.70 → 1.00**;100 → 200 只剩噪声级增益。
684
+ 代价是每次重排的提示从约 2.5k tokens 涨到约 20k(≈ ¥0.01/次 + 几秒);嫌贵就 `--rerank-pool 20`,
685
+ 这比整个 `--no-rerank` 温和。
686
+ - **同文本转载默认折叠**:同一篇文章被几个号转发时,结果里只出现一次(`duplicateCount` 告诉你有几个号发过)。
687
+ 实测 20 条问句的 top-5 里 7% 是这类重复("环境监测新规"那类通稿能一次占掉 4 个名额)。`--no-dedup` 关掉。
688
+ 它**只改展示、不改排序**(不影响 recall 指标),也不需要在建索引时去重 —— 索引侧去重会破坏 `--from <公众号>`。
689
+ - **要正文就加 `--with-text`**:默认返回的是"用来匹配的摘录"(标题 + 前 500 字),不是内容。加上它,每条会多给
690
+ `fullText`(能读的正文:去标记/图片语法/公众号套话,保留段落)、`sourceFile`(本地日报 md 路径)、`url`(原文链接)。
691
+ 正文本地就有两处、**取更长的那份**:日报 md,以及正文缓存 `output/.cache/fetch/`(回填/日报抓来的完整正文)。
692
+ 聊天记录里索引那段**本来就是消息全文**。`--with-text 2000` 可限每条字数(默认 4000);读不到的会留 `None`
693
+ 并保留 `sourceFile`,不会假装成"这篇没正文"。常用组合:`search "…" --top-k 3 --with-text`。
694
+ - **正文太薄就按 url 抓:`--fetch-missing [N]`**(默认关;不写字数时最多抓 3 条)。判据是"本地正文短于 300 字
695
+ 且记录里有 `url`",抓到的写回 `fullText` 并标 `fetchedFromUrl`;它**复用日报的同一份缓存**
696
+ (`output/.cache/fetch/`,只缓存成功),所以抓过的日报重跑直接命中。实测:某条 201 字的"好文回顾"→ 965 字
697
+ (完整摘要与结论),而同一趟里本地已 3,020 字的那条**没有被碰**(只抓薄的)。
698
+ 与日报的 8–12 秒批量节流不同:那是几百篇的礼貌,这是几条的按需 —— 这个差异是刻意的。
699
+ - **`--mmr` 压近似邻居(默认关,2026-10-16 量过)**:把最终结果按 MMR(λ=0.7,照 WeKnora)去冗余,
700
+ 压掉"讲同一件事"的近似条目。它确实让结果更不重复(top-10 两两余弦 0.627 → 0.559),但**判官口径下的准确率
701
+ 掉了**(严格相关 40% → 30%,宽松 68% → 64%),recall@5 也掉 0.03 —— 因为这份语料里的"重复"多是
702
+ **多个号转载同一件事**,MMR 把有用的重复一起删了。所以默认关;要"同一件事只留一条"用默认的
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
+
810
+ ### 检索质量怎么量(评测台)
811
+
812
+ 改了检索(换模型、调窗口、动排名)之后,"变好还是变坏"要用同一批题量,不能靠手工试问:
813
+
814
+ ```powershell
815
+ python scripts/recall_eval.py --records 30 --yes # 自动造题:碎片 + 标题,各线各 30 条
816
+ python scripts/recall_eval.py --records 30 --dedup --yes # 顺便量"同文本多号转载"吃掉多少名额
817
+ python scripts/recall_eval.py --queries-file D:/q.tsv --records 0 --yes # 只用你手写的问句
818
+ ```
819
+
820
+ 指标是标准口径:Recall@1/5/10、MRR@10、nDCG@10、Hit@1,外加**覆盖**(答案在库里根本不存在时不算
821
+ 排序失败)。`--queries-file` 的格式是 TSV:`问题<TAB>答案里必然出现的字面量<TAB>线`,**请放在仓库外**
822
+ (锚串取自你的语料)。它要花钱(每题一次 embedding)且问句出境,所以必须 `--yes`;不写任何文件。
823
+
824
+ 2026-10-15 的基线(索引 15,995 条):标题/开头 30 字 recall@10 = 0.97–1.00;12 字正文碎片
825
+ recall@10 = 1.00、hit@1 = 0.80(**这是门控融合开了之后的数**,纯向量是 0.91/0.60)。
615
826
 
616
827
  ### 语音消息转文字
617
828
 
@@ -641,6 +852,12 @@ python scripts/wechat_voice.py --db "<message_0.db>" --key <key> --salt <salt> `
641
852
  --passphrase <passphrase> --talker "<会话id>" --cache-dir "output\.voice-cache"
642
853
  ```
643
854
 
855
+ - **`--from <公众号>` 按号筛,走的是属性不是语义。** 号名不住在嵌入文本里(它住在 `id`/文件名
856
+ `<公众号>-<标题>.md` 里),所以按号查必须精确筛。实测窗口 2026-09-12..10-13(10,759 篇 / 413 个号):
857
+ 红星新闻 908 篇、政事儿 632 篇、新京报 529 篇,而不筛号直接搜号名时,前 10 条里只有 0–3 条真是那个号。
858
+ 三点要知道:`--from` 是**子串匹配**(给"红星"也认);聊天消息没有"公众号"这个属性,所以它实际只作用于
859
+ 文章线;筛不到时会明确说"索引里没有这个号的记录",而不是安静返回空。**它给对的池子,不保证池子里的排序**
860
+ —— 号内检索仍是语义最近邻(实测一个综合新闻号里搜"暗访",前五条有两三条是别的主题)。
644
861
  - **有 NVIDIA GPU 时自动走 GPU**,约快 30 倍(实测 0.07 秒/条 vs 2.0 秒/条);GPU 不可用则退回 CPU,不会报错。需要 `pip install nvidia-cublas-cu12 nvidia-cudnn-cu12`。
645
862
  - 语言**自动检测**。强制 `--language yue` 在粤语模型上会返回空,别加。
646
863
  - 中断后重跑会从缓存未命中的地方继续,不会从头再来。
@@ -839,30 +1056,150 @@ weflow-cli daily --dry-run --json # 看它读到多少篇、准备处理哪
839
1056
  ```
840
1057
 
841
1058
  正常会打印当天扫到的文章数与一个 `{"success":true,"dryRun":true,...}`。读到几百篇就说明
842
- 读取没问题,缺的只是一个触发。交给计划任务(与「WeFlow Health Check」同一套做法,
843
- 不依赖任何编辑器或会话开着):
1059
+ 读取没问题,缺的只是一个触发。交给计划任务 —— **用仓库里的安装脚本注册,别手抄命令**:
1060
+
1061
+ ```text
1062
+ 双击 scripts\install_tasks.cmd ← 最省事:它自己申请管理员权限,装完窗口留着给你看结果
1063
+ ```
1064
+
1065
+ 或者在你自己的管理员 PowerShell 里:
844
1066
 
845
1067
  ```powershell
846
- schtasks /create /tn "WeFlow Daily" ^
847
- /tr "weflow-cli daily --yes" ^
848
- /sc daily /st 08:20
1068
+ powershell -NoProfile -ExecutionPolicy Bypass -File <仓库>\scripts\install_tasks.ps1
849
1069
  ```
850
1070
 
851
- - 查看:`Get-ScheduledTask -TaskName 'WeFlow Daily'`
852
- - 手动跑一次:`Start-ScheduledTask -TaskName 'WeFlow Daily'`
853
- - 上次结果:`schtasks /query /tn "WeFlow Daily" /fo LIST /v`
1071
+ 它装两个任务(重复运行只会覆盖成同样的定义):
1072
+
1073
+ | 任务 | 时间 | 跑什么 | 为什么是这个点 |
1074
+ | --- | --- | --- | --- |
1075
+ | `WeFlow Daily` | **00:05** + 每次登录 | **昨天**(刚结束的完整一天) | 公众号发到 24:00 才停,这是唯一能拿到完整一天的时点;00:05 让"昨天"与日期目录天然对齐 |
1076
+ | `WeFlow Daily Noon` | **13:00** | **今天已到达的部分**(半天) | 想当天先看一眼重点;不完整是有意的,别拿它当"今天的全部" |
854
1077
 
855
- 几点要事先知道:
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
+ > 并且会写进当天那份日志 —— 证明链路是通的)。
856
1119
 
857
- - **`--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、会写文件)。
858
1149
  - **会花钱**:默认跑 AI(摘要/标签/简报)。想让它不出网、不花钱就用
859
- `weflow-cli daily --yes --no-ai`;只要主题与相关度、不要摘要可用 `--no-summary`。
1150
+ `--no-ai`;只要主题与相关度、不要摘要可用 `--no-summary`。
1151
+ 【未知】单价仓库里没有常量、客户端也不回 usage,所以**事先算不准**,只能看账单。
860
1152
  - **没跑成留下的空目录不等于跑过了**:一个只有目录、没有 `README.md` / `.articles.json` /
861
1153
  `index.html` 的日期**不算完成**,再跑一次不带日期的 `daily` 会先把它补上(D-011)。
862
1154
  所以看到 `output/biz-daily/2026-09-22/` 这种空目录时,处理办法就是再跑一次,
863
1155
  不需要手工删。
864
1156
  - **别把触发条件设成"上次成功才跑"**:那样第一次失败之后就再也不会被触发了。
865
1157
 
1158
+ #### 两种形态:完整日报 vs 历史回填(先认清你要哪种)
1159
+
1160
+ `output/biz-daily/<日期>/` 里可能是两种东西,**从 README 第一行就能分辨**:
1161
+
1162
+ | | 完整日报(`daily`) | 历史回填(`backfill_articles.py`) |
1163
+ | --- | --- | --- |
1164
+ | README 开头 | `# 公众号日报` | `# 公众号文章 — <日期>(历史回填)` |
1165
+ | 内容 | AI 摘要 + 图片本地化 + `index.html` 阅读页 + 行动建议 | 只抓正文,**不下载图片、不调用模型** |
1166
+ | 成本 | 一天约 1 小时(含 8–12 秒/篇的节流;**加 `--prefetch-bodies` 后抓取阶段只剩几分钟**)+ AI 费用 | 并发抓取,几分钟一天,**不花钱** |
1167
+ | 排期命令 | `pipeline.py --yesterday --prefetch-bodies`(见上「让它每天自己跑」) | `python <仓库>\scripts\backfill_articles.py --since <昨天> --until <昨天> --workers 4` |
1168
+
1169
+ 两条**可以并存**,但要知道区别:回填那条便宜得多、跑得快,代价是没有摘要/图片/网页。
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 的教训:文件在 ≠ 这天完整。
1176
+
1177
+ > 回填与完整日报**共用一份正文缓存**(`output/.cache/fetch/`,D-108):回填抓过的正文,之后
1178
+ > `daily --date X` 会直接命中、不再联网、也不等节流。但**别把接力读成"那天就免费了"**——日报对
1179
+ > 每篇有 url 的文章都要抓完整正文,回填只抓"本地正文过短"的那些,所以 2026-10-13 那天 485 篇里
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"。
1186
+
1187
+ #### 怎么看出某天"没做完"
1188
+
1189
+ 按顺序看三个地方,一步就能定性:
1190
+
1191
+ 1. **目录是不是空壳**:只有目录、没有 `README.md`/`.articles.json` → 那次在抓取阶段就断了。
1192
+ 2. **`.articles.json` 里有没有 `fetchFailed`**:这是"这一轮有 N 篇正文没抓到",`day_done` 把它读作
1193
+ **没做完**,所以再跑一次只会重试那几篇(其余走缓存)。README 里也会写明篇数。
1194
+ ⚠️ 与此对应的是 `topicFilter`(只回填了部分主题)和 `truncated`(被 `--limit-per-day` 截过)——
1195
+ 三种都表示"这天不完整",但只有 `fetchFailed` 会在下次零失败的跑动后消失。
1196
+ **怎么判断剩下的失败是"重试可得"还是"永久没了":连着跑两次,看失败数。** 2026-10-14 实测一轮
1197
+ 13 天:首次 68 篇失败 → 重试后 43 篇,**再重试仍是 43 篇且逐日数字完全相同** ⇒ 触底了,那些
1198
+ 正文在源站已经取不到(同仓库更早的实测:84 次失败里 28 次重试可得、44 篇是被删除或违规下架)。
1199
+ 重试很便宜(命中缓存的那些不发请求),所以"再跑一次"是安全的确认动作,而不是赌运气。
1200
+ 3. **计划任务的上次结果**:`schtasks /query /tn "WeFlow Daily" /fo LIST /v`(或换成你给任务起的名字)。
1201
+ 任务不存在 / 从没运行 / 上次失败,是三种不同的情况,别混成"日报坏了"。
1202
+
866
1203
  ## 6. AI 和助手
867
1204
 
868
1205
  日报关闭 AI 不会自动关闭其他命令的 AI。报告、RAG、助手和证据线索分析分别按命令参数和配置决定是否调用模型。云端分析前应确认输入范围、供应商和隐私设置;优先使用本地模型处理聊天正文。
@@ -1260,11 +1597,22 @@ weflow-cli scene unbind wxid_xxx --yes
1260
1597
 
1261
1598
  ## 7. 常见问题
1262
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
+
1263
1610
  | 现象 | 处理 |
1264
1611
  | --- | --- |
1265
1612
  | 找不到数据目录 | 先 `init --path`,再 `--search-drives`,最后 `--full-scan`。 |
1266
1613
  | `Python not found` | 安装 Python 并确保当前 PowerShell 能执行 `python --version`。 |
1267
1614
  | 缺少 `sqlcipher3` 等依赖 | 用同一个 Python 执行 `python -m pip install -r requirements.txt`,再 `weflow-cli check`。 |
1615
+ | `sessions` 显示"未找到会话"但微信里明明有聊天 | 先 `weflow-cli check --report` 看结论:这多半是读取那一步没成功,不是"没有会话"。 |
1268
1616
  | `WCDB ... -1006` | 检查 NT 配置和数据库路径,不要只看 WCDB 降级信息;必要时刷新初始化。 |
1269
1617
  | 日报缺昨天 | 无日期运行 `daily` 会检查并补齐昨天;补齐失败会停止今天,不覆盖失败状态。 |
1270
1618
  | `daily --dry-run` 不识别 | 确认使用的是当前源码或最新发布包;当前源码支持该参数,旧 npm 包可能落后。 |
package/README.md CHANGED
@@ -134,6 +134,10 @@ python -m pip install -r requirements.txt
134
134
  weflow-cli init
135
135
  ```
136
136
 
137
+ > **数据目录不在默认位置时,请同时用 `--path` 指定。** 关掉微信之后,自动发现只剩下
138
+ > "`文档\xwechat_files` 这类默认位置"这一条路;微信"存储位置"改到别的盘的用户会直接看到
139
+ > 「未检测到微信数据目录」。
140
+
137
141
  微信“存储位置”、账号目录和其 `db_storage` 目录都可以直接指定:
138
142
 
139
143
  ```powershell