weflow-cli 1.17.0 → 1.19.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 (71) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +3 -0
  3. package/bin/weflow-cli.ts +167 -0
  4. package/dist/bin/weflow-cli.js +199 -0
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/services/diagnosticReport.d.ts +6 -0
  7. package/dist/src/services/diagnosticReport.d.ts.map +1 -1
  8. package/dist/src/services/diagnosticReport.js +16 -0
  9. package/dist/src/services/diagnosticReport.js.map +1 -1
  10. package/dist/src/services/wechatCompat.d.ts +29 -0
  11. package/dist/src/services/wechatCompat.d.ts.map +1 -0
  12. package/dist/src/services/wechatCompat.js +65 -0
  13. package/dist/src/services/wechatCompat.js.map +1 -0
  14. package/docs/DATA_CONTRACT.md +25 -25
  15. package/docs/DECISIONS.md +26 -0
  16. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  17. package/docs/HEALTH-CHECK.md +104 -104
  18. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  19. package/docs/NEXT_ACTIONS.md +236 -236
  20. package/docs/NPM-PUBLISH.md +130 -130
  21. package/docs/NT_DATABASES.md +47 -0
  22. package/docs/POSITIONING.md +68 -68
  23. package/docs/PROJECT_STATE.md +2 -1
  24. package/docs/RELEASING.md +4 -0
  25. package/package.json +1 -1
  26. package/requirements-3x.txt +4 -4
  27. package/requirements-voice.txt +15 -15
  28. package/resources/js/graph3d/NOTICE.txt +35 -35
  29. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  30. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  31. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  32. package/resources/js/graph3d/d3-octree.min.js +2 -2
  33. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  34. package/resources/js/graph3d/d3-timer.min.js +2 -2
  35. package/resources/js/graph3d/three.min.js +6 -6
  36. package/resources/panel/quick-menu.cjs +197 -197
  37. package/scripts/_utils.py +1338 -1338
  38. package/scripts/annual_report.py +3 -1
  39. package/scripts/article_notes.py +423 -423
  40. package/scripts/contact_schema.py +339 -339
  41. package/scripts/create_reading_notes.py +11 -11
  42. package/scripts/daily_task.ps1 +213 -213
  43. package/scripts/extract_todos.py +3 -1
  44. package/scripts/fav_notes.py +321 -321
  45. package/scripts/health_check.py +473 -473
  46. package/scripts/imported_chats.py +68 -68
  47. package/scripts/install_tasks.ps1 +105 -105
  48. package/scripts/leads.py +179 -179
  49. package/scripts/mcp_bridge.py +3 -1
  50. package/scripts/media_index.py +150 -0
  51. package/scripts/network.py +206 -206
  52. package/scripts/nt_common.py +32 -0
  53. package/scripts/nt_decrypt.py +19 -15
  54. package/scripts/opinion_timeline.py +138 -138
  55. package/scripts/replay.py +803 -803
  56. package/scripts/run_hidden.vbs +24 -24
  57. package/scripts/semantic_search.py +5 -1
  58. package/scripts/user_notes.py +315 -315
  59. package/scripts/voice_transcribe.py +139 -139
  60. package/scripts/watch_issues.py +212 -212
  61. package/scripts/watch_mail.py +131 -131
  62. package/scripts/wechat_avatar.py +185 -0
  63. package/scripts/wechat_ledger.py +190 -0
  64. package/src/services/cursorProbe.ts +222 -222
  65. package/src/services/diagnosticReport.ts +22 -0
  66. package/src/services/qchatImport.ts +327 -327
  67. package/src/services/setupVenv.ts +238 -238
  68. package/src/services/syncService.ts +363 -363
  69. package/src/services/syncState.ts +275 -275
  70. package/src/services/wechatCompat.ts +67 -0
  71. package/src/services/wechatMedia.ts +166 -166
package/scripts/_utils.py CHANGED
@@ -1,1338 +1,1338 @@
1
- """
2
- weflow-cli 公共工具函数 — 供 biz_daily / classify_daily / chat_report 等共用。
3
- """
4
- import os, json, re, base64, socket, urllib.request, hashlib
5
- from functools import wraps
6
-
7
-
8
- # ======================================================================
9
- # 工具库(静态知识,供报告「行动指南」自动匹配)
10
- # 按关键词 → GitHub 项目/工具,匹配越靠前优先级越高
11
- # ======================================================================
12
-
13
- TOOL_LIBRARY = [
14
- # --- AI 编程 / Agent ---
15
- {'keywords': ['cursor', 'agent', 'ide', '编程助手', '代码生成'],
16
- 'name': 'Cursor', 'url': 'https://github.com/getcursor/cursor',
17
- 'desc': 'AI 原生 IDE,支持多文件编辑与 Agent 模式'},
18
- {'keywords': ['windsurf', 'agent', 'ide'],
19
- 'name': 'Windsurf', 'url': 'https://codeium.com/windsurf',
20
- 'desc': 'AI 原生 IDE,类 Cursor,支持任务级自动编程'},
21
- {'keywords': ['continue', 'agent', 'ide', 'vscode'],
22
- 'name': 'Continue', 'url': 'https://github.com/continuedev/continue',
23
- 'desc': '开源 VS Code 插件,接入本地/云端大模型'},
24
-
25
- # --- 大模型 / 推理 ---
26
- {'keywords': ['deepseek', 'r1', '开源模型'],
27
- 'name': 'DeepSeek R1', 'url': 'https://github.com/deepseek-ai/DeepSeek-R1',
28
- 'desc': 'DeepSeek 开源推理模型,中文表现强'},
29
- {'keywords': ['llama', 'meta', '开源模型'],
30
- 'name': 'Llama 3', 'url': 'https://github.com/meta-llama/llama3',
31
- 'desc': 'Meta 开源大模型,社区生态最成熟'},
32
- {'keywords': ['ollama', '本地部署', '本地推理'],
33
- 'name': 'Ollama', 'url': 'https://github.com/ollama/ollama',
34
- 'desc': '一行命令运行本地大模型(macOS/Linux/Windows)'},
35
- {'keywords': ['lmstudio', '本地部署', '本地推理'],
36
- 'name': 'LM Studio', 'url': 'https://lmstudio.ai',
37
- 'desc': '桌面端本地大模型运行器,有 GUI'},
38
-
39
- # --- 信息聚合 / 日报 / RAG ---
40
- {'keywords': ['rag', '检索增强', '知识库', '论文'],
41
- 'name': 'AnythingLLM', 'url': 'https://github.com/Mintplex-Labs/anything-llm',
42
- 'desc': '开箱即用的 RAG 知识库 + 聊天桌面应用'},
43
- {'keywords': ['rss', '信息源', '订阅'],
44
- 'name': 'Fluent Reader', 'url': 'https://github.com/yang991178/fluent-reader',
45
- 'desc': '现代化 RSS 阅读器,聚合公众号/博客/新闻'},
46
-
47
- # --- 环境科学 / 交叉方向 ---
48
- {'keywords': ['遥感', '卫星', '反演', 'landsat', 'sentinel'],
49
- 'name': 'Google Earth Engine', 'url': 'https://earthengine.google.com',
50
- 'desc': '大规模遥感影像在线分析平台'},
51
- {'keywords': ['遥感', '卫星', 'python'],
52
- 'name': 'xarray', 'url': 'https://github.com/pydata/xarray',
53
- 'desc': 'N 维数组处理,气象/遥感数据标配'},
54
- {'keywords': ['大气', '排放清单', '排放'],
55
- 'name': 'MEIC', 'url': 'https://meicmodel.org',
56
- 'desc': '中国多尺度排放清单模型'},
57
- {'keywords': ['lca', '生命周期', '生命周期评估'],
58
- 'name': 'brightway', 'url': 'https://github.com/brightway-lca/brightway2',
59
- 'desc': '开源生命周期评估(LCA)框架'},
60
- {'keywords': ['数值模拟', '大气', '模型', '模拟'],
61
- 'name': 'WRF', 'url': 'https://github.com/wrf-model/WRF',
62
- 'desc': '中尺度数值天气预报模型'},
63
-
64
- # --- 效率工具 ---
65
- {'keywords': ['obsidian', '笔记', '知识管理'],
66
- 'name': 'Obsidian', 'url': 'https://obsidian.md',
67
- 'desc': '本地优先的 Markdown 笔记,双链/图谱'},
68
- {'keywords': ['anki', '记忆', '复习'],
69
- 'name': 'Anki', 'url': 'https://apps.ankiweb.net',
70
- 'desc': '基于间隔重复的闪卡记忆工具'},
71
-
72
- # --- 一般关键词兜底 ---
73
- {'keywords': ['投资', '财报', '股价', '股票'],
74
- 'name': 'Tushare', 'url': 'https://tushare.pro',
75
- 'desc': '免费金融数据接口(Python)'},
76
- ]
77
-
78
-
79
- def match_tools(articles: list[dict], top_n: int = 8) -> list[dict]:
80
- """从文章的标题/摘要/标签中抽取关键词,匹配 TOOL_LIBRARY。"""
81
- collected_text = ''
82
- for a in articles:
83
- tags = a.get('tags') or []
84
- text_parts = [
85
- a.get('title', ''),
86
- a.get('summary', ''),
87
- ' '.join(tags) if isinstance(tags, list) else str(tags),
88
- ]
89
- collected_text += ' ' + ' '.join(text_parts)
90
- collected_text = collected_text.lower()
91
-
92
- ranked = []
93
- for tool in TOOL_LIBRARY:
94
- score = sum(1 for kw in tool['keywords'] if kw.lower() in collected_text)
95
- if score > 0:
96
- ranked.append((score, tool))
97
- ranked.sort(key=lambda x: x[0], reverse=True)
98
- return [t for _, t in ranked[:top_n]]
99
-
100
-
101
- # ======================================================================
102
- # Config
103
- # ======================================================================
104
-
105
- CONFIG_PATH = os.path.join(os.path.expanduser('~'), '.weflow-cli', 'config.json')
106
-
107
-
108
- # ======================================================================
109
- # AI Engine 抽象
110
- # ======================================================================
111
-
112
- class AIEngine:
113
- """OpenAI-compatible API 引擎基类。"""
114
- def __init__(self, api_key: str, base_url: str, model: str, timeout=60):
115
- self.api_key = api_key
116
- self.base_url = base_url.rstrip('/')
117
- self.model = model
118
- self.timeout = timeout
119
-
120
- def chat(self, prompt: str, max_tokens=2000) -> str:
121
- payload = json.dumps({
122
- 'model': self.model,
123
- 'messages': [{'role': 'user', 'content': prompt}],
124
- 'max_tokens': max_tokens,
125
- 'temperature': 0.2,
126
- }).encode('utf-8')
127
- req = urllib.request.Request(
128
- f'{self.base_url}/chat/completions',
129
- data=payload,
130
- headers={
131
- 'Content-Type': 'application/json',
132
- 'Authorization': f'Bearer {self.api_key}',
133
- },
134
- method='POST',
135
- )
136
- with urllib.request.urlopen(req, timeout=self.timeout) as resp:
137
- data = json.loads(resp.read())
138
- return data['choices'][0]['message']['content']
139
-
140
-
141
- class AnthropicEngine(AIEngine):
142
- """Anthropic Messages API 引擎(/v1/messages)。"""
143
- def __init__(self, api_key: str, base_url: str, model: str, timeout=120):
144
- super().__init__(api_key, base_url, model, timeout=timeout)
145
-
146
- def chat(self, prompt: str, max_tokens=2000) -> str:
147
- payload = json.dumps({
148
- 'model': self.model,
149
- 'messages': [{'role': 'user', 'content': prompt}],
150
- 'max_tokens': max_tokens,
151
- }).encode('utf-8')
152
- req = urllib.request.Request(
153
- f'{self.base_url}/messages',
154
- data=payload,
155
- headers={
156
- 'Content-Type': 'application/json',
157
- 'x-api-key': self.api_key,
158
- 'anthropic-version': '2023-06-01',
159
- 'Authorization': f'Bearer {self.api_key}',
160
- },
161
- method='POST',
162
- )
163
- with urllib.request.urlopen(req, timeout=self.timeout) as resp:
164
- data = json.loads(resp.read())
165
- return data['content'][0]['text']
166
-
167
-
168
- class DeepSeekEngine(AIEngine):
169
- def __init__(self, api_key: str, model='deepseek-chat', timeout=180):
170
- super().__init__(api_key, 'https://api.deepseek.com/v1', model, timeout=timeout)
171
-
172
-
173
- class ClaudeEngine(AIEngine):
174
- def __init__(self, api_key: str, model='claude-sonnet-4-6', timeout=90):
175
- super().__init__(api_key, 'https://api.anthropic.com/v1', model, timeout=timeout)
176
-
177
-
178
- class OllamaEngine(AIEngine):
179
- def __init__(self, model='llama3', timeout=120):
180
- super().__init__('ollama', 'http://localhost:11434/v1', model, timeout=timeout)
181
-
182
-
183
- # ======================================================================
184
- # 本地引擎自动检测
185
- # ======================================================================
186
-
187
- _LOCAL_ENDPOINTS = [
188
- # (name, base_url, api_style)
189
- ('ollama', 'http://localhost:11434', 'openai'),
190
- ('lmstudio', 'http://localhost:1234', 'openai'),
191
- ('claude', 'http://localhost:8080', 'anthropic'),
192
- ('lmstudio', 'http://localhost:8000', 'openai'),
193
- ('lmstudio', 'http://localhost:3000', 'openai'),
194
- ]
195
-
196
-
197
- def _try_local_endpoint(name: str, base_url: str, api_style: str,
198
- timeout: int = 5) -> tuple[bool, str, str, str]:
199
- health_path = f'{base_url}/v1/models'
200
- try:
201
- req = urllib.request.Request(health_path, headers={'Accept': 'application/json'})
202
- with urllib.request.urlopen(req, timeout=timeout) as resp:
203
- data = json.loads(resp.read())
204
- models = data.get('data', data.get('models', []))
205
- if not models:
206
- return True, name, base_url, 'local'
207
- first = models[0]
208
- model_name = first.get('id', first.get('model', 'local'))
209
- return True, name, base_url, model_name
210
- except Exception as e:
211
- return False, name, base_url, str(e)
212
-
213
-
214
- def detect_local_engine(timeout: int = 5) -> tuple[bool, AIEngine, str]:
215
- """自动检测本地推理服务。返回 (detected, engine, description)。"""
216
- checked = set()
217
- for name, base_url, api_style in _LOCAL_ENDPOINTS:
218
- key = (name, base_url)
219
- if key in checked:
220
- continue
221
- checked.add(key)
222
- ok, dname, durl, model = _try_local_endpoint(name, base_url, api_style, timeout)
223
- if not ok:
224
- continue
225
- if api_style == 'anthropic':
226
- engine = AnthropicEngine('local', durl, model, timeout=180)
227
- else:
228
- engine = OllamaEngine(model=model, timeout=180)
229
- engine.base_url = durl.rstrip('/') + '/v1'
230
- desc = f'{dname} ({model}@{durl})'
231
- return True, engine, desc
232
- return False, None, '无可用本地推理服务'
233
-
234
-
235
- def create_engine(engine_type: str, api_key: str = '') -> AIEngine:
236
- """工厂函数:根据类型创建 AI 引擎。
237
-
238
- 引擎类型: local(自动检测) / deepseek / claude / ollama
239
- 配置 aiBaseUrl 时, deepseek 引擎走自定义 OpenAI 兼容端点 (中转站)。
240
- """
241
- t = engine_type.lower()
242
- if t == 'local':
243
- ok, engine, desc = detect_local_engine(timeout=8)
244
- if not ok:
245
- raise RuntimeError(
246
- '未检测到本地推理服务。请启动以下任一服务后重试:\n'
247
- ' • Ollama: ollama serve (11434)\n'
248
- ' • LM Studio: lmstudio server start (1234)\n'
249
- ' • Claude Code 本地服务 (8080)\n'
250
- '或使用 --engine deepseek/claude 指定云端引擎。'
251
- )
252
- return engine
253
- elif t == 'deepseek':
254
- base_url, model = _custom_endpoint()
255
- if base_url:
256
- return AIEngine(api_key, base_url, model)
257
- return DeepSeekEngine(api_key)
258
- elif t == 'claude':
259
- return ClaudeEngine(api_key)
260
- elif t == 'ollama':
261
- return OllamaEngine()
262
- raise ValueError(
263
- f'未知引擎: {engine_type},可选: local / deepseek / claude / ollama'
264
- )
265
-
266
-
267
- def _custom_endpoint():
268
- """读取 config.json 的 aiBaseUrl/aiModel; 未配置返回 (None, None)。"""
269
- try:
270
- with open(CONFIG_PATH, encoding='utf-8') as f:
271
- cfg = json.load(f)
272
- base_url = (cfg.get('aiBaseUrl') or '').strip().rstrip('/')
273
- model = (cfg.get('aiModel') or '').strip() or 'deepseek-chat'
274
- return (base_url or None), (model if base_url else None)
275
- except Exception:
276
- return None, None
277
-
278
-
279
- def call_deepseek(prompt: str, api_key: str, max_tokens=2000, timeout=60) -> str:
280
- """向后兼容:调用 DeepSeek API。"""
281
- return DeepSeekEngine(api_key, timeout=timeout).chat(prompt, max_tokens=max_tokens)
282
-
283
-
284
- def call_ai(prompt: str, engine_type: str = 'local', api_key: str = '', max_tokens=2000) -> str:
285
- """通用 AI 调用:支持 local/deepseek/claude/ollama 引擎。"""
286
- engine = create_engine(engine_type, api_key)
287
- return engine.chat(prompt, max_tokens=max_tokens)
288
-
289
-
290
- # ======================================================================
291
- # 缓存机制 — 避免重跑 pipeline 时重复调用 API
292
- # ======================================================================
293
-
294
- _CACHE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
295
- 'output', '.cache')
296
-
297
-
298
- def _cache_key(*args, **kwargs) -> str:
299
- """根据参数生成 MD5 缓存 key。"""
300
- raw = json.dumps({'args': args, 'kwargs': kwargs}, sort_keys=True, ensure_ascii=False)
301
- return hashlib.md5(raw.encode('utf-8')).hexdigest()
302
-
303
-
304
- def cached_call(func, *args, cache_subdir: str = '', **kwargs):
305
- """带缓存的函数调用。命中缓存则直接返回,否则调用 func 并存储结果。
306
-
307
- Args:
308
- func: 要调用的函数(需返回可 JSON 序列化的结果)
309
- cache_subdir: 缓存子目录名(如 'classify'、'wiki')
310
- """
311
- cache_dir = os.path.join(_CACHE_DIR, cache_subdir) if cache_subdir else _CACHE_DIR
312
- os.makedirs(cache_dir, exist_ok=True)
313
- key = _cache_key(func.__name__, *args, **kwargs)
314
- cache_file = os.path.join(cache_dir, f'{key}.json')
315
-
316
- if os.path.exists(cache_file):
317
- try:
318
- with open(cache_file, 'r', encoding='utf-8') as f:
319
- return json.load(f)
320
- except (json.JSONDecodeError, OSError):
321
- pass # 缓存损坏,重新调用
322
-
323
- result = func(*args, **kwargs)
324
- try:
325
- with open(cache_file, 'w', encoding='utf-8') as f:
326
- json.dump(result, f, ensure_ascii=False)
327
- except (TypeError, OSError):
328
- pass # 结果不可序列化或写入失败,跳过缓存
329
-
330
- return result
331
-
332
-
333
- def clear_cache(cache_subdir: str = ''):
334
- """清空指定子目录的缓存,或全部缓存。"""
335
- target = os.path.join(_CACHE_DIR, cache_subdir) if cache_subdir else _CACHE_DIR
336
- if not os.path.isdir(target):
337
- return
338
- for fname in os.listdir(target):
339
- fpath = os.path.join(target, fname)
340
- if os.path.isfile(fpath):
341
- os.unlink(fpath)
342
- # ======================================================================
343
- # Config Decrypt
344
- # ======================================================================
345
-
346
- # ======================================================================
347
- # 主题分类法 —— **唯一一处定义**
348
- #
349
- # 列表与定义都收在这里,因为「散文写的枚举会漂」在这个仓库里已经真的发生过:
350
- # `biz_daily.TOPIC_PROMPT` 的第一行按 TOPICS 生成了六类,但同一段提示词后面两处
351
- # 提醒是硬编码的五类(少了政治),判断规则里也没有政治那一栏——而政治在语料里占 26%。
352
- # 两条路径(LLM 提示词 / 决策模型的判据)现在读同一张表,所以它们不可能再对不上。
353
- #
354
- # **顺序有意义**:`generate_ai_report` 按这个顺序给报告分栏。
355
- TOPICS = ['AI', '学术', '新闻', '文学', '投资', '政治']
356
-
357
- # 聊天卡拷进 Vault `Sources/Chat/` 时的文件名前缀。**必须共享**:`chat_notes` 用它写文件、
358
- # `compile_wiki` 用它把概念页的"来源"链接指过去——两处各写一份就是"同一条信息写两处",
359
- # 而这个仓库已经因为这类分叉吃过亏。
360
- # 为什么要有这个前缀:聊天卡的名字**就是会话名**(`白马非马`、`老表亲戚群`),而概念页
361
- # 也可能叫同一个名字;两个文件同名时 `[[白马非马]]` 在 Obsidian 里是二义的。
362
- CHAT_CARD_PREFIX = '会话-'
363
-
364
- # 知识页住在哪几个目录(**Vault 相对路径**)。**分成两个是有意的**:用户明确要求
365
- # 聊天知识库与公众号文章知识库分开(2026-09-27),所以是"一条线一个目录"而不是混在一起。
366
- # 顺序即展示顺序:文章在前。
367
- #
368
- # 读的一方(`wiki_lint` / `vault_search` / `vault_rag` / 助手的 `search_knowledge`)
369
- # 都要把**两个都读**,否则新分出去的那一半会静默地搜不到——而这个仓库已经因为
370
- # "同一个事实写在两处"栽过好几次,所以 TS 那边有一份平行的列表,有测试钉住两者一致。
371
- #
372
- # 这一层是 2026-10-10 立的:用户要求两条线**各自一棵树**(不只是同根下的两个子目录),
373
- # 而"一串 Vault 相对路径"这个形状把"共用一个根"写死了 ⇒ 改值不够、得先改形状。
374
- # 现在两条线的根**仍然相同**(物理迁移还没做),所以下面这层只是把形状立起来;
375
- # 迁移时只改 `root` 的值,不再动形状。**唯一源头**:`CONCEPT_DIRS` 由它派生,
376
- # 图谱那两处曾经的硬编码也改读它。
377
- KNOWLEDGE_LINES = (
378
- # (线 id, 根, 线内的概念目录)
379
- ('wiki', 'output/wechat-vault', 'Wiki/Concepts'),
380
- ('chat', 'output/wechat-vault', 'Chat/Concepts'),
381
- )
382
-
383
-
384
- def concept_dirs():
385
- """所有线的概念目录(Vault 相对路径),顺序即展示顺序:文章在前。"""
386
- return tuple(concepts for _line, _root, concepts in KNOWLEDGE_LINES)
387
-
388
-
389
- CONCEPT_DIRS = concept_dirs()
390
-
391
-
392
- def line_for_concept_dir(path):
393
- """给一个概念目录(任意写法)→ 线 id;认不出就返回 None(**不猜**)。
394
-
395
- 认不出是正常情形(`wiki compile --output` 可以指到库外的任意目录),
396
- 调用方要按"没有线"处理,而不是当成某条线。
397
- """
398
- try:
399
- target = os.path.normcase(os.path.abspath(str(path)))
400
- except Exception:
401
- return None
402
- for line_id, root, concepts in KNOWLEDGE_LINES:
403
- if target == os.path.normcase(os.path.abspath(os.path.join(root, concepts))):
404
- return line_id
405
- return None
406
-
407
-
408
- # 线的展示名。**只此一份**:图谱的提示、体检的报告、助手的输出都取它,各写一句
409
- # "文章线/聊天线"的后果是改词那天只改到一半,而没有任何东西会红。
410
- LINE_LABELS = {'wiki': '文章线', 'chat': '聊天线'}
411
-
412
- # 主键的分隔符。选 `:` 是因为**概念页的文件名里不可能有它**(Windows 禁止,
413
- # `compile_wiki` 也会把它换成 `_`)—— 主键要能被原样切回 (线, 名字),
414
- # 这个判据就必须没有二义。改成 `-` 或 `_` 都会把 `AI-Agent` 这类名字切错,
415
- # 而症状是"图谱里两个节点凭一个错名字连上了",不报错。
416
- LINE_SEP = ':'
417
-
418
-
419
- def line_for_relative_concepts(relative):
420
- """给一个**库内相对**概念目录(`Wiki/Concepts`)→ 线 id;认不出返回 None(**不猜**)。
421
-
422
- 与 `line_for_concept_dir` 的分工:那个比的是**绝对路径**(适合已经知道库根的调用方);
423
- 这个只比对清单里那串相对路径 —— 测试里的临时库、或 `--vault` 指到别处时库根对不上,
424
- 但"`Chat/Concepts` 属于聊天线"这一层仍然成立。
425
- """
426
- def norm(p):
427
- return os.path.normcase(str(p).replace('/', os.sep).replace('\\', os.sep))
428
- for line_id, _root, concepts in KNOWLEDGE_LINES:
429
- if norm(concepts) == norm(relative):
430
- return line_id
431
- return None
432
-
433
-
434
- def line_concepts_path(line_id):
435
- """线 id → 它的概念目录(**库内相对路径**,`Wiki/Concepts`);认不出返回 None。"""
436
- for lid, _root, concepts in KNOWLEDGE_LINES:
437
- if lid == line_id:
438
- return concepts
439
- return None
440
-
441
-
442
- def concept_link_target(line_id, name, other_line_names=()):
443
- """渲染一个概念链接该写成什么目标。
444
-
445
- **只在"另一条线也有这个名字"时才带路径**(`Chat/Concepts/DeepSeek|DeepSeek`):
446
- 两条线同名时裸 `[[DeepSeek]]` 在 Obsidian 里是二义的(官方判据:会 deterministic
447
- 解析,但不保证命中你想要的那张),所以给目标带上路径、用 `|` 保住显示文本。
448
- 只属于一条线的名字**一个字节都不动** —— 否则 4.9 万张页里绝大多数链接都要白重写
449
- 一遍,而那样改出来的 diff 没人看得完。
450
- """
451
- if name in other_line_names:
452
- concepts = line_concepts_path(line_id)
453
- if concepts:
454
- return '%s/%s|%s' % (concepts, name, name)
455
- return name
456
-
457
-
458
- def link_target_name(raw):
459
- """`[[…]]` 里抓到的那一串 → 概念名。**解析只此一份。**
460
-
461
- `[[Chat/Concepts/DeepSeek|DeepSeek]]` → `DeepSeek`。各处自己写 `split('|')` 的话,
462
- 漏掉一处那处的边就整条丢掉,而图里少一条边不报错。
463
-
464
- **改动面越小越好**:只切掉 `|显示文本`,并且**只对带线路径的那些**再去掉前缀。
465
- 其余一律原样返回 —— 这个函数的上游既有"概念名"也有"卡片名",随手多切一刀就会
466
- 改掉既有行为:
467
-
468
- - 别切 `#`:这个库里的概念名**真的带 `#`**(`#GPT6`、`Erdős#1026`)。切了它们会被
469
- 当成别的名字,指向它们的边整条消失(2026-10-10 实测少了 4 条边,而图里少边不报错)。
470
- - 别切 `.md`:`## 来源` 的卡片链接就写成 `[[名字.md]]`,`wiki_lint.resolve` 靠这个
471
- 后缀把它分流到卡片目录。切了它,那一支就再也不走了。
472
- """
473
- name = str(raw or '').strip()
474
- if '|' in name:
475
- name = name.split('|', 1)[0].strip() # `[[目标|显示文本]]` → 目标
476
- norm = name.replace('\\', '/')
477
- for _lid, _root, concepts in KNOWLEDGE_LINES:
478
- prefix = concepts + '/'
479
- if norm.startswith(prefix):
480
- norm = norm[len(prefix):]
481
- if norm.lower().endswith('.md'): # 带路径的写法里才去后缀(我们自己写的那种)
482
- norm = norm[:-3]
483
- break
484
- return norm.strip()
485
-
486
-
487
- def is_line_qualified(raw):
488
- """这条链接是不是**带了线的路径前缀**(`Wiki/Concepts/…` / `Chat/Concepts/…`)。
489
-
490
- 体检用它区分"已经消歧过的链接"和"还是裸的二义链接"。
491
- """
492
- name = str(raw or '').strip()
493
- if '|' in name:
494
- name = name.split('|', 1)[0].strip()
495
- norm = name.replace('\\', '/')
496
- for _lid, _root, concepts in KNOWLEDGE_LINES:
497
- if norm == concepts or norm.startswith(concepts + '/'):
498
- return True
499
- return False
500
-
501
-
502
- def sibling_concepts_dir(concept_dir):
503
- """同一个库里**另一条线**的概念目录;认不出线、或它不存在 → None。
504
-
505
- **两级**:两条线都是 `<库>/<线>/Concepts`,库根是 `concept_dir.parent.parent`。
506
- 写成一级的话候选恒不存在(`<库>/Chat/Wiki/Concepts`)—— 于是"另一条线没有这个名字"
507
- 永远成立,链接不会带路径,而 Obsidian 里那条裸链接就**悄悄**变成二义的。
508
- 2026-09-27 那段同样的两级路径就是这么栽过一次(写在已删掉的那个函数的注释里)。
509
-
510
- 认线用**路径尾两段**而不是绝对路径(`line_for_relative_concepts`):测试里的临时库、
511
- 或者用户把库根换了地方,尾两段仍然是 `Chat/Concepts` ⇒ 认得出;用绝对路径的话
512
- 只有仓库里那两个真实根才认得出,别的地方会安静地退化成"没有另一条线"。
513
- """
514
- norm = str(concept_dir).replace('\\', '/').rstrip('/')
515
- parts = [p for p in norm.split('/') if p]
516
- if len(parts) < 2:
517
- return None
518
- my_line = line_for_relative_concepts('/'.join(parts[-2:]))
519
- if not my_line:
520
- return None # 库外的目录:那里没有"另一条线"
521
- root = os.path.dirname(os.path.dirname(norm))
522
- for line_id, _r, concepts in KNOWLEDGE_LINES:
523
- if line_id == my_line:
524
- continue
525
- candidate = os.path.join(root, *concepts.split('/'))
526
- if os.path.isdir(candidate):
527
- return candidate
528
- return None
529
-
530
-
531
- def concept_names(concept_dir):
532
- """那个目录里的概念名(文件名去掉 `.md`)。读不出来就返回空集(不猜)。"""
533
- try:
534
- return {f[:-3] for f in os.listdir(str(concept_dir)) if f.endswith('.md')}
535
- except OSError:
536
- return set()
537
-
538
-
539
- class LinkNamer:
540
- """把概念名渲染成链接目标 —— **跨线同名时才带路径**,其余原样。
541
-
542
- 为什么要一个对象而不是每处写一句:**渲染点有三处**(模型建页、零模型建页、索引表),
543
- 各写一份的话漏一处就会在库里留下一批裸的二义链接,而它只在有人跑 `wiki lint` 时
544
- 才看得见。判断"要不要带路径"只需要另一条线的**文件名集合**,所以构造一次就够。
545
- """
546
-
547
- def __init__(self, line_id, other_names=()):
548
- self.line_id = line_id or ''
549
- self.other_names = set(other_names)
550
-
551
- @classmethod
552
- def for_out_dir(cls, out_dir):
553
- """按输出目录定线,并顺手把另一条线的名字读出来(一次 listdir)。
554
-
555
- 认线用**路径尾两段**(`Chat/Concepts`),所以测试里的临时库、或库根换了地方都认得出。
556
- 认不出线(`--output` 指到库外的任意目录)时 **`line_id` 为空 → 一律渲染成裸名字**:
557
- 那是"库外目录"的既有行为,而不是按猜出来的线写一堆带路径的链接。
558
- """
559
- parts = [p for p in str(out_dir).replace('\\', '/').rstrip('/').split('/') if p]
560
- line_id = line_for_relative_concepts('/'.join(parts[-2:])) if len(parts) >= 2 else None
561
- if not line_id:
562
- return cls('', ())
563
- sibling = sibling_concepts_dir(out_dir)
564
- return cls(line_id, concept_names(sibling) if sibling else ())
565
-
566
- def __call__(self, name):
567
- if not self.line_id:
568
- return name
569
- return concept_link_target(self.line_id, name, self.other_names)
570
-
571
-
572
- def concept_key(line_id, name):
573
- """线 id + 概念名 → 跨线唯一的主键。
574
-
575
- **为什么需要**:两条线各有一张同名页是设计使然(用户 2026-10-10 要求两个概念
576
- 命名空间分开),而图谱的主键原来就是文件 stem ⇒ 两棵目录里的 `DeepSeek` 是
577
- **同一个键**。2026-10-10 实测:合并图里 49,956 个节点里有 36 组撞键,前端
578
- `new Map(NODES.map(n => [n.id, n]))` 会静默只留一个 —— 节点数看着对,少掉的那个
579
- 却点不到。同名的两页靠这个键分开。
580
- """
581
- return '%s%s%s' % (line_id, LINE_SEP, name)
582
-
583
-
584
- def split_concept_key(key):
585
- """主键 → (线 id, 名字);**认不出线就返回 `(None, 原样)`,不猜**。
586
-
587
- 兼容没有线前缀的旧主键(认不出线的情形),调用方按"没有线"处理。
588
- """
589
- line_id, sep, name = str(key).partition(LINE_SEP)
590
- if not sep or line_id not in LINE_LABELS:
591
- return None, str(key)
592
- return line_id, name
593
-
594
-
595
- def line_label(line_id):
596
- """线 id → 展示名;认不出返回 None(调用方自己决定"无线"怎么写)。"""
597
- return LINE_LABELS.get(line_id)
598
-
599
- # 一篇文章**没能被分类**时落到哪一类。
600
- #
601
- # 收成一处是因为它曾经在写入路径上有两个不同的答案、相隔七行:
602
- # `.articles.json` 那条路默认 `''`(空主题),而分组/落 md 那条路默认 `'学术'`。
603
- # 于是一篇文章在 md 里写 `topic: 学术`、在 json 里写 `topic: ""`——
604
- # **同一次运行、同一篇文章,两个产物给出不同的主题**,而且都不报错。
605
- #
606
- # 真实后果(2026-09-04 的产出,178 篇):md 全落在 `学术/` 目录、frontmatter 写
607
- # `topic: 学术`(其中两篇是「OpenAI 深夜发布 GPT-6」「专为高管准备的 AI 助手」,
608
- # 明显不该是学术),而 json 里同一批全是 `""` —— 报告那条路读 json,
609
- # 判据 `topic != FOCUS_TOPIC and relevance != '高'` 就把它们整批静默排除了。
610
- #
611
- # 什么情况下会走到这里:`daily --no-ai`、或者没配 API key 时,Phase 2(分类)整段
612
- # 不跑,文章 dict 里**根本没有 `topic` 键**,而 Phase 3 照常写文件。
613
- #
614
- # 这个值是**兜底不是判断**:落到这里的文章其实是"未分类"。之所以不新造一个
615
- # `未分类` 类别,是因为它要额外维护一个目录、一处 HTML 分栏、以及下游对
616
- # 主题字面量的比较;改用 `未分类` 属于改既有契约,得单独决策。
617
- DEFAULT_TOPIC = '学术'
618
-
619
- # 每一类的判据。既生成提示词里的判断规则,也直接作为 Jev 的 choice criteria。
620
- TOPIC_CRITERIA = {
621
- 'AI': 'AI大模型/Agent/编程/开源/科技产品/工具教程',
622
- '投资': '股票基金/融资/经济分析/商业市场',
623
- '新闻': '时事政策/社会热点/娱乐/招聘促销/会议通知',
624
- '文学': '散文小说/美食旅游/生活随笔/历史文化',
625
- '学术': '科研论文/期刊文章/实验室成果/学术会议/高校研究',
626
- '政治': '党政理论/政策解读/领导人讲话/官方评论',
627
- }
628
-
629
- # 相关度的三个档位。与 `TOPICS` 一样是**下游按字面量比较**的封闭词表:
630
- # `generate_ai_report.py`、`enrich_backlinks.py`、`generate_html.py` 都在比
631
- # `'高'`/`'中'`/`'低'` 这三个字。定义收在这里,`jev_client` 与 `biz_daily`
632
- # 都从这里取——原先 `jev_client` 一份、`biz_daily` 的成员校验里再写一份。
633
- #
634
- # **顺序即语义**:`jev_client.RELEVANCE_LEVELS` 是给模型看的判据文本,按下标与
635
- # 这个列表一一对应(有测试钉住),所以不要重排。
636
- RELEVANCE_NAMES = ['低', '中', '高']
637
-
638
- # 一篇文章**没能被判相关度**时落到哪一档。和 `DEFAULT_TOPIC` 是同一类东西:
639
- # 兜底不是判断。之所以要具名,是因为"全库 2199/2201 篇都是「中」"这件事的答案
640
- # 就在这里——分类失败的每条路径最终都落到这个值,而 `'中'` 读起来是
641
- # 「有启发性」(正面评价),不是「没判断」。值不变更(改档位会动到下游的
642
- # 收录判据),只是把它写在一处,好让它可被搜索、可被讨论。
643
- DEFAULT_RELEVANCE = '中'
644
-
645
-
646
- def load_config():
647
- with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
648
- return json.load(f)
649
-
650
-
651
- # ---- 「不说话地失败」的最小护栏 ---------------------------------------------
652
- # 这个文件里有一批函数**故意不抛**(跨机器拷配置时,解不开的密文该表现为"没配",
653
- # 而不是让整条命令崩)。但"不抛"不等于"不说":`except: return ''` 会让调用方
654
- # 与用户都以为"本来就没有",而真实原因是**解密失败/配置坏了**。
655
- # 所以这里只做一件事:同一条原因**说一次**,然后照旧返回空值。
656
- # 只说一次是必须的——这些函数每次请求/每次重排都会被调用,刷屏等于没警告。
657
- _WARNED_ONCE = set()
658
-
659
-
660
- def warn_once(tag, message):
661
- """同一条原因只往 stderr 说一次(tag 相同即视为同一条)。"""
662
- if tag in _WARNED_ONCE:
663
- return
664
- _WARNED_ONCE.add(tag)
665
- import sys as _sys
666
- print('[WARN] ' + message, file=_sys.stderr)
667
- _sys.stderr.flush()
668
-
669
-
670
- def reset_warn_once():
671
- """测试用:清空去重表。"""
672
- _WARNED_ONCE.clear()
673
-
674
-
675
- # 配置里"不想在日报里看到的主题"的键名。值形如 `新闻,投资,学术`。
676
- EXCLUDED_TOPICS_KEY = 'dailyExcludeTopics'
677
-
678
-
679
- def excluded_topics(config=None, explicit='', protected=()):
680
- """用户不想在日报里看到的主题集合。`explicit`(命令行)覆盖配置。
681
-
682
- **这是展示层的开关,不是抓取层的**:正文照常抓取、照常归档,只是不出现在
683
- 日报里。理由是实测:拉之前只有来源+标题+摘要,按它判类型**不可靠**(与来源级
684
- 比对一致率仅 60%、误伤 48/217),而**排除是不可逆的**——没抓取就没归档。
685
- 放在展示层还换来一件事:改主意不用重抓,改个配置重新生成报告即可。
686
-
687
- 写错的主题名会被忽略并**打一行 WARN**(静默忽略会让人以为过滤生效了)。
688
-
689
- `protected` 是不许被排除的主题(呼叫方传自己的「焦点主题」)。它比「未知主题名」
690
- 更值得拦一下:把焦点主题排掉,报告要么没有主体、要么直接报「未找到文章」
691
- **指错方向**(那句话说去跑 biz_daily,可文章其实在)。同样 WARN 后忽略。
692
- """
693
- raw = explicit or (config or {}).get(EXCLUDED_TOPICS_KEY, '') or ''
694
- names = [t.strip() for t in str(raw).replace(',', ',').split(',') if t.strip()]
695
- out = set()
696
- for name in names:
697
- if name in TOPICS:
698
- if name in protected:
699
- print('[WARN] %s 里的「%s」是这份报告的主体,不能排除,已忽略'
700
- % (EXCLUDED_TOPICS_KEY, name))
701
- continue
702
- out.add(name)
703
- else:
704
- print('[WARN] %s 里的「%s」不是已知主题,已忽略(可选:%s)'
705
- % (EXCLUDED_TOPICS_KEY, name, '/'.join(TOPICS)))
706
- return out
707
-
708
-
709
- # === 来源级先验:"哪个号稳定发哪一类"从真实判断里长出来 ===
710
- #
711
- # 为什么不写死一张表:拉取之前只有来源+标题+摘要,按它判类型实测只有 60% 一致率、
712
- # 误伤 48/217(见 excluded_topics 的说明)。但**抓回来之后**每次都有 Jev 按正文判的
713
- # 主题,日积月累就看得出某个号一贯发什么。这张表因此是长出来的,不是我编的。
714
- #
715
- # 它现在只用来**报数**,不用来跳过任何东西:跳过是不可逆的。
716
- SOURCE_TOPICS_FILE = 'source_topics.json'
717
- SOURCE_PRIOR_MIN_SAMPLES = 8 # 少于这么多篇不下结论
718
- SOURCE_PRIOR_SHARE = 0.8 # 某一类占比到这个数才算"稳定地只发这一路"
719
-
720
-
721
- def source_topics_path(path=None):
722
- """先验表的落盘位置。默认与 config.json 同目录(家目录)。"""
723
- return path or os.path.join(os.path.dirname(CONFIG_PATH), SOURCE_TOPICS_FILE)
724
-
725
-
726
- def load_source_topics(path=None):
727
- """读先验表:`{来源: {主题: 次数}}`。
728
-
729
- 读不出、坏掉、不是字典——一律返回 `{}`。这是**辅助**数据,坏了不该让日报挂掉。
730
- """
731
- try:
732
- with open(source_topics_path(path), encoding='utf-8') as f:
733
- data = json.load(f)
734
- except (OSError, ValueError):
735
- return {}
736
- if not isinstance(data, dict):
737
- return {}
738
- out = {}
739
- for source, counts in data.items():
740
- if isinstance(source, str) and source.strip() and isinstance(counts, dict):
741
- out[source] = counts
742
- return out
743
-
744
-
745
- def record_source_topics(pairs, path=None):
746
- """把这一批 `(来源, 主题)` 累加进先验表,返回摘要 dict。
747
-
748
- **只增不减**(与 frontmatter 的 only-increase 同一个纪律):累计次数是你事后判断
749
- "这个号到底稳不稳"的唯一依据,抹掉就回不来了。
750
- 来源为空、主题不在 TOPICS 里的都不计——计了会污染分母,让占比算错。
751
- """
752
- import tempfile as _tmp # 模块顶没导入它:本文件里它是函数内导入的
753
- store = load_source_topics(path)
754
- added = skipped = 0
755
- for source, topic in pairs:
756
- source = (source or '').strip()
757
- topic = (topic or '').strip()
758
- if not source or topic not in TOPICS:
759
- skipped += 1
760
- continue
761
- bucket = store.setdefault(source, {})
762
- try:
763
- bucket[topic] = int(bucket.get(topic, 0)) + 1
764
- except (TypeError, ValueError):
765
- bucket[topic] = 1 # 值被改坏了,从 1 重新数,别让它把整个文件废掉
766
- added += 1
767
- target = source_topics_path(path)
768
- try:
769
- os.makedirs(os.path.dirname(target) or '.', exist_ok=True)
770
- fd, tmp_path = _tmp.mkstemp(suffix='.json', dir=os.path.dirname(target) or '.')
771
- try:
772
- with os.fdopen(fd, 'w', encoding='utf-8') as f:
773
- json.dump(store, f, ensure_ascii=False, indent=1, sort_keys=True)
774
- os.replace(tmp_path, target)
775
- except Exception:
776
- if os.path.exists(tmp_path):
777
- os.unlink(tmp_path)
778
- raise
779
- except OSError as exc:
780
- # 写不进去(权限、磁盘)不能让日报失败——它只是记个账。
781
- return {'sources': len(store), 'added': added, 'skipped': skipped,
782
- 'error': str(exc)}
783
- return {'sources': len(store), 'added': added, 'skipped': skipped, 'error': ''}
784
-
785
-
786
- def stable_source_topic(counts, min_samples=SOURCE_PRIOR_MIN_SAMPLES,
787
- share=SOURCE_PRIOR_SHARE):
788
- """某个来源是不是已经**稳定地只发某一类**。
789
-
790
- 返回 `(主题, 占比, 样本数)`;还判不了返回 `None`。
791
- **`None` 是"还不知道",不是"没有主题"**——调用方必须把这两种情况分开,
792
- 别把 `None` 当成某个默认主题用(那正好是"来源判不准"最坏的那种错)。
793
- """
794
- clean = {}
795
- for topic, count in (counts or {}).items():
796
- try:
797
- value = int(count)
798
- except (TypeError, ValueError):
799
- continue
800
- if value > 0:
801
- clean[topic] = value
802
- total = sum(clean.values())
803
- if total < min_samples:
804
- return None
805
- topic, top = max(clean.items(), key=lambda kv: kv[1])
806
- ratio = top / total
807
- if ratio < share:
808
- return None
809
- return (topic, ratio, total)
810
-
811
-
812
- def source_prior_candidates(store, exclude, **kwargs):
813
- """先验里"已经稳到能判"且落在排除集里的来源,按样本数从多到少。
814
-
815
- 只报出来给人看,**不据此跳过任何东西**:跳过是不可逆的,而这张表还在长。"""
816
- rows = []
817
- for source, counts in (store or {}).items():
818
- stable = stable_source_topic(counts, **kwargs)
819
- if stable and stable[0] in (exclude or ()):
820
- rows.append((source, stable[0], stable[1], stable[2]))
821
- return sorted(rows, key=lambda row: (-row[3], row[0]))
822
-
823
- def decrypt_lock(locked_str: str) -> str:
824
- if not locked_str or not locked_str.startswith('lock:'):
825
- return locked_str
826
- from cryptography.hazmat.primitives.ciphers.aead import AESGCM
827
- from cryptography.hazmat.primitives import hashes
828
- from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
829
- from cryptography.hazmat.backends import default_backend
830
-
831
- raw = base64.b64decode(locked_str[5:])
832
- salt, iv, auth_tag, ciphertext = raw[:16], raw[16:28], raw[28:44], raw[44:]
833
- machine_id = f'{socket.gethostname()}-{os.environ.get("USERNAME", "")}-weflow-cli'
834
- kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=100000,
835
- backend=default_backend())
836
- key = kdf.derive(machine_id.encode('utf-8'))
837
- aesgcm = AESGCM(key)
838
- return aesgcm.decrypt(iv, ciphertext + auth_tag, None).decode()
839
-
840
-
841
- def get_db_config(config=None):
842
- if config is None:
843
- config = load_config()
844
- return {
845
- 'nt_db': config.get('ntDbPath', ''),
846
- 'nt_key': decrypt_lock(config.get('ntKey', '')),
847
- 'nt_salt': config.get('ntSalt', ''),
848
- 'contact_db': config.get('contactDbPath', ''),
849
- 'contact_key': decrypt_lock(config.get('contactKey', '')),
850
- 'contact_salt': config.get('contactSalt', ''),
851
- }
852
-
853
-
854
- def biz_message_db(config=None):
855
- """订阅号(公众号)消息库 `biz_message_0.db` 的路径与钥匙,返回 `(db, key_hex, salt_hex)`。
856
-
857
- **微信 4.x:钥匙由全库 passphrase 派生**(PBKDF2-HMAC-SHA512,256000 轮,32 字节),
858
- 盐取**库文件头 16 字节**。`favPassphrase` 优先,退而用 `decryptKey`;
859
- 更早的 `bizKey`/`bizSalt`(配了就直用)只作兼容保留——那两个键在本机早就不存在了。
860
-
861
- 抽成一个函数,是因为这把钥匙原来有**三份实现**:`biz_daily` 那份是 passphrase 派生(可用),
862
- `chat_stats` 与 `mcp_bridge` 那两份只认 `bizKey`(在本机必然报『缺少密钥』,而照它们的提示去
863
- `config set bizKey` 还会撞上『该键不在 CLI 可写白名单里』——越修越远)。
864
- """
865
- import hashlib
866
- if config is None:
867
- config = load_config()
868
- nt_db = str(config.get('ntDbPath', ''))
869
- msg_dir = os.path.dirname(nt_db.replace('\\', '/'))
870
- db = os.path.join(msg_dir, 'biz_message_0.db')
871
- key_enc = config.get('bizKey', '')
872
- salt = config.get('bizSalt', '')
873
- if key_enc and salt:
874
- return db, decrypt_lock(key_enc), salt
875
- pass_enc = config.get('favPassphrase', '') or config.get('decryptKey', '')
876
- if not pass_enc or not os.path.exists(db):
877
- raise RuntimeError(
878
- '缺少订阅号数据库密钥: 请运行 weflow-cli fav set-key --passphrase <64位hex> '
879
- '配置全库 passphrase (自动派生各库密钥)')
880
- with open(db, 'rb') as fh:
881
- salt = fh.read(16).hex()
882
- key = hashlib.pbkdf2_hmac('sha512', bytes.fromhex(decrypt_lock(pass_enc)),
883
- bytes.fromhex(salt), 256000, dklen=32).hex()
884
- return db, key, salt
885
-
886
- def get_api_key(config=None) -> str:
887
- """读取 AI API key (自动解密 lock: 前缀)。"""
888
- if config is None:
889
- config = load_config()
890
- return decrypt_lock(config.get('deepseekApiKey', ''))
891
-
892
-
893
- def get_dashscope_key(config=None) -> str:
894
- """读取阿里云百炼(DashScope)的 embedding key,自动解密 lock: 前缀。
895
-
896
- 和 `get_api_key` / `get_typesafe_key` 同一形状:**不要直接 config.get**——
897
- 这个键进了 `ENCRYPTED_KEYS`,磁盘上是密文。解密失败回空串而不是抛,
898
- 理由同 get_typesafe_key(跨机器拷配置时它该表现为"没配",不是崩)。
899
- """
900
- if config is None:
901
- try:
902
- config = load_config()
903
- except Exception as exc:
904
- warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
905
- % (CONFIG_PATH, type(exc).__name__, exc))
906
- return ''
907
- try:
908
- return decrypt_lock(config.get('dashscopeApiKey', ''))
909
- except Exception as exc:
910
- warn_once('dashscopeApiKey', '配置项 dashscopeApiKey 解不开(%s: %s)—— 当作"没配",语义检索会退成关键词'
911
- % (type(exc).__name__, exc))
912
- return ''
913
-
914
-
915
- def get_typesafe_key(config=None) -> str:
916
- """读取 TypeSafe (Jev) 决策模型的 key,自动解密 lock: 前缀。
917
-
918
- 解密失败**回空串而不是抛**:TS 侧 lockDecrypt() 失败就是静默回 '',而
919
- decrypt_lock() 是抛的。两份 config.json 一跨机器拷贝,同一份密文在
920
- TS 侧表现为"没配 key"、在这里表现为崩——那会让日报整个挂掉,而它本该
921
- 只是退回到 LLM 解析路径。
922
- """
923
- if config is None:
924
- try:
925
- config = load_config()
926
- except Exception as exc:
927
- warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
928
- % (CONFIG_PATH, type(exc).__name__, exc))
929
- return ''
930
- try:
931
- return decrypt_lock(config.get('typesafeApiKey', ''))
932
- except Exception as exc:
933
- warn_once('typesafeApiKey', '配置项 typesafeApiKey 解不开(%s: %s)—— 当作"没配",决策模型退回 LLM 解析'
934
- % (type(exc).__name__, exc))
935
- return ''
936
-
937
-
938
- def get_openrouter_key(config=None) -> str:
939
- """读取 OpenRouter 的 key(决策模型的第二条通路,D-120),自动解密 lock: 前缀。
940
-
941
- 与 `get_typesafe_key` 逐字同一条规矩:**跨机器拷配置时表现为"没配",不是崩**。
942
- 解密失败回空串之后,`create_client()` 会返回 None,调用方按老路退回 ——
943
- 这是"这条路没配好"的**安全**失效方向。
944
- """
945
- if config is None:
946
- try:
947
- config = load_config()
948
- except Exception as exc:
949
- warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
950
- % (CONFIG_PATH, type(exc).__name__, exc))
951
- return ''
952
- try:
953
- return decrypt_lock(config.get('openrouterApiKey', ''))
954
- except Exception as exc:
955
- warn_once('openrouterApiKey', '配置项 openrouterApiKey 解不开(%s: %s)—— 当作"没配",OpenRouter 那条路会停用'
956
- % (type(exc).__name__, exc))
957
- return ''
958
-
959
-
960
- def get_judge_provider(config=None) -> str:
961
- """决策模型走哪条通路:`native`(默认)、`chat`、`openrouter` 或 `local`(本机决策模型)。
962
-
963
- **默认必须是 native**:这个开关存在的意义是"本机原生被地区封锁时换条路",
964
- 而不是替所有用户改行为。取值不认识时也回 native —— 打错的配置项不该把判断整层关掉。
965
- """
966
- if config is None:
967
- try:
968
- config = load_config()
969
- except Exception:
970
- return 'native'
971
- value = str((config or {}).get('judgeProvider') or '').strip().lower()
972
- return value if value in ('native', 'chat', 'openrouter', 'local') else 'native'
973
-
974
-
975
- # 本地决策模型服务的默认地址:`python decide.py serve --port 8090` 起的那个(D-127)。
976
- DEFAULT_JUDGE_LOCAL_URL = 'http://127.0.0.1:8090'
977
-
978
-
979
- def get_judge_local_url(config=None) -> str:
980
- """`judgeProvider=local` 时往哪发:配置项 `judgeLocalUrl`,没配就用默认地址。
981
-
982
- 与 `get_chat_endpoint` 同一态度:配置读不出来就当没配,返回默认值而不是抛 ——
983
- 这里抛会让"本机能跑但配置少一个键"变成整条判断链不可用。
984
- """
985
- if config is None:
986
- try:
987
- config = load_config()
988
- except Exception:
989
- return DEFAULT_JUDGE_LOCAL_URL
990
- value = str((config or {}).get('judgeLocalUrl') or '').strip()
991
- return value.rstrip('/') or DEFAULT_JUDGE_LOCAL_URL
992
-
993
-
994
- def get_chat_endpoint(config=None):
995
- """`judgeProvider=chat` 时该往哪个 (base_url, model) 发 —— 与"中转站"同一条口径。
996
-
997
- 故意只做一件事:**委托 `_custom_endpoint()`**。那个函数是 `aiBaseUrl`/`aiModel` 的权威读法
998
- (base 与 model 成对出现),判断器另写一份就会在"只配了 base 没配 model"这类组合上分叉,
999
- 而分叉的表现是"发去了错的端点"——不报错、只是判断质量变了。没配 `aiBaseUrl` 时返回 (None, None)。
1000
- """
1001
- try:
1002
- return _custom_endpoint()
1003
- except Exception:
1004
- return None, None
1005
-
1006
-
1007
- def get_ai_engine(config=None) -> str:
1008
- """配置里选的对话引擎:`local` / `deepseek`(默认)/ `claude` / `ollama`。
1009
-
1010
- **决策模型的 `chat` 通路必须读它**:`judgeProvider=chat` 的语义是"用**配置里的**对话模型",
1011
- 而"配置里的"到底指谁只有 `aiEngine` 说得清。2026-10-18 之前那条路无条件用 `deepseekApiKey`,
1012
- 于是 `aiEngine=local` 的用户一开 `judgeProvider=chat` 就会把文章正文发去 DeepSeek ——
1013
- 这不是"多花点钱",是**用户明确选了不出境之后又被送出境**。
1014
- """
1015
- if config is None:
1016
- try:
1017
- config = load_config()
1018
- except Exception as exc:
1019
- warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
1020
- % (CONFIG_PATH, type(exc).__name__, exc))
1021
- return 'deepseek'
1022
- return str((config or {}).get('aiEngine') or 'deepseek').strip().lower()
1023
-
1024
-
1025
- def get_openrouter_model(config=None) -> str:
1026
- """OpenRouter 上用的模型 id(默认 `typesafe/jev-router`,即 TypeSafe 发布的决策模型)。
1027
-
1028
- ⚠️ **地区限制是逐模型的**(实测 2026-10-17):`typesafe/jev-router` 与 `:free` 模型能通,
1029
- 付费通用模型(`openai/gpt-4o-mini`)在本地区返回 403。换模型必须重测,见 D-120。
1030
- """
1031
- if config is None:
1032
- try:
1033
- config = load_config()
1034
- except Exception as exc:
1035
- warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
1036
- % (CONFIG_PATH, type(exc).__name__, exc))
1037
- return ''
1038
- return str((config or {}).get('openrouterJudgeModel') or '').strip()
1039
-
1040
-
1041
- # ======================================================================
1042
- # Markdown / Frontmatter
1043
- # ======================================================================
1044
-
1045
- def write_with_frontmatter(filepath: str, frontmatter: dict, body: str):
1046
- import tempfile as _tmp
1047
- fm_lines = ['---']
1048
- for k, v in frontmatter.items():
1049
- if isinstance(v, list):
1050
- fm_lines.append(f'{k}: [{", ".join(v)}]')
1051
- elif isinstance(v, str) and ('"' in v or ':' in v or '#' in v):
1052
- vs = v.strip()
1053
- fm_lines.append(f'{k}: "{vs}"' if not (vs.startswith('"') and vs.endswith('"'))
1054
- else f'{k}: {v}')
1055
- else:
1056
- fm_lines.append(f'{k}: {v}')
1057
- fm_lines.append('---')
1058
- fm_block = '\n'.join(fm_lines) + '\n\n'
1059
- dir_name = os.path.dirname(filepath) or '.'
1060
- fd, tmp_path = _tmp.mkstemp(suffix='.md', dir=dir_name)
1061
- try:
1062
- with os.fdopen(fd, 'w', encoding='utf-8') as f:
1063
- f.write(fm_block)
1064
- f.write(body)
1065
- os.replace(tmp_path, filepath)
1066
- except Exception:
1067
- if os.path.exists(tmp_path):
1068
- os.unlink(tmp_path)
1069
- raise
1070
-
1071
-
1072
- def _unquote(item: str) -> str:
1073
- """拆掉**成对的**外层引号;不成对就一个字都不动。
1074
-
1075
- 原来这里写的是 `item.strip().strip('"\\'')` —— `.strip()` 的语义是"剥掉首尾**所有**这些
1076
- 字符",于是项**内部**末尾的引号也被吃掉:`AI 长出"手脚"` 读回来变成 `AI 长出"手脚`。
1077
- 一个字符之差、不报错,下游只表现为"这个名字对不上"(2026-10-01:一张概念页的别名因此
1078
- 永远匹配不上卡片里的链接,那页被报成孤儿)。成对判断把它修好,同时保住"外层引号是语法"。
1079
- """
1080
- item = item.strip()
1081
- if len(item) >= 2 and item[0] == item[-1] and item[0] in '"\'':
1082
- return item[1:-1]
1083
- return item
1084
-
1085
-
1086
- def parse_frontmatter(content: str) -> tuple[dict, str]:
1087
- """解析 Markdown 文件中的 YAML frontmatter。"""
1088
- if not content.startswith('---'):
1089
- return {}, content
1090
- end = content.find('---', 3)
1091
- if end == -1:
1092
- return {}, content
1093
- fm_text = content[3:end].strip()
1094
- body = content[end + 3:].lstrip('\n')
1095
- result = {}
1096
- for line in fm_text.split('\n'):
1097
- line = line.strip()
1098
- if not line or ':' not in line:
1099
- continue
1100
- key, _, val = line.partition(':')
1101
- key = key.strip()
1102
- val = val.strip()
1103
- if val.startswith('[') and val.endswith(']'):
1104
- inner = val[1:-1]
1105
- items = [_unquote(v) for v in inner.split(',')] if inner.strip() else []
1106
- result[key] = items
1107
- elif val.startswith('"') and val.endswith('"'):
1108
- result[key] = val[1:-1]
1109
- elif val.startswith("'") and val.endswith("'"):
1110
- result[key] = val[1:-1]
1111
- else:
1112
- result[key] = val
1113
- return result, body
1114
-
1115
-
1116
- NORM_STRIP_RE = re.compile(r'[\s\-_·、,.。::/\\()()\[\]【】"\’]+')
1117
-
1118
-
1119
- def normalize_concept_name(name: str) -> str:
1120
- """判「这两个名字是不是同一个概念」用的规范形。
1121
-
1122
- 去空格、连字符、下划线、标点,转小写,再去掉英文复数尾 —— 实测这样能把
1123
- `GPT 5.6`/`GPT-5.6`/`GPT5.6`、`AI 工具`/`AI工具`、`AI skill`/`AI skills` 收到一起。
1124
-
1125
- **它也会把 `news`/`new` 收到一起**,所以它只用来**提议**合并;真正的裁决是
1126
- "留来源最多的那张页",而且删之前还有 `--dry-run` 可以看名单。
1127
-
1128
- 住在这里(而不是 `compile_wiki`)是因为**两个读者必须用同一个口径**:`compile_wiki
1129
- --merge-duplicates` 拿它决定合并谁,`wiki_lint` 拿它报告"还有哪些是同一个节点的两种写法"。
1130
- 两边各写一份的话,体检报出来的名单就会有一半是合并工具不会动的 —— 2026-09-28 之前正是
1131
- 如此:lint 用的是另一套(把所有非字母数字都去掉),于是 `DeepSeek++` 在它眼里等于
1132
- `DeepSeek`,而合并工具并不这么认为。
1133
- """
1134
- stripped = NORM_STRIP_RE.sub('', name.casefold().strip())
1135
- return re.sub(r'(?<=[a-z])s$', '', stripped)
1136
-
1137
-
1138
- def format_wikilinks(concepts: list[tuple[str, str]]) -> str:
1139
- lines = ['## 相关概念', '']
1140
- for name, desc in concepts:
1141
- if desc:
1142
- lines.append(f'- [[{name}]] — {desc}')
1143
- else:
1144
- lines.append(f'- [[{name}]]')
1145
- return '\n'.join(lines) + '\n'
1146
-
1147
-
1148
- # ======================================================================
1149
- # 用户定位 & 行动建议(向后兼容)
1150
- # ======================================================================
1151
-
1152
- DEFAULT_USER_PROFILE = (
1153
- '你是一名对AI感兴趣的环境科学研究生,研究方向是计算机与环境的交叉领域'
1154
- '(如环境模型、大气污染模拟、遥感反演、环境大数据分析、LCA生命周期评估等)。'
1155
- '你关注AI工具如何提升科研效率、环境数据处理新技术、以及交叉领域的学术机会。'
1156
- )
1157
-
1158
- ACTION_PROMPT = """基于以下文章,为读者生成可落地的行动建议。
1159
-
1160
- 【读者定位】
1161
- {profile}
1162
-
1163
- 【文章信息】
1164
- 标题:{title}
1165
- 来源:{source}
1166
- 主题:{topic}
1167
- 摘要:{summary}
1168
-
1169
- 【正文节选】
1170
- {content}
1171
-
1172
- 【输出格式】
1173
- ### 相关度
1174
- (高/中/低 + 一句话解释)
1175
-
1176
- ### 行动建议
1177
- - **立即可做**:1-2个今天就能执行的具体动作
1178
- - **本周计划**:1个本周可以推进的中期动作
1179
- - **长期关注**:1个值得持续跟踪的方向(相关度为低时省略)"""
1180
-
1181
-
1182
- def generate_action_suggestion(title: str, source: str, topic: str,
1183
- summary: str, content: str,
1184
- api_key: str, profile: str = '',
1185
- engine_type: str = 'deepseek',
1186
- max_tokens: int = 1500) -> str:
1187
- if not profile:
1188
- profile = DEFAULT_USER_PROFILE
1189
- prompt = ACTION_PROMPT.format(
1190
- profile=profile, title=title, source=source, topic=topic,
1191
- summary=summary[:500], content=content[:3000],
1192
- )
1193
- engine = create_engine(engine_type, api_key)
1194
- return engine.chat(prompt, max_tokens=max_tokens)
1195
-
1196
- # ---------------------------------------------------------------- 知识卡片的公共件
1197
- #
1198
- # `chat_notes.py`(对话线)与 `article_notes.py`(文章线)产出的是**同一种卡片**:
1199
- # 带 frontmatter + `## AI 摘要` + `[[概念]] — 说明` 的 markdown,都喂给 `compile_wiki`。
1200
- # 所以这几样放在这里,一份实现两边用——两处各写一份,早晚有一处会漏。
1201
-
1202
- SEPARATOR = '—' # 与 `compile_wiki` 认的破折号一致(那条描述要能被它读回描述)
1203
-
1204
- # 文件名里不许出现的字符(Windows 一套 + 控制字符)
1205
- _ILLEGAL_IN_NAME = set('\\/:*?"<>|') | {chr(code) for code in range(0, 32)}
1206
-
1207
-
1208
- def plain(text) -> str:
1209
- """自由文本里的 `[[…]]` 一律拆掉。
1210
-
1211
- 纪律是「wikilink 只由我们的代码渲染,不由模型写」:`compile_wiki.scan_articles` 收正文里
1212
- **所有** wikilink,它不会问这是谁写的——模型在摘要里随手一个 `[[X]]`,就会凭空长出一个概念。
1213
- """
1214
- out, index = [], 0
1215
- source = str(text or '')
1216
- while True:
1217
- start = source.find('[[', index)
1218
- if start < 0:
1219
- out.append(source[index:])
1220
- break
1221
- end = source.find(']]', start)
1222
- if end < 0:
1223
- out.append(source[index:])
1224
- break
1225
- out.append(source[index:start])
1226
- out.append(source[start + 2:end])
1227
- index = end + 2
1228
- return ''.join(out).replace('[[', '').replace(']]', '')
1229
-
1230
-
1231
- def note_path(out_root, name: str, fallback: str = '未命名'):
1232
- """一张卡一个文件。名字要过文件名安全:会话/文章名里有 `/` 会写到别的目录去。"""
1233
- from pathlib import Path as _Path
1234
- cleaned = ''.join('_' if ch in _ILLEGAL_IN_NAME else ch for ch in str(name))
1235
- cleaned = ' '.join(cleaned.split())[:80].strip().strip('.')
1236
- return _Path(out_root) / ('%s.md' % (cleaned or fallback))
1237
-
1238
-
1239
- # ---------------------------------------------------------------- 微信正文里的界面残留
1240
-
1241
- # 微信文章正文会带**阅读器自己的界面文字**("在小说阅读器读本章"那一串),以及文末的
1242
- # 原创标记与公众号名重复。实测:库里 1633 篇笔记,**1484 篇(91%)**的摘要里混着它
1243
- # (先按"整份文件里出现某条短语"数是 330,那是数漏了——短语有好几种写法,按摘要层数才对)。
1244
- # 这份清理在真库上跑过:1484 篇 → 清理后 **0 篇**仍带残留。
1245
- #
1246
- # `classify_daily.py` 里本来有一份 `AD_PATTERNS`,但它只用在日报那条路上,
1247
- # **写笔记那条路(`create_reading_notes.py`)一次都没清洗**——而且对着真串比对,
1248
- # 那份表还漏了一条:真串是「在**公众号**小说中沉浸阅读」,表里写的是
1249
- # 「在小说阅读器**中**沉浸阅读」。所以这里一次列全,各处共用。
1250
- _AD_PHRASES = (
1251
- '在小说阅读器读本章',
1252
- '在小说阅读器中沉浸阅读',
1253
- '在公众号小说中沉浸阅读',
1254
- '去阅读',
1255
- 'Scan to Follow',
1256
- '轻触阅读原文',
1257
- '预览时标签不可点',
1258
- '继续滑动看下一个',
1259
- )
1260
-
1261
- _UNDERSCORE_RUN = None # 惰性编译,避免在 import 期做什么重活
1262
- _JS_LINK = None
1263
-
1264
-
1265
- def strip_wx_ads(text: str) -> str:
1266
- """去掉微信正文里那几样界面残留。
1267
-
1268
- 处理顺序有讲究:先删短语,再删下划线长串,最后收拾"原创 + 公众号名重复 N 遍"。
1269
- 只动**已知的**几样——这是给摘要与提示词做清洁,不是改写正文;
1270
- 拿不准的一律留着(宁可留一点噪音,也不要把用户的话删掉)。
1271
- """
1272
- import re as _re
1273
- global _UNDERSCORE_RUN
1274
- if _UNDERSCORE_RUN is None:
1275
- _UNDERSCORE_RUN = _re.compile(r'_{4,}')
1276
- out = str(text or '')
1277
- for phrase in _AD_PHRASES:
1278
- out = out.replace(phrase, ' ')
1279
- out = _UNDERSCORE_RUN.sub(' ', out)
1280
- global _JS_LINK
1281
- if _JS_LINK is None:
1282
- _JS_LINK = _re.compile(r'\[.*?\]\(javascript:void\(0\);?\)')
1283
- out = _JS_LINK.sub(' ', out)
1284
- # 「原创 开源星探 开源星探 开源星探」:公众号名在文末被重复粘了几遍,压成一个
1285
- out = _re.sub(r'原创\s+(\S{2,12})(?:\s+\1\b){1,}\s*', ' ', out)
1286
- out = _re.sub(r'原创\s+[一-龥A-Za-z0-9]{2,12}\s*$', '', out)
1287
- out = _re.sub(r'\b(\S{2,12})(?:\s+\1\b)+', r'\1', out)
1288
- out = _re.sub(r'[ \t]{2,}', ' ', out)
1289
- out = _re.sub(r'\n{3,}', '\n\n', out)
1290
- # 短语被删掉之后可能留下孤零零的空白或行首标点
1291
- out = _re.sub(r'[ \t]+([,。、;:!?])', r'\1', out)
1292
- return out.strip()
1293
-
1294
-
1295
- _FENCED_BLOCK = None
1296
-
1297
-
1298
- def strip_wx_ads_document(text: str) -> str:
1299
- """整篇原文用的清洗:**frontmatter 与围栏代码块一个字不动**,其余同 `strip_wx_ads`。
1300
-
1301
- 删短语的规则**完全复用** `strip_wx_ads`,不另写一套 —— 两处各写一份,迟早会对
1302
- "什么算残留"给出不同答案(本仓库因为这类分叉吃过亏)。
1303
-
1304
- 两处防护各有实测理由:
1305
-
1306
- - **frontmatter**:`去阅读` 是**短词**,完全可能出现在标题里("如何去阅读一本书")。
1307
- 标题被改就等于改文件名对齐关系 —— 笔记靠 `日期-标题` 找原文,改了就对不上了。
1308
- 实测当前 35,060 篇里 0 篇的 frontmatter 含短语,所以这一条是**防未来**,不是修现在。
1309
- - **围栏代码块**:`strip_wx_ads` 末尾那几条空白规整(`[ \\t]{2,}` → ' '、`\\n{3,}` → `\\n\\n`)
1310
- 对摘要是无损的,对**原文**却会吃掉代码缩进 —— 实测 20 篇带围栏、其中 10 篇围栏内容会变
1311
- (HTML/JS 示例)。原文是素材,缩进就是内容。
1312
- """
1313
- if text.startswith('---'):
1314
- end = text.find('\n---', 3)
1315
- front, body = (text[:end + 4], text[end + 4:]) if end != -1 else ('', text)
1316
- else:
1317
- front, body = '', text
1318
- # `strip_wx_ads` 末尾带 `.strip()`,会把正文首尾空白吃掉。frontmatter 与正文之间那两行
1319
- # 空行是正文的一部分,所以这里把首尾空白单独摘出来、洗完再放回去。
1320
- lead = body[:len(body) - len(body.lstrip())]
1321
- tail = body[len(body.rstrip()):]
1322
- core = body.strip()
1323
- global _FENCED_BLOCK
1324
- if _FENCED_BLOCK is None:
1325
- import re as _re
1326
- _FENCED_BLOCK = _re.compile(r'^```.*?^```', _re.S | _re.M)
1327
- # 占位符用 chr(0) 拼,不写字面反斜杠:本仓有"JSON 层吃掉转义"的前科,能避开就避开。
1328
- mark = chr(0) + 'FENCE' + chr(0)
1329
- blocks = []
1330
-
1331
- def stash(match):
1332
- blocks.append(match.group(0))
1333
- return mark + str(len(blocks) - 1) + mark
1334
-
1335
- cleaned = strip_wx_ads(_FENCED_BLOCK.sub(stash, core))
1336
- for index, block in enumerate(blocks):
1337
- cleaned = cleaned.replace(mark + str(index) + mark, block)
1338
- return front + lead + cleaned + tail
1
+ """
2
+ weflow-cli 公共工具函数 — 供 biz_daily / classify_daily / chat_report 等共用。
3
+ """
4
+ import os, json, re, base64, socket, urllib.request, hashlib
5
+ from functools import wraps
6
+
7
+
8
+ # ======================================================================
9
+ # 工具库(静态知识,供报告「行动指南」自动匹配)
10
+ # 按关键词 → GitHub 项目/工具,匹配越靠前优先级越高
11
+ # ======================================================================
12
+
13
+ TOOL_LIBRARY = [
14
+ # --- AI 编程 / Agent ---
15
+ {'keywords': ['cursor', 'agent', 'ide', '编程助手', '代码生成'],
16
+ 'name': 'Cursor', 'url': 'https://github.com/getcursor/cursor',
17
+ 'desc': 'AI 原生 IDE,支持多文件编辑与 Agent 模式'},
18
+ {'keywords': ['windsurf', 'agent', 'ide'],
19
+ 'name': 'Windsurf', 'url': 'https://codeium.com/windsurf',
20
+ 'desc': 'AI 原生 IDE,类 Cursor,支持任务级自动编程'},
21
+ {'keywords': ['continue', 'agent', 'ide', 'vscode'],
22
+ 'name': 'Continue', 'url': 'https://github.com/continuedev/continue',
23
+ 'desc': '开源 VS Code 插件,接入本地/云端大模型'},
24
+
25
+ # --- 大模型 / 推理 ---
26
+ {'keywords': ['deepseek', 'r1', '开源模型'],
27
+ 'name': 'DeepSeek R1', 'url': 'https://github.com/deepseek-ai/DeepSeek-R1',
28
+ 'desc': 'DeepSeek 开源推理模型,中文表现强'},
29
+ {'keywords': ['llama', 'meta', '开源模型'],
30
+ 'name': 'Llama 3', 'url': 'https://github.com/meta-llama/llama3',
31
+ 'desc': 'Meta 开源大模型,社区生态最成熟'},
32
+ {'keywords': ['ollama', '本地部署', '本地推理'],
33
+ 'name': 'Ollama', 'url': 'https://github.com/ollama/ollama',
34
+ 'desc': '一行命令运行本地大模型(macOS/Linux/Windows)'},
35
+ {'keywords': ['lmstudio', '本地部署', '本地推理'],
36
+ 'name': 'LM Studio', 'url': 'https://lmstudio.ai',
37
+ 'desc': '桌面端本地大模型运行器,有 GUI'},
38
+
39
+ # --- 信息聚合 / 日报 / RAG ---
40
+ {'keywords': ['rag', '检索增强', '知识库', '论文'],
41
+ 'name': 'AnythingLLM', 'url': 'https://github.com/Mintplex-Labs/anything-llm',
42
+ 'desc': '开箱即用的 RAG 知识库 + 聊天桌面应用'},
43
+ {'keywords': ['rss', '信息源', '订阅'],
44
+ 'name': 'Fluent Reader', 'url': 'https://github.com/yang991178/fluent-reader',
45
+ 'desc': '现代化 RSS 阅读器,聚合公众号/博客/新闻'},
46
+
47
+ # --- 环境科学 / 交叉方向 ---
48
+ {'keywords': ['遥感', '卫星', '反演', 'landsat', 'sentinel'],
49
+ 'name': 'Google Earth Engine', 'url': 'https://earthengine.google.com',
50
+ 'desc': '大规模遥感影像在线分析平台'},
51
+ {'keywords': ['遥感', '卫星', 'python'],
52
+ 'name': 'xarray', 'url': 'https://github.com/pydata/xarray',
53
+ 'desc': 'N 维数组处理,气象/遥感数据标配'},
54
+ {'keywords': ['大气', '排放清单', '排放'],
55
+ 'name': 'MEIC', 'url': 'https://meicmodel.org',
56
+ 'desc': '中国多尺度排放清单模型'},
57
+ {'keywords': ['lca', '生命周期', '生命周期评估'],
58
+ 'name': 'brightway', 'url': 'https://github.com/brightway-lca/brightway2',
59
+ 'desc': '开源生命周期评估(LCA)框架'},
60
+ {'keywords': ['数值模拟', '大气', '模型', '模拟'],
61
+ 'name': 'WRF', 'url': 'https://github.com/wrf-model/WRF',
62
+ 'desc': '中尺度数值天气预报模型'},
63
+
64
+ # --- 效率工具 ---
65
+ {'keywords': ['obsidian', '笔记', '知识管理'],
66
+ 'name': 'Obsidian', 'url': 'https://obsidian.md',
67
+ 'desc': '本地优先的 Markdown 笔记,双链/图谱'},
68
+ {'keywords': ['anki', '记忆', '复习'],
69
+ 'name': 'Anki', 'url': 'https://apps.ankiweb.net',
70
+ 'desc': '基于间隔重复的闪卡记忆工具'},
71
+
72
+ # --- 一般关键词兜底 ---
73
+ {'keywords': ['投资', '财报', '股价', '股票'],
74
+ 'name': 'Tushare', 'url': 'https://tushare.pro',
75
+ 'desc': '免费金融数据接口(Python)'},
76
+ ]
77
+
78
+
79
+ def match_tools(articles: list[dict], top_n: int = 8) -> list[dict]:
80
+ """从文章的标题/摘要/标签中抽取关键词,匹配 TOOL_LIBRARY。"""
81
+ collected_text = ''
82
+ for a in articles:
83
+ tags = a.get('tags') or []
84
+ text_parts = [
85
+ a.get('title', ''),
86
+ a.get('summary', ''),
87
+ ' '.join(tags) if isinstance(tags, list) else str(tags),
88
+ ]
89
+ collected_text += ' ' + ' '.join(text_parts)
90
+ collected_text = collected_text.lower()
91
+
92
+ ranked = []
93
+ for tool in TOOL_LIBRARY:
94
+ score = sum(1 for kw in tool['keywords'] if kw.lower() in collected_text)
95
+ if score > 0:
96
+ ranked.append((score, tool))
97
+ ranked.sort(key=lambda x: x[0], reverse=True)
98
+ return [t for _, t in ranked[:top_n]]
99
+
100
+
101
+ # ======================================================================
102
+ # Config
103
+ # ======================================================================
104
+
105
+ CONFIG_PATH = os.path.join(os.path.expanduser('~'), '.weflow-cli', 'config.json')
106
+
107
+
108
+ # ======================================================================
109
+ # AI Engine 抽象
110
+ # ======================================================================
111
+
112
+ class AIEngine:
113
+ """OpenAI-compatible API 引擎基类。"""
114
+ def __init__(self, api_key: str, base_url: str, model: str, timeout=60):
115
+ self.api_key = api_key
116
+ self.base_url = base_url.rstrip('/')
117
+ self.model = model
118
+ self.timeout = timeout
119
+
120
+ def chat(self, prompt: str, max_tokens=2000) -> str:
121
+ payload = json.dumps({
122
+ 'model': self.model,
123
+ 'messages': [{'role': 'user', 'content': prompt}],
124
+ 'max_tokens': max_tokens,
125
+ 'temperature': 0.2,
126
+ }).encode('utf-8')
127
+ req = urllib.request.Request(
128
+ f'{self.base_url}/chat/completions',
129
+ data=payload,
130
+ headers={
131
+ 'Content-Type': 'application/json',
132
+ 'Authorization': f'Bearer {self.api_key}',
133
+ },
134
+ method='POST',
135
+ )
136
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
137
+ data = json.loads(resp.read())
138
+ return data['choices'][0]['message']['content']
139
+
140
+
141
+ class AnthropicEngine(AIEngine):
142
+ """Anthropic Messages API 引擎(/v1/messages)。"""
143
+ def __init__(self, api_key: str, base_url: str, model: str, timeout=120):
144
+ super().__init__(api_key, base_url, model, timeout=timeout)
145
+
146
+ def chat(self, prompt: str, max_tokens=2000) -> str:
147
+ payload = json.dumps({
148
+ 'model': self.model,
149
+ 'messages': [{'role': 'user', 'content': prompt}],
150
+ 'max_tokens': max_tokens,
151
+ }).encode('utf-8')
152
+ req = urllib.request.Request(
153
+ f'{self.base_url}/messages',
154
+ data=payload,
155
+ headers={
156
+ 'Content-Type': 'application/json',
157
+ 'x-api-key': self.api_key,
158
+ 'anthropic-version': '2023-06-01',
159
+ 'Authorization': f'Bearer {self.api_key}',
160
+ },
161
+ method='POST',
162
+ )
163
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
164
+ data = json.loads(resp.read())
165
+ return data['content'][0]['text']
166
+
167
+
168
+ class DeepSeekEngine(AIEngine):
169
+ def __init__(self, api_key: str, model='deepseek-chat', timeout=180):
170
+ super().__init__(api_key, 'https://api.deepseek.com/v1', model, timeout=timeout)
171
+
172
+
173
+ class ClaudeEngine(AIEngine):
174
+ def __init__(self, api_key: str, model='claude-sonnet-4-6', timeout=90):
175
+ super().__init__(api_key, 'https://api.anthropic.com/v1', model, timeout=timeout)
176
+
177
+
178
+ class OllamaEngine(AIEngine):
179
+ def __init__(self, model='llama3', timeout=120):
180
+ super().__init__('ollama', 'http://localhost:11434/v1', model, timeout=timeout)
181
+
182
+
183
+ # ======================================================================
184
+ # 本地引擎自动检测
185
+ # ======================================================================
186
+
187
+ _LOCAL_ENDPOINTS = [
188
+ # (name, base_url, api_style)
189
+ ('ollama', 'http://localhost:11434', 'openai'),
190
+ ('lmstudio', 'http://localhost:1234', 'openai'),
191
+ ('claude', 'http://localhost:8080', 'anthropic'),
192
+ ('lmstudio', 'http://localhost:8000', 'openai'),
193
+ ('lmstudio', 'http://localhost:3000', 'openai'),
194
+ ]
195
+
196
+
197
+ def _try_local_endpoint(name: str, base_url: str, api_style: str,
198
+ timeout: int = 5) -> tuple[bool, str, str, str]:
199
+ health_path = f'{base_url}/v1/models'
200
+ try:
201
+ req = urllib.request.Request(health_path, headers={'Accept': 'application/json'})
202
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
203
+ data = json.loads(resp.read())
204
+ models = data.get('data', data.get('models', []))
205
+ if not models:
206
+ return True, name, base_url, 'local'
207
+ first = models[0]
208
+ model_name = first.get('id', first.get('model', 'local'))
209
+ return True, name, base_url, model_name
210
+ except Exception as e:
211
+ return False, name, base_url, str(e)
212
+
213
+
214
+ def detect_local_engine(timeout: int = 5) -> tuple[bool, AIEngine, str]:
215
+ """自动检测本地推理服务。返回 (detected, engine, description)。"""
216
+ checked = set()
217
+ for name, base_url, api_style in _LOCAL_ENDPOINTS:
218
+ key = (name, base_url)
219
+ if key in checked:
220
+ continue
221
+ checked.add(key)
222
+ ok, dname, durl, model = _try_local_endpoint(name, base_url, api_style, timeout)
223
+ if not ok:
224
+ continue
225
+ if api_style == 'anthropic':
226
+ engine = AnthropicEngine('local', durl, model, timeout=180)
227
+ else:
228
+ engine = OllamaEngine(model=model, timeout=180)
229
+ engine.base_url = durl.rstrip('/') + '/v1'
230
+ desc = f'{dname} ({model}@{durl})'
231
+ return True, engine, desc
232
+ return False, None, '无可用本地推理服务'
233
+
234
+
235
+ def create_engine(engine_type: str, api_key: str = '') -> AIEngine:
236
+ """工厂函数:根据类型创建 AI 引擎。
237
+
238
+ 引擎类型: local(自动检测) / deepseek / claude / ollama
239
+ 配置 aiBaseUrl 时, deepseek 引擎走自定义 OpenAI 兼容端点 (中转站)。
240
+ """
241
+ t = engine_type.lower()
242
+ if t == 'local':
243
+ ok, engine, desc = detect_local_engine(timeout=8)
244
+ if not ok:
245
+ raise RuntimeError(
246
+ '未检测到本地推理服务。请启动以下任一服务后重试:\n'
247
+ ' • Ollama: ollama serve (11434)\n'
248
+ ' • LM Studio: lmstudio server start (1234)\n'
249
+ ' • Claude Code 本地服务 (8080)\n'
250
+ '或使用 --engine deepseek/claude 指定云端引擎。'
251
+ )
252
+ return engine
253
+ elif t == 'deepseek':
254
+ base_url, model = _custom_endpoint()
255
+ if base_url:
256
+ return AIEngine(api_key, base_url, model)
257
+ return DeepSeekEngine(api_key)
258
+ elif t == 'claude':
259
+ return ClaudeEngine(api_key)
260
+ elif t == 'ollama':
261
+ return OllamaEngine()
262
+ raise ValueError(
263
+ f'未知引擎: {engine_type},可选: local / deepseek / claude / ollama'
264
+ )
265
+
266
+
267
+ def _custom_endpoint():
268
+ """读取 config.json 的 aiBaseUrl/aiModel; 未配置返回 (None, None)。"""
269
+ try:
270
+ with open(CONFIG_PATH, encoding='utf-8') as f:
271
+ cfg = json.load(f)
272
+ base_url = (cfg.get('aiBaseUrl') or '').strip().rstrip('/')
273
+ model = (cfg.get('aiModel') or '').strip() or 'deepseek-chat'
274
+ return (base_url or None), (model if base_url else None)
275
+ except Exception:
276
+ return None, None
277
+
278
+
279
+ def call_deepseek(prompt: str, api_key: str, max_tokens=2000, timeout=60) -> str:
280
+ """向后兼容:调用 DeepSeek API。"""
281
+ return DeepSeekEngine(api_key, timeout=timeout).chat(prompt, max_tokens=max_tokens)
282
+
283
+
284
+ def call_ai(prompt: str, engine_type: str = 'local', api_key: str = '', max_tokens=2000) -> str:
285
+ """通用 AI 调用:支持 local/deepseek/claude/ollama 引擎。"""
286
+ engine = create_engine(engine_type, api_key)
287
+ return engine.chat(prompt, max_tokens=max_tokens)
288
+
289
+
290
+ # ======================================================================
291
+ # 缓存机制 — 避免重跑 pipeline 时重复调用 API
292
+ # ======================================================================
293
+
294
+ _CACHE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
295
+ 'output', '.cache')
296
+
297
+
298
+ def _cache_key(*args, **kwargs) -> str:
299
+ """根据参数生成 MD5 缓存 key。"""
300
+ raw = json.dumps({'args': args, 'kwargs': kwargs}, sort_keys=True, ensure_ascii=False)
301
+ return hashlib.md5(raw.encode('utf-8')).hexdigest()
302
+
303
+
304
+ def cached_call(func, *args, cache_subdir: str = '', **kwargs):
305
+ """带缓存的函数调用。命中缓存则直接返回,否则调用 func 并存储结果。
306
+
307
+ Args:
308
+ func: 要调用的函数(需返回可 JSON 序列化的结果)
309
+ cache_subdir: 缓存子目录名(如 'classify'、'wiki')
310
+ """
311
+ cache_dir = os.path.join(_CACHE_DIR, cache_subdir) if cache_subdir else _CACHE_DIR
312
+ os.makedirs(cache_dir, exist_ok=True)
313
+ key = _cache_key(func.__name__, *args, **kwargs)
314
+ cache_file = os.path.join(cache_dir, f'{key}.json')
315
+
316
+ if os.path.exists(cache_file):
317
+ try:
318
+ with open(cache_file, 'r', encoding='utf-8') as f:
319
+ return json.load(f)
320
+ except (json.JSONDecodeError, OSError):
321
+ pass # 缓存损坏,重新调用
322
+
323
+ result = func(*args, **kwargs)
324
+ try:
325
+ with open(cache_file, 'w', encoding='utf-8') as f:
326
+ json.dump(result, f, ensure_ascii=False)
327
+ except (TypeError, OSError):
328
+ pass # 结果不可序列化或写入失败,跳过缓存
329
+
330
+ return result
331
+
332
+
333
+ def clear_cache(cache_subdir: str = ''):
334
+ """清空指定子目录的缓存,或全部缓存。"""
335
+ target = os.path.join(_CACHE_DIR, cache_subdir) if cache_subdir else _CACHE_DIR
336
+ if not os.path.isdir(target):
337
+ return
338
+ for fname in os.listdir(target):
339
+ fpath = os.path.join(target, fname)
340
+ if os.path.isfile(fpath):
341
+ os.unlink(fpath)
342
+ # ======================================================================
343
+ # Config Decrypt
344
+ # ======================================================================
345
+
346
+ # ======================================================================
347
+ # 主题分类法 —— **唯一一处定义**
348
+ #
349
+ # 列表与定义都收在这里,因为「散文写的枚举会漂」在这个仓库里已经真的发生过:
350
+ # `biz_daily.TOPIC_PROMPT` 的第一行按 TOPICS 生成了六类,但同一段提示词后面两处
351
+ # 提醒是硬编码的五类(少了政治),判断规则里也没有政治那一栏——而政治在语料里占 26%。
352
+ # 两条路径(LLM 提示词 / 决策模型的判据)现在读同一张表,所以它们不可能再对不上。
353
+ #
354
+ # **顺序有意义**:`generate_ai_report` 按这个顺序给报告分栏。
355
+ TOPICS = ['AI', '学术', '新闻', '文学', '投资', '政治']
356
+
357
+ # 聊天卡拷进 Vault `Sources/Chat/` 时的文件名前缀。**必须共享**:`chat_notes` 用它写文件、
358
+ # `compile_wiki` 用它把概念页的"来源"链接指过去——两处各写一份就是"同一条信息写两处",
359
+ # 而这个仓库已经因为这类分叉吃过亏。
360
+ # 为什么要有这个前缀:聊天卡的名字**就是会话名**(`白马非马`、`老表亲戚群`),而概念页
361
+ # 也可能叫同一个名字;两个文件同名时 `[[白马非马]]` 在 Obsidian 里是二义的。
362
+ CHAT_CARD_PREFIX = '会话-'
363
+
364
+ # 知识页住在哪几个目录(**Vault 相对路径**)。**分成两个是有意的**:用户明确要求
365
+ # 聊天知识库与公众号文章知识库分开(2026-09-27),所以是"一条线一个目录"而不是混在一起。
366
+ # 顺序即展示顺序:文章在前。
367
+ #
368
+ # 读的一方(`wiki_lint` / `vault_search` / `vault_rag` / 助手的 `search_knowledge`)
369
+ # 都要把**两个都读**,否则新分出去的那一半会静默地搜不到——而这个仓库已经因为
370
+ # "同一个事实写在两处"栽过好几次,所以 TS 那边有一份平行的列表,有测试钉住两者一致。
371
+ #
372
+ # 这一层是 2026-10-10 立的:用户要求两条线**各自一棵树**(不只是同根下的两个子目录),
373
+ # 而"一串 Vault 相对路径"这个形状把"共用一个根"写死了 ⇒ 改值不够、得先改形状。
374
+ # 现在两条线的根**仍然相同**(物理迁移还没做),所以下面这层只是把形状立起来;
375
+ # 迁移时只改 `root` 的值,不再动形状。**唯一源头**:`CONCEPT_DIRS` 由它派生,
376
+ # 图谱那两处曾经的硬编码也改读它。
377
+ KNOWLEDGE_LINES = (
378
+ # (线 id, 根, 线内的概念目录)
379
+ ('wiki', 'output/wechat-vault', 'Wiki/Concepts'),
380
+ ('chat', 'output/wechat-vault', 'Chat/Concepts'),
381
+ )
382
+
383
+
384
+ def concept_dirs():
385
+ """所有线的概念目录(Vault 相对路径),顺序即展示顺序:文章在前。"""
386
+ return tuple(concepts for _line, _root, concepts in KNOWLEDGE_LINES)
387
+
388
+
389
+ CONCEPT_DIRS = concept_dirs()
390
+
391
+
392
+ def line_for_concept_dir(path):
393
+ """给一个概念目录(任意写法)→ 线 id;认不出就返回 None(**不猜**)。
394
+
395
+ 认不出是正常情形(`wiki compile --output` 可以指到库外的任意目录),
396
+ 调用方要按"没有线"处理,而不是当成某条线。
397
+ """
398
+ try:
399
+ target = os.path.normcase(os.path.abspath(str(path)))
400
+ except Exception:
401
+ return None
402
+ for line_id, root, concepts in KNOWLEDGE_LINES:
403
+ if target == os.path.normcase(os.path.abspath(os.path.join(root, concepts))):
404
+ return line_id
405
+ return None
406
+
407
+
408
+ # 线的展示名。**只此一份**:图谱的提示、体检的报告、助手的输出都取它,各写一句
409
+ # "文章线/聊天线"的后果是改词那天只改到一半,而没有任何东西会红。
410
+ LINE_LABELS = {'wiki': '文章线', 'chat': '聊天线'}
411
+
412
+ # 主键的分隔符。选 `:` 是因为**概念页的文件名里不可能有它**(Windows 禁止,
413
+ # `compile_wiki` 也会把它换成 `_`)—— 主键要能被原样切回 (线, 名字),
414
+ # 这个判据就必须没有二义。改成 `-` 或 `_` 都会把 `AI-Agent` 这类名字切错,
415
+ # 而症状是"图谱里两个节点凭一个错名字连上了",不报错。
416
+ LINE_SEP = ':'
417
+
418
+
419
+ def line_for_relative_concepts(relative):
420
+ """给一个**库内相对**概念目录(`Wiki/Concepts`)→ 线 id;认不出返回 None(**不猜**)。
421
+
422
+ 与 `line_for_concept_dir` 的分工:那个比的是**绝对路径**(适合已经知道库根的调用方);
423
+ 这个只比对清单里那串相对路径 —— 测试里的临时库、或 `--vault` 指到别处时库根对不上,
424
+ 但"`Chat/Concepts` 属于聊天线"这一层仍然成立。
425
+ """
426
+ def norm(p):
427
+ return os.path.normcase(str(p).replace('/', os.sep).replace('\\', os.sep))
428
+ for line_id, _root, concepts in KNOWLEDGE_LINES:
429
+ if norm(concepts) == norm(relative):
430
+ return line_id
431
+ return None
432
+
433
+
434
+ def line_concepts_path(line_id):
435
+ """线 id → 它的概念目录(**库内相对路径**,`Wiki/Concepts`);认不出返回 None。"""
436
+ for lid, _root, concepts in KNOWLEDGE_LINES:
437
+ if lid == line_id:
438
+ return concepts
439
+ return None
440
+
441
+
442
+ def concept_link_target(line_id, name, other_line_names=()):
443
+ """渲染一个概念链接该写成什么目标。
444
+
445
+ **只在"另一条线也有这个名字"时才带路径**(`Chat/Concepts/DeepSeek|DeepSeek`):
446
+ 两条线同名时裸 `[[DeepSeek]]` 在 Obsidian 里是二义的(官方判据:会 deterministic
447
+ 解析,但不保证命中你想要的那张),所以给目标带上路径、用 `|` 保住显示文本。
448
+ 只属于一条线的名字**一个字节都不动** —— 否则 4.9 万张页里绝大多数链接都要白重写
449
+ 一遍,而那样改出来的 diff 没人看得完。
450
+ """
451
+ if name in other_line_names:
452
+ concepts = line_concepts_path(line_id)
453
+ if concepts:
454
+ return '%s/%s|%s' % (concepts, name, name)
455
+ return name
456
+
457
+
458
+ def link_target_name(raw):
459
+ """`[[…]]` 里抓到的那一串 → 概念名。**解析只此一份。**
460
+
461
+ `[[Chat/Concepts/DeepSeek|DeepSeek]]` → `DeepSeek`。各处自己写 `split('|')` 的话,
462
+ 漏掉一处那处的边就整条丢掉,而图里少一条边不报错。
463
+
464
+ **改动面越小越好**:只切掉 `|显示文本`,并且**只对带线路径的那些**再去掉前缀。
465
+ 其余一律原样返回 —— 这个函数的上游既有"概念名"也有"卡片名",随手多切一刀就会
466
+ 改掉既有行为:
467
+
468
+ - 别切 `#`:这个库里的概念名**真的带 `#`**(`#GPT6`、`Erdős#1026`)。切了它们会被
469
+ 当成别的名字,指向它们的边整条消失(2026-10-10 实测少了 4 条边,而图里少边不报错)。
470
+ - 别切 `.md`:`## 来源` 的卡片链接就写成 `[[名字.md]]`,`wiki_lint.resolve` 靠这个
471
+ 后缀把它分流到卡片目录。切了它,那一支就再也不走了。
472
+ """
473
+ name = str(raw or '').strip()
474
+ if '|' in name:
475
+ name = name.split('|', 1)[0].strip() # `[[目标|显示文本]]` → 目标
476
+ norm = name.replace('\\', '/')
477
+ for _lid, _root, concepts in KNOWLEDGE_LINES:
478
+ prefix = concepts + '/'
479
+ if norm.startswith(prefix):
480
+ norm = norm[len(prefix):]
481
+ if norm.lower().endswith('.md'): # 带路径的写法里才去后缀(我们自己写的那种)
482
+ norm = norm[:-3]
483
+ break
484
+ return norm.strip()
485
+
486
+
487
+ def is_line_qualified(raw):
488
+ """这条链接是不是**带了线的路径前缀**(`Wiki/Concepts/…` / `Chat/Concepts/…`)。
489
+
490
+ 体检用它区分"已经消歧过的链接"和"还是裸的二义链接"。
491
+ """
492
+ name = str(raw or '').strip()
493
+ if '|' in name:
494
+ name = name.split('|', 1)[0].strip()
495
+ norm = name.replace('\\', '/')
496
+ for _lid, _root, concepts in KNOWLEDGE_LINES:
497
+ if norm == concepts or norm.startswith(concepts + '/'):
498
+ return True
499
+ return False
500
+
501
+
502
+ def sibling_concepts_dir(concept_dir):
503
+ """同一个库里**另一条线**的概念目录;认不出线、或它不存在 → None。
504
+
505
+ **两级**:两条线都是 `<库>/<线>/Concepts`,库根是 `concept_dir.parent.parent`。
506
+ 写成一级的话候选恒不存在(`<库>/Chat/Wiki/Concepts`)—— 于是"另一条线没有这个名字"
507
+ 永远成立,链接不会带路径,而 Obsidian 里那条裸链接就**悄悄**变成二义的。
508
+ 2026-09-27 那段同样的两级路径就是这么栽过一次(写在已删掉的那个函数的注释里)。
509
+
510
+ 认线用**路径尾两段**而不是绝对路径(`line_for_relative_concepts`):测试里的临时库、
511
+ 或者用户把库根换了地方,尾两段仍然是 `Chat/Concepts` ⇒ 认得出;用绝对路径的话
512
+ 只有仓库里那两个真实根才认得出,别的地方会安静地退化成"没有另一条线"。
513
+ """
514
+ norm = str(concept_dir).replace('\\', '/').rstrip('/')
515
+ parts = [p for p in norm.split('/') if p]
516
+ if len(parts) < 2:
517
+ return None
518
+ my_line = line_for_relative_concepts('/'.join(parts[-2:]))
519
+ if not my_line:
520
+ return None # 库外的目录:那里没有"另一条线"
521
+ root = os.path.dirname(os.path.dirname(norm))
522
+ for line_id, _r, concepts in KNOWLEDGE_LINES:
523
+ if line_id == my_line:
524
+ continue
525
+ candidate = os.path.join(root, *concepts.split('/'))
526
+ if os.path.isdir(candidate):
527
+ return candidate
528
+ return None
529
+
530
+
531
+ def concept_names(concept_dir):
532
+ """那个目录里的概念名(文件名去掉 `.md`)。读不出来就返回空集(不猜)。"""
533
+ try:
534
+ return {f[:-3] for f in os.listdir(str(concept_dir)) if f.endswith('.md')}
535
+ except OSError:
536
+ return set()
537
+
538
+
539
+ class LinkNamer:
540
+ """把概念名渲染成链接目标 —— **跨线同名时才带路径**,其余原样。
541
+
542
+ 为什么要一个对象而不是每处写一句:**渲染点有三处**(模型建页、零模型建页、索引表),
543
+ 各写一份的话漏一处就会在库里留下一批裸的二义链接,而它只在有人跑 `wiki lint` 时
544
+ 才看得见。判断"要不要带路径"只需要另一条线的**文件名集合**,所以构造一次就够。
545
+ """
546
+
547
+ def __init__(self, line_id, other_names=()):
548
+ self.line_id = line_id or ''
549
+ self.other_names = set(other_names)
550
+
551
+ @classmethod
552
+ def for_out_dir(cls, out_dir):
553
+ """按输出目录定线,并顺手把另一条线的名字读出来(一次 listdir)。
554
+
555
+ 认线用**路径尾两段**(`Chat/Concepts`),所以测试里的临时库、或库根换了地方都认得出。
556
+ 认不出线(`--output` 指到库外的任意目录)时 **`line_id` 为空 → 一律渲染成裸名字**:
557
+ 那是"库外目录"的既有行为,而不是按猜出来的线写一堆带路径的链接。
558
+ """
559
+ parts = [p for p in str(out_dir).replace('\\', '/').rstrip('/').split('/') if p]
560
+ line_id = line_for_relative_concepts('/'.join(parts[-2:])) if len(parts) >= 2 else None
561
+ if not line_id:
562
+ return cls('', ())
563
+ sibling = sibling_concepts_dir(out_dir)
564
+ return cls(line_id, concept_names(sibling) if sibling else ())
565
+
566
+ def __call__(self, name):
567
+ if not self.line_id:
568
+ return name
569
+ return concept_link_target(self.line_id, name, self.other_names)
570
+
571
+
572
+ def concept_key(line_id, name):
573
+ """线 id + 概念名 → 跨线唯一的主键。
574
+
575
+ **为什么需要**:两条线各有一张同名页是设计使然(用户 2026-10-10 要求两个概念
576
+ 命名空间分开),而图谱的主键原来就是文件 stem ⇒ 两棵目录里的 `DeepSeek` 是
577
+ **同一个键**。2026-10-10 实测:合并图里 49,956 个节点里有 36 组撞键,前端
578
+ `new Map(NODES.map(n => [n.id, n]))` 会静默只留一个 —— 节点数看着对,少掉的那个
579
+ 却点不到。同名的两页靠这个键分开。
580
+ """
581
+ return '%s%s%s' % (line_id, LINE_SEP, name)
582
+
583
+
584
+ def split_concept_key(key):
585
+ """主键 → (线 id, 名字);**认不出线就返回 `(None, 原样)`,不猜**。
586
+
587
+ 兼容没有线前缀的旧主键(认不出线的情形),调用方按"没有线"处理。
588
+ """
589
+ line_id, sep, name = str(key).partition(LINE_SEP)
590
+ if not sep or line_id not in LINE_LABELS:
591
+ return None, str(key)
592
+ return line_id, name
593
+
594
+
595
+ def line_label(line_id):
596
+ """线 id → 展示名;认不出返回 None(调用方自己决定"无线"怎么写)。"""
597
+ return LINE_LABELS.get(line_id)
598
+
599
+ # 一篇文章**没能被分类**时落到哪一类。
600
+ #
601
+ # 收成一处是因为它曾经在写入路径上有两个不同的答案、相隔七行:
602
+ # `.articles.json` 那条路默认 `''`(空主题),而分组/落 md 那条路默认 `'学术'`。
603
+ # 于是一篇文章在 md 里写 `topic: 学术`、在 json 里写 `topic: ""`——
604
+ # **同一次运行、同一篇文章,两个产物给出不同的主题**,而且都不报错。
605
+ #
606
+ # 真实后果(2026-09-04 的产出,178 篇):md 全落在 `学术/` 目录、frontmatter 写
607
+ # `topic: 学术`(其中两篇是「OpenAI 深夜发布 GPT-6」「专为高管准备的 AI 助手」,
608
+ # 明显不该是学术),而 json 里同一批全是 `""` —— 报告那条路读 json,
609
+ # 判据 `topic != FOCUS_TOPIC and relevance != '高'` 就把它们整批静默排除了。
610
+ #
611
+ # 什么情况下会走到这里:`daily --no-ai`、或者没配 API key 时,Phase 2(分类)整段
612
+ # 不跑,文章 dict 里**根本没有 `topic` 键**,而 Phase 3 照常写文件。
613
+ #
614
+ # 这个值是**兜底不是判断**:落到这里的文章其实是"未分类"。之所以不新造一个
615
+ # `未分类` 类别,是因为它要额外维护一个目录、一处 HTML 分栏、以及下游对
616
+ # 主题字面量的比较;改用 `未分类` 属于改既有契约,得单独决策。
617
+ DEFAULT_TOPIC = '学术'
618
+
619
+ # 每一类的判据。既生成提示词里的判断规则,也直接作为 Jev 的 choice criteria。
620
+ TOPIC_CRITERIA = {
621
+ 'AI': 'AI大模型/Agent/编程/开源/科技产品/工具教程',
622
+ '投资': '股票基金/融资/经济分析/商业市场',
623
+ '新闻': '时事政策/社会热点/娱乐/招聘促销/会议通知',
624
+ '文学': '散文小说/美食旅游/生活随笔/历史文化',
625
+ '学术': '科研论文/期刊文章/实验室成果/学术会议/高校研究',
626
+ '政治': '党政理论/政策解读/领导人讲话/官方评论',
627
+ }
628
+
629
+ # 相关度的三个档位。与 `TOPICS` 一样是**下游按字面量比较**的封闭词表:
630
+ # `generate_ai_report.py`、`enrich_backlinks.py`、`generate_html.py` 都在比
631
+ # `'高'`/`'中'`/`'低'` 这三个字。定义收在这里,`jev_client` 与 `biz_daily`
632
+ # 都从这里取——原先 `jev_client` 一份、`biz_daily` 的成员校验里再写一份。
633
+ #
634
+ # **顺序即语义**:`jev_client.RELEVANCE_LEVELS` 是给模型看的判据文本,按下标与
635
+ # 这个列表一一对应(有测试钉住),所以不要重排。
636
+ RELEVANCE_NAMES = ['低', '中', '高']
637
+
638
+ # 一篇文章**没能被判相关度**时落到哪一档。和 `DEFAULT_TOPIC` 是同一类东西:
639
+ # 兜底不是判断。之所以要具名,是因为"全库 2199/2201 篇都是「中」"这件事的答案
640
+ # 就在这里——分类失败的每条路径最终都落到这个值,而 `'中'` 读起来是
641
+ # 「有启发性」(正面评价),不是「没判断」。值不变更(改档位会动到下游的
642
+ # 收录判据),只是把它写在一处,好让它可被搜索、可被讨论。
643
+ DEFAULT_RELEVANCE = '中'
644
+
645
+
646
+ def load_config():
647
+ with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
648
+ return json.load(f)
649
+
650
+
651
+ # ---- 「不说话地失败」的最小护栏 ---------------------------------------------
652
+ # 这个文件里有一批函数**故意不抛**(跨机器拷配置时,解不开的密文该表现为"没配",
653
+ # 而不是让整条命令崩)。但"不抛"不等于"不说":`except: return ''` 会让调用方
654
+ # 与用户都以为"本来就没有",而真实原因是**解密失败/配置坏了**。
655
+ # 所以这里只做一件事:同一条原因**说一次**,然后照旧返回空值。
656
+ # 只说一次是必须的——这些函数每次请求/每次重排都会被调用,刷屏等于没警告。
657
+ _WARNED_ONCE = set()
658
+
659
+
660
+ def warn_once(tag, message):
661
+ """同一条原因只往 stderr 说一次(tag 相同即视为同一条)。"""
662
+ if tag in _WARNED_ONCE:
663
+ return
664
+ _WARNED_ONCE.add(tag)
665
+ import sys as _sys
666
+ print('[WARN] ' + message, file=_sys.stderr)
667
+ _sys.stderr.flush()
668
+
669
+
670
+ def reset_warn_once():
671
+ """测试用:清空去重表。"""
672
+ _WARNED_ONCE.clear()
673
+
674
+
675
+ # 配置里"不想在日报里看到的主题"的键名。值形如 `新闻,投资,学术`。
676
+ EXCLUDED_TOPICS_KEY = 'dailyExcludeTopics'
677
+
678
+
679
+ def excluded_topics(config=None, explicit='', protected=()):
680
+ """用户不想在日报里看到的主题集合。`explicit`(命令行)覆盖配置。
681
+
682
+ **这是展示层的开关,不是抓取层的**:正文照常抓取、照常归档,只是不出现在
683
+ 日报里。理由是实测:拉之前只有来源+标题+摘要,按它判类型**不可靠**(与来源级
684
+ 比对一致率仅 60%、误伤 48/217),而**排除是不可逆的**——没抓取就没归档。
685
+ 放在展示层还换来一件事:改主意不用重抓,改个配置重新生成报告即可。
686
+
687
+ 写错的主题名会被忽略并**打一行 WARN**(静默忽略会让人以为过滤生效了)。
688
+
689
+ `protected` 是不许被排除的主题(呼叫方传自己的「焦点主题」)。它比「未知主题名」
690
+ 更值得拦一下:把焦点主题排掉,报告要么没有主体、要么直接报「未找到文章」
691
+ **指错方向**(那句话说去跑 biz_daily,可文章其实在)。同样 WARN 后忽略。
692
+ """
693
+ raw = explicit or (config or {}).get(EXCLUDED_TOPICS_KEY, '') or ''
694
+ names = [t.strip() for t in str(raw).replace(',', ',').split(',') if t.strip()]
695
+ out = set()
696
+ for name in names:
697
+ if name in TOPICS:
698
+ if name in protected:
699
+ print('[WARN] %s 里的「%s」是这份报告的主体,不能排除,已忽略'
700
+ % (EXCLUDED_TOPICS_KEY, name))
701
+ continue
702
+ out.add(name)
703
+ else:
704
+ print('[WARN] %s 里的「%s」不是已知主题,已忽略(可选:%s)'
705
+ % (EXCLUDED_TOPICS_KEY, name, '/'.join(TOPICS)))
706
+ return out
707
+
708
+
709
+ # === 来源级先验:"哪个号稳定发哪一类"从真实判断里长出来 ===
710
+ #
711
+ # 为什么不写死一张表:拉取之前只有来源+标题+摘要,按它判类型实测只有 60% 一致率、
712
+ # 误伤 48/217(见 excluded_topics 的说明)。但**抓回来之后**每次都有 Jev 按正文判的
713
+ # 主题,日积月累就看得出某个号一贯发什么。这张表因此是长出来的,不是我编的。
714
+ #
715
+ # 它现在只用来**报数**,不用来跳过任何东西:跳过是不可逆的。
716
+ SOURCE_TOPICS_FILE = 'source_topics.json'
717
+ SOURCE_PRIOR_MIN_SAMPLES = 8 # 少于这么多篇不下结论
718
+ SOURCE_PRIOR_SHARE = 0.8 # 某一类占比到这个数才算"稳定地只发这一路"
719
+
720
+
721
+ def source_topics_path(path=None):
722
+ """先验表的落盘位置。默认与 config.json 同目录(家目录)。"""
723
+ return path or os.path.join(os.path.dirname(CONFIG_PATH), SOURCE_TOPICS_FILE)
724
+
725
+
726
+ def load_source_topics(path=None):
727
+ """读先验表:`{来源: {主题: 次数}}`。
728
+
729
+ 读不出、坏掉、不是字典——一律返回 `{}`。这是**辅助**数据,坏了不该让日报挂掉。
730
+ """
731
+ try:
732
+ with open(source_topics_path(path), encoding='utf-8') as f:
733
+ data = json.load(f)
734
+ except (OSError, ValueError):
735
+ return {}
736
+ if not isinstance(data, dict):
737
+ return {}
738
+ out = {}
739
+ for source, counts in data.items():
740
+ if isinstance(source, str) and source.strip() and isinstance(counts, dict):
741
+ out[source] = counts
742
+ return out
743
+
744
+
745
+ def record_source_topics(pairs, path=None):
746
+ """把这一批 `(来源, 主题)` 累加进先验表,返回摘要 dict。
747
+
748
+ **只增不减**(与 frontmatter 的 only-increase 同一个纪律):累计次数是你事后判断
749
+ "这个号到底稳不稳"的唯一依据,抹掉就回不来了。
750
+ 来源为空、主题不在 TOPICS 里的都不计——计了会污染分母,让占比算错。
751
+ """
752
+ import tempfile as _tmp # 模块顶没导入它:本文件里它是函数内导入的
753
+ store = load_source_topics(path)
754
+ added = skipped = 0
755
+ for source, topic in pairs:
756
+ source = (source or '').strip()
757
+ topic = (topic or '').strip()
758
+ if not source or topic not in TOPICS:
759
+ skipped += 1
760
+ continue
761
+ bucket = store.setdefault(source, {})
762
+ try:
763
+ bucket[topic] = int(bucket.get(topic, 0)) + 1
764
+ except (TypeError, ValueError):
765
+ bucket[topic] = 1 # 值被改坏了,从 1 重新数,别让它把整个文件废掉
766
+ added += 1
767
+ target = source_topics_path(path)
768
+ try:
769
+ os.makedirs(os.path.dirname(target) or '.', exist_ok=True)
770
+ fd, tmp_path = _tmp.mkstemp(suffix='.json', dir=os.path.dirname(target) or '.')
771
+ try:
772
+ with os.fdopen(fd, 'w', encoding='utf-8') as f:
773
+ json.dump(store, f, ensure_ascii=False, indent=1, sort_keys=True)
774
+ os.replace(tmp_path, target)
775
+ except Exception:
776
+ if os.path.exists(tmp_path):
777
+ os.unlink(tmp_path)
778
+ raise
779
+ except OSError as exc:
780
+ # 写不进去(权限、磁盘)不能让日报失败——它只是记个账。
781
+ return {'sources': len(store), 'added': added, 'skipped': skipped,
782
+ 'error': str(exc)}
783
+ return {'sources': len(store), 'added': added, 'skipped': skipped, 'error': ''}
784
+
785
+
786
+ def stable_source_topic(counts, min_samples=SOURCE_PRIOR_MIN_SAMPLES,
787
+ share=SOURCE_PRIOR_SHARE):
788
+ """某个来源是不是已经**稳定地只发某一类**。
789
+
790
+ 返回 `(主题, 占比, 样本数)`;还判不了返回 `None`。
791
+ **`None` 是"还不知道",不是"没有主题"**——调用方必须把这两种情况分开,
792
+ 别把 `None` 当成某个默认主题用(那正好是"来源判不准"最坏的那种错)。
793
+ """
794
+ clean = {}
795
+ for topic, count in (counts or {}).items():
796
+ try:
797
+ value = int(count)
798
+ except (TypeError, ValueError):
799
+ continue
800
+ if value > 0:
801
+ clean[topic] = value
802
+ total = sum(clean.values())
803
+ if total < min_samples:
804
+ return None
805
+ topic, top = max(clean.items(), key=lambda kv: kv[1])
806
+ ratio = top / total
807
+ if ratio < share:
808
+ return None
809
+ return (topic, ratio, total)
810
+
811
+
812
+ def source_prior_candidates(store, exclude, **kwargs):
813
+ """先验里"已经稳到能判"且落在排除集里的来源,按样本数从多到少。
814
+
815
+ 只报出来给人看,**不据此跳过任何东西**:跳过是不可逆的,而这张表还在长。"""
816
+ rows = []
817
+ for source, counts in (store or {}).items():
818
+ stable = stable_source_topic(counts, **kwargs)
819
+ if stable and stable[0] in (exclude or ()):
820
+ rows.append((source, stable[0], stable[1], stable[2]))
821
+ return sorted(rows, key=lambda row: (-row[3], row[0]))
822
+
823
+ def decrypt_lock(locked_str: str) -> str:
824
+ if not locked_str or not locked_str.startswith('lock:'):
825
+ return locked_str
826
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
827
+ from cryptography.hazmat.primitives import hashes
828
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
829
+ from cryptography.hazmat.backends import default_backend
830
+
831
+ raw = base64.b64decode(locked_str[5:])
832
+ salt, iv, auth_tag, ciphertext = raw[:16], raw[16:28], raw[28:44], raw[44:]
833
+ machine_id = f'{socket.gethostname()}-{os.environ.get("USERNAME", "")}-weflow-cli'
834
+ kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=100000,
835
+ backend=default_backend())
836
+ key = kdf.derive(machine_id.encode('utf-8'))
837
+ aesgcm = AESGCM(key)
838
+ return aesgcm.decrypt(iv, ciphertext + auth_tag, None).decode()
839
+
840
+
841
+ def get_db_config(config=None):
842
+ if config is None:
843
+ config = load_config()
844
+ return {
845
+ 'nt_db': config.get('ntDbPath', ''),
846
+ 'nt_key': decrypt_lock(config.get('ntKey', '')),
847
+ 'nt_salt': config.get('ntSalt', ''),
848
+ 'contact_db': config.get('contactDbPath', ''),
849
+ 'contact_key': decrypt_lock(config.get('contactKey', '')),
850
+ 'contact_salt': config.get('contactSalt', ''),
851
+ }
852
+
853
+
854
+ def biz_message_db(config=None):
855
+ """订阅号(公众号)消息库 `biz_message_0.db` 的路径与钥匙,返回 `(db, key_hex, salt_hex)`。
856
+
857
+ **微信 4.x:钥匙由全库 passphrase 派生**(PBKDF2-HMAC-SHA512,256000 轮,32 字节),
858
+ 盐取**库文件头 16 字节**。`favPassphrase` 优先,退而用 `decryptKey`;
859
+ 更早的 `bizKey`/`bizSalt`(配了就直用)只作兼容保留——那两个键在本机早就不存在了。
860
+
861
+ 抽成一个函数,是因为这把钥匙原来有**三份实现**:`biz_daily` 那份是 passphrase 派生(可用),
862
+ `chat_stats` 与 `mcp_bridge` 那两份只认 `bizKey`(在本机必然报『缺少密钥』,而照它们的提示去
863
+ `config set bizKey` 还会撞上『该键不在 CLI 可写白名单里』——越修越远)。
864
+ """
865
+ import hashlib
866
+ if config is None:
867
+ config = load_config()
868
+ nt_db = str(config.get('ntDbPath', ''))
869
+ msg_dir = os.path.dirname(nt_db.replace('\\', '/'))
870
+ db = os.path.join(msg_dir, 'biz_message_0.db')
871
+ key_enc = config.get('bizKey', '')
872
+ salt = config.get('bizSalt', '')
873
+ if key_enc and salt:
874
+ return db, decrypt_lock(key_enc), salt
875
+ pass_enc = config.get('favPassphrase', '') or config.get('decryptKey', '')
876
+ if not pass_enc or not os.path.exists(db):
877
+ raise RuntimeError(
878
+ '缺少订阅号数据库密钥: 请运行 weflow-cli fav set-key --passphrase <64位hex> '
879
+ '配置全库 passphrase (自动派生各库密钥)')
880
+ with open(db, 'rb') as fh:
881
+ salt = fh.read(16).hex()
882
+ key = hashlib.pbkdf2_hmac('sha512', bytes.fromhex(decrypt_lock(pass_enc)),
883
+ bytes.fromhex(salt), 256000, dklen=32).hex()
884
+ return db, key, salt
885
+
886
+ def get_api_key(config=None) -> str:
887
+ """读取 AI API key (自动解密 lock: 前缀)。"""
888
+ if config is None:
889
+ config = load_config()
890
+ return decrypt_lock(config.get('deepseekApiKey', ''))
891
+
892
+
893
+ def get_dashscope_key(config=None) -> str:
894
+ """读取阿里云百炼(DashScope)的 embedding key,自动解密 lock: 前缀。
895
+
896
+ 和 `get_api_key` / `get_typesafe_key` 同一形状:**不要直接 config.get**——
897
+ 这个键进了 `ENCRYPTED_KEYS`,磁盘上是密文。解密失败回空串而不是抛,
898
+ 理由同 get_typesafe_key(跨机器拷配置时它该表现为"没配",不是崩)。
899
+ """
900
+ if config is None:
901
+ try:
902
+ config = load_config()
903
+ except Exception as exc:
904
+ warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
905
+ % (CONFIG_PATH, type(exc).__name__, exc))
906
+ return ''
907
+ try:
908
+ return decrypt_lock(config.get('dashscopeApiKey', ''))
909
+ except Exception as exc:
910
+ warn_once('dashscopeApiKey', '配置项 dashscopeApiKey 解不开(%s: %s)—— 当作"没配",语义检索会退成关键词'
911
+ % (type(exc).__name__, exc))
912
+ return ''
913
+
914
+
915
+ def get_typesafe_key(config=None) -> str:
916
+ """读取 TypeSafe (Jev) 决策模型的 key,自动解密 lock: 前缀。
917
+
918
+ 解密失败**回空串而不是抛**:TS 侧 lockDecrypt() 失败就是静默回 '',而
919
+ decrypt_lock() 是抛的。两份 config.json 一跨机器拷贝,同一份密文在
920
+ TS 侧表现为"没配 key"、在这里表现为崩——那会让日报整个挂掉,而它本该
921
+ 只是退回到 LLM 解析路径。
922
+ """
923
+ if config is None:
924
+ try:
925
+ config = load_config()
926
+ except Exception as exc:
927
+ warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
928
+ % (CONFIG_PATH, type(exc).__name__, exc))
929
+ return ''
930
+ try:
931
+ return decrypt_lock(config.get('typesafeApiKey', ''))
932
+ except Exception as exc:
933
+ warn_once('typesafeApiKey', '配置项 typesafeApiKey 解不开(%s: %s)—— 当作"没配",决策模型退回 LLM 解析'
934
+ % (type(exc).__name__, exc))
935
+ return ''
936
+
937
+
938
+ def get_openrouter_key(config=None) -> str:
939
+ """读取 OpenRouter 的 key(决策模型的第二条通路,D-120),自动解密 lock: 前缀。
940
+
941
+ 与 `get_typesafe_key` 逐字同一条规矩:**跨机器拷配置时表现为"没配",不是崩**。
942
+ 解密失败回空串之后,`create_client()` 会返回 None,调用方按老路退回 ——
943
+ 这是"这条路没配好"的**安全**失效方向。
944
+ """
945
+ if config is None:
946
+ try:
947
+ config = load_config()
948
+ except Exception as exc:
949
+ warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
950
+ % (CONFIG_PATH, type(exc).__name__, exc))
951
+ return ''
952
+ try:
953
+ return decrypt_lock(config.get('openrouterApiKey', ''))
954
+ except Exception as exc:
955
+ warn_once('openrouterApiKey', '配置项 openrouterApiKey 解不开(%s: %s)—— 当作"没配",OpenRouter 那条路会停用'
956
+ % (type(exc).__name__, exc))
957
+ return ''
958
+
959
+
960
+ def get_judge_provider(config=None) -> str:
961
+ """决策模型走哪条通路:`native`(默认)、`chat`、`openrouter` 或 `local`(本机决策模型)。
962
+
963
+ **默认必须是 native**:这个开关存在的意义是"本机原生被地区封锁时换条路",
964
+ 而不是替所有用户改行为。取值不认识时也回 native —— 打错的配置项不该把判断整层关掉。
965
+ """
966
+ if config is None:
967
+ try:
968
+ config = load_config()
969
+ except Exception:
970
+ return 'native'
971
+ value = str((config or {}).get('judgeProvider') or '').strip().lower()
972
+ return value if value in ('native', 'chat', 'openrouter', 'local') else 'native'
973
+
974
+
975
+ # 本地决策模型服务的默认地址:`python decide.py serve --port 8090` 起的那个(D-127)。
976
+ DEFAULT_JUDGE_LOCAL_URL = 'http://127.0.0.1:8090'
977
+
978
+
979
+ def get_judge_local_url(config=None) -> str:
980
+ """`judgeProvider=local` 时往哪发:配置项 `judgeLocalUrl`,没配就用默认地址。
981
+
982
+ 与 `get_chat_endpoint` 同一态度:配置读不出来就当没配,返回默认值而不是抛 ——
983
+ 这里抛会让"本机能跑但配置少一个键"变成整条判断链不可用。
984
+ """
985
+ if config is None:
986
+ try:
987
+ config = load_config()
988
+ except Exception:
989
+ return DEFAULT_JUDGE_LOCAL_URL
990
+ value = str((config or {}).get('judgeLocalUrl') or '').strip()
991
+ return value.rstrip('/') or DEFAULT_JUDGE_LOCAL_URL
992
+
993
+
994
+ def get_chat_endpoint(config=None):
995
+ """`judgeProvider=chat` 时该往哪个 (base_url, model) 发 —— 与"中转站"同一条口径。
996
+
997
+ 故意只做一件事:**委托 `_custom_endpoint()`**。那个函数是 `aiBaseUrl`/`aiModel` 的权威读法
998
+ (base 与 model 成对出现),判断器另写一份就会在"只配了 base 没配 model"这类组合上分叉,
999
+ 而分叉的表现是"发去了错的端点"——不报错、只是判断质量变了。没配 `aiBaseUrl` 时返回 (None, None)。
1000
+ """
1001
+ try:
1002
+ return _custom_endpoint()
1003
+ except Exception:
1004
+ return None, None
1005
+
1006
+
1007
+ def get_ai_engine(config=None) -> str:
1008
+ """配置里选的对话引擎:`local` / `deepseek`(默认)/ `claude` / `ollama`。
1009
+
1010
+ **决策模型的 `chat` 通路必须读它**:`judgeProvider=chat` 的语义是"用**配置里的**对话模型",
1011
+ 而"配置里的"到底指谁只有 `aiEngine` 说得清。2026-10-18 之前那条路无条件用 `deepseekApiKey`,
1012
+ 于是 `aiEngine=local` 的用户一开 `judgeProvider=chat` 就会把文章正文发去 DeepSeek ——
1013
+ 这不是"多花点钱",是**用户明确选了不出境之后又被送出境**。
1014
+ """
1015
+ if config is None:
1016
+ try:
1017
+ config = load_config()
1018
+ except Exception as exc:
1019
+ warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
1020
+ % (CONFIG_PATH, type(exc).__name__, exc))
1021
+ return 'deepseek'
1022
+ return str((config or {}).get('aiEngine') or 'deepseek').strip().lower()
1023
+
1024
+
1025
+ def get_openrouter_model(config=None) -> str:
1026
+ """OpenRouter 上用的模型 id(默认 `typesafe/jev-router`,即 TypeSafe 发布的决策模型)。
1027
+
1028
+ ⚠️ **地区限制是逐模型的**(实测 2026-10-17):`typesafe/jev-router` 与 `:free` 模型能通,
1029
+ 付费通用模型(`openai/gpt-4o-mini`)在本地区返回 403。换模型必须重测,见 D-120。
1030
+ """
1031
+ if config is None:
1032
+ try:
1033
+ config = load_config()
1034
+ except Exception as exc:
1035
+ warn_once('load_config', '读不到配置文件 %s(%s: %s)—— 本次当作空配置,需要配置的功能会退化成默认行为'
1036
+ % (CONFIG_PATH, type(exc).__name__, exc))
1037
+ return ''
1038
+ return str((config or {}).get('openrouterJudgeModel') or '').strip()
1039
+
1040
+
1041
+ # ======================================================================
1042
+ # Markdown / Frontmatter
1043
+ # ======================================================================
1044
+
1045
+ def write_with_frontmatter(filepath: str, frontmatter: dict, body: str):
1046
+ import tempfile as _tmp
1047
+ fm_lines = ['---']
1048
+ for k, v in frontmatter.items():
1049
+ if isinstance(v, list):
1050
+ fm_lines.append(f'{k}: [{", ".join(v)}]')
1051
+ elif isinstance(v, str) and ('"' in v or ':' in v or '#' in v):
1052
+ vs = v.strip()
1053
+ fm_lines.append(f'{k}: "{vs}"' if not (vs.startswith('"') and vs.endswith('"'))
1054
+ else f'{k}: {v}')
1055
+ else:
1056
+ fm_lines.append(f'{k}: {v}')
1057
+ fm_lines.append('---')
1058
+ fm_block = '\n'.join(fm_lines) + '\n\n'
1059
+ dir_name = os.path.dirname(filepath) or '.'
1060
+ fd, tmp_path = _tmp.mkstemp(suffix='.md', dir=dir_name)
1061
+ try:
1062
+ with os.fdopen(fd, 'w', encoding='utf-8') as f:
1063
+ f.write(fm_block)
1064
+ f.write(body)
1065
+ os.replace(tmp_path, filepath)
1066
+ except Exception:
1067
+ if os.path.exists(tmp_path):
1068
+ os.unlink(tmp_path)
1069
+ raise
1070
+
1071
+
1072
+ def _unquote(item: str) -> str:
1073
+ """拆掉**成对的**外层引号;不成对就一个字都不动。
1074
+
1075
+ 原来这里写的是 `item.strip().strip('"\\'')` —— `.strip()` 的语义是"剥掉首尾**所有**这些
1076
+ 字符",于是项**内部**末尾的引号也被吃掉:`AI 长出"手脚"` 读回来变成 `AI 长出"手脚`。
1077
+ 一个字符之差、不报错,下游只表现为"这个名字对不上"(2026-10-01:一张概念页的别名因此
1078
+ 永远匹配不上卡片里的链接,那页被报成孤儿)。成对判断把它修好,同时保住"外层引号是语法"。
1079
+ """
1080
+ item = item.strip()
1081
+ if len(item) >= 2 and item[0] == item[-1] and item[0] in '"\'':
1082
+ return item[1:-1]
1083
+ return item
1084
+
1085
+
1086
+ def parse_frontmatter(content: str) -> tuple[dict, str]:
1087
+ """解析 Markdown 文件中的 YAML frontmatter。"""
1088
+ if not content.startswith('---'):
1089
+ return {}, content
1090
+ end = content.find('---', 3)
1091
+ if end == -1:
1092
+ return {}, content
1093
+ fm_text = content[3:end].strip()
1094
+ body = content[end + 3:].lstrip('\n')
1095
+ result = {}
1096
+ for line in fm_text.split('\n'):
1097
+ line = line.strip()
1098
+ if not line or ':' not in line:
1099
+ continue
1100
+ key, _, val = line.partition(':')
1101
+ key = key.strip()
1102
+ val = val.strip()
1103
+ if val.startswith('[') and val.endswith(']'):
1104
+ inner = val[1:-1]
1105
+ items = [_unquote(v) for v in inner.split(',')] if inner.strip() else []
1106
+ result[key] = items
1107
+ elif val.startswith('"') and val.endswith('"'):
1108
+ result[key] = val[1:-1]
1109
+ elif val.startswith("'") and val.endswith("'"):
1110
+ result[key] = val[1:-1]
1111
+ else:
1112
+ result[key] = val
1113
+ return result, body
1114
+
1115
+
1116
+ NORM_STRIP_RE = re.compile(r'[\s\-_·、,.。::/\\()()\[\]【】"\’]+')
1117
+
1118
+
1119
+ def normalize_concept_name(name: str) -> str:
1120
+ """判「这两个名字是不是同一个概念」用的规范形。
1121
+
1122
+ 去空格、连字符、下划线、标点,转小写,再去掉英文复数尾 —— 实测这样能把
1123
+ `GPT 5.6`/`GPT-5.6`/`GPT5.6`、`AI 工具`/`AI工具`、`AI skill`/`AI skills` 收到一起。
1124
+
1125
+ **它也会把 `news`/`new` 收到一起**,所以它只用来**提议**合并;真正的裁决是
1126
+ "留来源最多的那张页",而且删之前还有 `--dry-run` 可以看名单。
1127
+
1128
+ 住在这里(而不是 `compile_wiki`)是因为**两个读者必须用同一个口径**:`compile_wiki
1129
+ --merge-duplicates` 拿它决定合并谁,`wiki_lint` 拿它报告"还有哪些是同一个节点的两种写法"。
1130
+ 两边各写一份的话,体检报出来的名单就会有一半是合并工具不会动的 —— 2026-09-28 之前正是
1131
+ 如此:lint 用的是另一套(把所有非字母数字都去掉),于是 `DeepSeek++` 在它眼里等于
1132
+ `DeepSeek`,而合并工具并不这么认为。
1133
+ """
1134
+ stripped = NORM_STRIP_RE.sub('', name.casefold().strip())
1135
+ return re.sub(r'(?<=[a-z])s$', '', stripped)
1136
+
1137
+
1138
+ def format_wikilinks(concepts: list[tuple[str, str]]) -> str:
1139
+ lines = ['## 相关概念', '']
1140
+ for name, desc in concepts:
1141
+ if desc:
1142
+ lines.append(f'- [[{name}]] — {desc}')
1143
+ else:
1144
+ lines.append(f'- [[{name}]]')
1145
+ return '\n'.join(lines) + '\n'
1146
+
1147
+
1148
+ # ======================================================================
1149
+ # 用户定位 & 行动建议(向后兼容)
1150
+ # ======================================================================
1151
+
1152
+ DEFAULT_USER_PROFILE = (
1153
+ '你是一名对AI感兴趣的环境科学研究生,研究方向是计算机与环境的交叉领域'
1154
+ '(如环境模型、大气污染模拟、遥感反演、环境大数据分析、LCA生命周期评估等)。'
1155
+ '你关注AI工具如何提升科研效率、环境数据处理新技术、以及交叉领域的学术机会。'
1156
+ )
1157
+
1158
+ ACTION_PROMPT = """基于以下文章,为读者生成可落地的行动建议。
1159
+
1160
+ 【读者定位】
1161
+ {profile}
1162
+
1163
+ 【文章信息】
1164
+ 标题:{title}
1165
+ 来源:{source}
1166
+ 主题:{topic}
1167
+ 摘要:{summary}
1168
+
1169
+ 【正文节选】
1170
+ {content}
1171
+
1172
+ 【输出格式】
1173
+ ### 相关度
1174
+ (高/中/低 + 一句话解释)
1175
+
1176
+ ### 行动建议
1177
+ - **立即可做**:1-2个今天就能执行的具体动作
1178
+ - **本周计划**:1个本周可以推进的中期动作
1179
+ - **长期关注**:1个值得持续跟踪的方向(相关度为低时省略)"""
1180
+
1181
+
1182
+ def generate_action_suggestion(title: str, source: str, topic: str,
1183
+ summary: str, content: str,
1184
+ api_key: str, profile: str = '',
1185
+ engine_type: str = 'deepseek',
1186
+ max_tokens: int = 1500) -> str:
1187
+ if not profile:
1188
+ profile = DEFAULT_USER_PROFILE
1189
+ prompt = ACTION_PROMPT.format(
1190
+ profile=profile, title=title, source=source, topic=topic,
1191
+ summary=summary[:500], content=content[:3000],
1192
+ )
1193
+ engine = create_engine(engine_type, api_key)
1194
+ return engine.chat(prompt, max_tokens=max_tokens)
1195
+
1196
+ # ---------------------------------------------------------------- 知识卡片的公共件
1197
+ #
1198
+ # `chat_notes.py`(对话线)与 `article_notes.py`(文章线)产出的是**同一种卡片**:
1199
+ # 带 frontmatter + `## AI 摘要` + `[[概念]] — 说明` 的 markdown,都喂给 `compile_wiki`。
1200
+ # 所以这几样放在这里,一份实现两边用——两处各写一份,早晚有一处会漏。
1201
+
1202
+ SEPARATOR = '—' # 与 `compile_wiki` 认的破折号一致(那条描述要能被它读回描述)
1203
+
1204
+ # 文件名里不许出现的字符(Windows 一套 + 控制字符)
1205
+ _ILLEGAL_IN_NAME = set('\\/:*?"<>|') | {chr(code) for code in range(0, 32)}
1206
+
1207
+
1208
+ def plain(text) -> str:
1209
+ """自由文本里的 `[[…]]` 一律拆掉。
1210
+
1211
+ 纪律是「wikilink 只由我们的代码渲染,不由模型写」:`compile_wiki.scan_articles` 收正文里
1212
+ **所有** wikilink,它不会问这是谁写的——模型在摘要里随手一个 `[[X]]`,就会凭空长出一个概念。
1213
+ """
1214
+ out, index = [], 0
1215
+ source = str(text or '')
1216
+ while True:
1217
+ start = source.find('[[', index)
1218
+ if start < 0:
1219
+ out.append(source[index:])
1220
+ break
1221
+ end = source.find(']]', start)
1222
+ if end < 0:
1223
+ out.append(source[index:])
1224
+ break
1225
+ out.append(source[index:start])
1226
+ out.append(source[start + 2:end])
1227
+ index = end + 2
1228
+ return ''.join(out).replace('[[', '').replace(']]', '')
1229
+
1230
+
1231
+ def note_path(out_root, name: str, fallback: str = '未命名'):
1232
+ """一张卡一个文件。名字要过文件名安全:会话/文章名里有 `/` 会写到别的目录去。"""
1233
+ from pathlib import Path as _Path
1234
+ cleaned = ''.join('_' if ch in _ILLEGAL_IN_NAME else ch for ch in str(name))
1235
+ cleaned = ' '.join(cleaned.split())[:80].strip().strip('.')
1236
+ return _Path(out_root) / ('%s.md' % (cleaned or fallback))
1237
+
1238
+
1239
+ # ---------------------------------------------------------------- 微信正文里的界面残留
1240
+
1241
+ # 微信文章正文会带**阅读器自己的界面文字**("在小说阅读器读本章"那一串),以及文末的
1242
+ # 原创标记与公众号名重复。实测:库里 1633 篇笔记,**1484 篇(91%)**的摘要里混着它
1243
+ # (先按"整份文件里出现某条短语"数是 330,那是数漏了——短语有好几种写法,按摘要层数才对)。
1244
+ # 这份清理在真库上跑过:1484 篇 → 清理后 **0 篇**仍带残留。
1245
+ #
1246
+ # `classify_daily.py` 里本来有一份 `AD_PATTERNS`,但它只用在日报那条路上,
1247
+ # **写笔记那条路(`create_reading_notes.py`)一次都没清洗**——而且对着真串比对,
1248
+ # 那份表还漏了一条:真串是「在**公众号**小说中沉浸阅读」,表里写的是
1249
+ # 「在小说阅读器**中**沉浸阅读」。所以这里一次列全,各处共用。
1250
+ _AD_PHRASES = (
1251
+ '在小说阅读器读本章',
1252
+ '在小说阅读器中沉浸阅读',
1253
+ '在公众号小说中沉浸阅读',
1254
+ '去阅读',
1255
+ 'Scan to Follow',
1256
+ '轻触阅读原文',
1257
+ '预览时标签不可点',
1258
+ '继续滑动看下一个',
1259
+ )
1260
+
1261
+ _UNDERSCORE_RUN = None # 惰性编译,避免在 import 期做什么重活
1262
+ _JS_LINK = None
1263
+
1264
+
1265
+ def strip_wx_ads(text: str) -> str:
1266
+ """去掉微信正文里那几样界面残留。
1267
+
1268
+ 处理顺序有讲究:先删短语,再删下划线长串,最后收拾"原创 + 公众号名重复 N 遍"。
1269
+ 只动**已知的**几样——这是给摘要与提示词做清洁,不是改写正文;
1270
+ 拿不准的一律留着(宁可留一点噪音,也不要把用户的话删掉)。
1271
+ """
1272
+ import re as _re
1273
+ global _UNDERSCORE_RUN
1274
+ if _UNDERSCORE_RUN is None:
1275
+ _UNDERSCORE_RUN = _re.compile(r'_{4,}')
1276
+ out = str(text or '')
1277
+ for phrase in _AD_PHRASES:
1278
+ out = out.replace(phrase, ' ')
1279
+ out = _UNDERSCORE_RUN.sub(' ', out)
1280
+ global _JS_LINK
1281
+ if _JS_LINK is None:
1282
+ _JS_LINK = _re.compile(r'\[.*?\]\(javascript:void\(0\);?\)')
1283
+ out = _JS_LINK.sub(' ', out)
1284
+ # 「原创 开源星探 开源星探 开源星探」:公众号名在文末被重复粘了几遍,压成一个
1285
+ out = _re.sub(r'原创\s+(\S{2,12})(?:\s+\1\b){1,}\s*', ' ', out)
1286
+ out = _re.sub(r'原创\s+[一-龥A-Za-z0-9]{2,12}\s*$', '', out)
1287
+ out = _re.sub(r'\b(\S{2,12})(?:\s+\1\b)+', r'\1', out)
1288
+ out = _re.sub(r'[ \t]{2,}', ' ', out)
1289
+ out = _re.sub(r'\n{3,}', '\n\n', out)
1290
+ # 短语被删掉之后可能留下孤零零的空白或行首标点
1291
+ out = _re.sub(r'[ \t]+([,。、;:!?])', r'\1', out)
1292
+ return out.strip()
1293
+
1294
+
1295
+ _FENCED_BLOCK = None
1296
+
1297
+
1298
+ def strip_wx_ads_document(text: str) -> str:
1299
+ """整篇原文用的清洗:**frontmatter 与围栏代码块一个字不动**,其余同 `strip_wx_ads`。
1300
+
1301
+ 删短语的规则**完全复用** `strip_wx_ads`,不另写一套 —— 两处各写一份,迟早会对
1302
+ "什么算残留"给出不同答案(本仓库因为这类分叉吃过亏)。
1303
+
1304
+ 两处防护各有实测理由:
1305
+
1306
+ - **frontmatter**:`去阅读` 是**短词**,完全可能出现在标题里("如何去阅读一本书")。
1307
+ 标题被改就等于改文件名对齐关系 —— 笔记靠 `日期-标题` 找原文,改了就对不上了。
1308
+ 实测当前 35,060 篇里 0 篇的 frontmatter 含短语,所以这一条是**防未来**,不是修现在。
1309
+ - **围栏代码块**:`strip_wx_ads` 末尾那几条空白规整(`[ \\t]{2,}` → ' '、`\\n{3,}` → `\\n\\n`)
1310
+ 对摘要是无损的,对**原文**却会吃掉代码缩进 —— 实测 20 篇带围栏、其中 10 篇围栏内容会变
1311
+ (HTML/JS 示例)。原文是素材,缩进就是内容。
1312
+ """
1313
+ if text.startswith('---'):
1314
+ end = text.find('\n---', 3)
1315
+ front, body = (text[:end + 4], text[end + 4:]) if end != -1 else ('', text)
1316
+ else:
1317
+ front, body = '', text
1318
+ # `strip_wx_ads` 末尾带 `.strip()`,会把正文首尾空白吃掉。frontmatter 与正文之间那两行
1319
+ # 空行是正文的一部分,所以这里把首尾空白单独摘出来、洗完再放回去。
1320
+ lead = body[:len(body) - len(body.lstrip())]
1321
+ tail = body[len(body.rstrip()):]
1322
+ core = body.strip()
1323
+ global _FENCED_BLOCK
1324
+ if _FENCED_BLOCK is None:
1325
+ import re as _re
1326
+ _FENCED_BLOCK = _re.compile(r'^```.*?^```', _re.S | _re.M)
1327
+ # 占位符用 chr(0) 拼,不写字面反斜杠:本仓有"JSON 层吃掉转义"的前科,能避开就避开。
1328
+ mark = chr(0) + 'FENCE' + chr(0)
1329
+ blocks = []
1330
+
1331
+ def stash(match):
1332
+ blocks.append(match.group(0))
1333
+ return mark + str(len(blocks) - 1) + mark
1334
+
1335
+ cleaned = strip_wx_ads(_FENCED_BLOCK.sub(stash, core))
1336
+ for index, block in enumerate(blocks):
1337
+ cleaned = cleaned.replace(mark + str(index) + mark, block)
1338
+ return front + lead + cleaned + tail