weflow-cli 1.11.1 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +378 -0
- package/OPERATIONS.md +359 -11
- package/README.md +4 -0
- package/bin/weflow-cli.ts +289 -37
- package/dist/bin/weflow-cli.js +300 -35
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +5 -0
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +12 -1
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +23 -3
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts +8 -0
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +46 -11
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts.map +1 -1
- package/dist/src/services/assistantEval.js +3 -1
- package/dist/src/services/assistantEval.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +25 -7
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts +3 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +6 -3
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/diagnosticReport.d.ts +80 -0
- package/dist/src/services/diagnosticReport.d.ts.map +1 -0
- package/dist/src/services/diagnosticReport.js +191 -0
- package/dist/src/services/diagnosticReport.js.map +1 -0
- package/dist/src/services/exportService.d.ts +7 -0
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +1 -1
- package/dist/src/services/exportService.js.map +1 -1
- package/dist/src/services/wechatClient.d.ts +9 -3
- package/dist/src/services/wechatClient.d.ts.map +1 -1
- package/dist/src/services/wechatClient.js +55 -22
- package/dist/src/services/wechatClient.js.map +1 -1
- package/docs/DECISIONS.md +662 -1
- package/docs/MCP.md +1 -1
- package/docs/NEXT_ACTIONS.md +236 -0
- package/docs/POSITIONING.md +68 -0
- package/docs/PROJECT_STATE.md +119 -17
- package/docs/RELEASING.md +14 -0
- package/package.json +88 -86
- package/scripts/_utils.py +120 -4
- package/scripts/backfill_articles.py +64 -14
- package/scripts/biz_daily.py +118 -11
- package/scripts/daily_task.ps1 +132 -0
- package/scripts/export_chat_html.py +79 -31
- package/scripts/install_tasks.cmd +32 -0
- package/scripts/install_tasks.ps1 +91 -0
- package/scripts/jev_client.py +335 -3
- package/scripts/nt_decrypt.py +79 -14
- package/scripts/pipeline.py +21 -0
- package/scripts/recall_eval.py +247 -0
- package/scripts/repro_customer_init.cmd +86 -0
- package/scripts/semantic_search.py +926 -51
- package/src/core/dbPathService.ts +5 -0
- package/src/core/ntCore.ts +34 -3
- package/src/core/sqlcipherCore.ts +47 -11
- package/src/services/assistantEval.ts +3 -1
- package/src/services/assistantTools.ts +2471 -2452
- package/src/services/configService.ts +12 -3
- package/src/services/diagnosticReport.ts +237 -0
- package/src/services/exportService.ts +14 -2
- package/src/services/wechatClient.ts +418 -380
package/scripts/jev_client.py
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"""
|
|
24
24
|
import json
|
|
25
25
|
import os
|
|
26
|
+
import sys
|
|
26
27
|
import time
|
|
27
28
|
import urllib.error
|
|
28
29
|
import urllib.request
|
|
@@ -102,6 +103,122 @@ def _check_choice(key, question, answer):
|
|
|
102
103
|
% (key, picked, top))
|
|
103
104
|
|
|
104
105
|
|
|
106
|
+
# ===== OpenRouter(第二条决策模型通路,D-120)=====
|
|
107
|
+
#
|
|
108
|
+
# **为什么需要它**:原生 `api.typesafe.ai` 在本机所在地区被挡(实测 HTTP 451
|
|
109
|
+
# "not available in your region"),于是整层判断静默退化——最近 40 个完整天里
|
|
110
|
+
# 0 篇带 relevanceScore。OpenRouter 上 TypeSafe 把同一个决策模型发布成了
|
|
111
|
+
# `typesafe/jev-router`,从这里能通(实测 200)。
|
|
112
|
+
#
|
|
113
|
+
# ⚠️ **地理限制是逐模型的**(实测 2026-10-17):
|
|
114
|
+
# · `typesafe/jev-router` → 通;
|
|
115
|
+
# · `:free` 的模型 → 通(零成本,但免费额度 50 次/天);
|
|
116
|
+
# · 付费通用模型(如 `openai/gpt-4o-mini`)→ **HTTP 403 "This model is not available
|
|
117
|
+
# in your region."**,失败在路由的 "Gate Endpoints with Geo Restrictions" 一步。
|
|
118
|
+
# 所以换模型时这条路必须重测,不能假定"能调一个就能调所有"。
|
|
119
|
+
OPENROUTER_BASE = 'https://openrouter.ai/api/v1'
|
|
120
|
+
OPENROUTER_ENDPOINT = OPENROUTER_BASE + '/chat/completions'
|
|
121
|
+
OPENROUTER_MODELS_ENDPOINT = OPENROUTER_BASE + '/models'
|
|
122
|
+
# 国内端点:这两条是本机实测能通、且都已有 key 的(D-120)。DeepSeek 是默认选择:
|
|
123
|
+
# 1.0 秒/篇、key 已在配置里、无需新厂商,成本量级 ¥1/天(500 篇)。
|
|
124
|
+
DEEPSEEK_BASE = 'https://api.deepseek.com/v1'
|
|
125
|
+
DASHSCOPE_BASE = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
|
126
|
+
OPENROUTER_DEFAULT_MODEL = 'typesafe/jev-router'
|
|
127
|
+
# OpenRouter 的两个归属头(可选,但官方文档建议带;不含任何用户数据)。
|
|
128
|
+
OPENROUTER_REFERER = 'https://github.com/zhuobichen/weflow-cli'
|
|
129
|
+
OPENROUTER_TITLE = 'weflow-cli'
|
|
130
|
+
# 预算给得**大**是有原因的:jev-router 会路由到推理模型(实测 `x-ai/grok-4.7`、
|
|
131
|
+
# `z-ai/glm-5.3-flash`),推理 token 会把预算吃光、正文留空。实测 max_tokens=800 时
|
|
132
|
+
# 一个免费推理模型 finish_reason=length、content 为空 —— 那不是"模型不会",是预算给少了。
|
|
133
|
+
# 反过来,这个值也是 OpenRouter 预检额度的依据(额度不足会 402 并报"你只能负担 N tokens")。
|
|
134
|
+
OPENROUTER_MAX_TOKENS = 4000
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _check_score(key, question, answer):
|
|
138
|
+
"""`score` 答案必须是数字(`score_to_relevance` 会拿它取整索引)。
|
|
139
|
+
|
|
140
|
+
没有这一条会怎样:`score` 缺失时 `score_to_relevance` 抛的是 `JevError('缺少 score')`,
|
|
141
|
+
但一个**字符串**分数(模型回 "高")会一路走到 `int(float('高'))` 抛 `ValueError`——
|
|
142
|
+
调用方的 `except JevError` 接不住,整天日报挂掉。所以在这里挡住。
|
|
143
|
+
"""
|
|
144
|
+
if question.get('type') != 'score':
|
|
145
|
+
return
|
|
146
|
+
value = answer.get('score')
|
|
147
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
148
|
+
raise JevError('%s:score 答案里的 score 不是数字:%r' % (key, value))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _check_noul(key, question, answer):
|
|
152
|
+
"""`noul` 答案必须是 [0,1] 的概率 —— **不是布尔**。
|
|
153
|
+
|
|
154
|
+
仓库里踩过同型的坑(D-034 附近):把 noul 的浮点当布尔读,每个问题都读成"否",
|
|
155
|
+
而输出还把责任推给模型。所以这里既挡 `true/false`,也挡越界的数。
|
|
156
|
+
"""
|
|
157
|
+
if question.get('type') != 'noul':
|
|
158
|
+
return
|
|
159
|
+
value = answer.get('noul')
|
|
160
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
161
|
+
raise JevError('%s:noul 答案是概率(0~1 的小数),不是布尔:%r' % (key, value))
|
|
162
|
+
if not 0.0 <= float(value) <= 1.0:
|
|
163
|
+
raise JevError('%s:noul 概率不在 [0,1]:%r' % (key, value))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_openrouter_prompt(state, questions):
|
|
167
|
+
"""把类型化的问题渲染成一段 chat 提示,要求**只输出一个 JSON 对象**。
|
|
168
|
+
|
|
169
|
+
形状必须与原生 `systemone` 的 answers 一致,因为下游七个调用方读的是同一套键:
|
|
170
|
+
`choice` → `{probabilities, choice}`、`score` → `{score}`、`noul` → `{noul}`(概率)。
|
|
171
|
+
渲染的是**同一份** `questions`(含 criteria 与判据文本),所以换通路不换判据。
|
|
172
|
+
"""
|
|
173
|
+
lines = ['你是判断器。读下面的材料,逐题作答,**只输出一个 JSON 对象**:',
|
|
174
|
+
'不要解释、不要 markdown 代码块、不要在 JSON 前后加任何其它字。', '', '题目(每题都要答,键名照抄):']
|
|
175
|
+
shape = []
|
|
176
|
+
for name, question in (questions or {}).items():
|
|
177
|
+
kind = (question or {}).get('type')
|
|
178
|
+
instructions = (question or {}).get('instructions') or ''
|
|
179
|
+
if kind == 'choice':
|
|
180
|
+
criteria = (question or {}).get('criteria') or {}
|
|
181
|
+
lines.append('- %s(choice):%s' % (name, instructions))
|
|
182
|
+
lines.append(' 候选(probabilities 的键必须就是这些,且和等于 1):')
|
|
183
|
+
for option, criterion in criteria.items():
|
|
184
|
+
lines.append(' · %s:%s' % (option, criterion))
|
|
185
|
+
shape.append('"%s": {"probabilities": {%s}, "choice": "<概率最大的候选>"}'
|
|
186
|
+
% (name, ', '.join('"%s": 0.0' % o for o in criteria)))
|
|
187
|
+
elif kind == 'score':
|
|
188
|
+
criteria = (question or {}).get('criteria') or []
|
|
189
|
+
lines.append('- %s(score):%s' % (name, instructions))
|
|
190
|
+
for index, criterion in enumerate(criteria):
|
|
191
|
+
lines.append(' %d = %s' % (index, criterion))
|
|
192
|
+
shape.append('"%s": {"score": 0}' % name)
|
|
193
|
+
elif kind == 'noul':
|
|
194
|
+
lines.append('- %s(noul):%s' % (name, instructions))
|
|
195
|
+
lines.append(' noul 是"该陈述成立"的概率(0~1 的小数),**不是** true/false。')
|
|
196
|
+
shape.append('"%s": {"noul": 0.0}' % name)
|
|
197
|
+
lines += ['', '输出结构(严格照抄形状,数字自己填):',
|
|
198
|
+
'{%s}' % ', '.join(shape),
|
|
199
|
+
'', '规则:probabilities 的键集与候选完全一致、和等于 1;choice 必须等于概率最大的键;',
|
|
200
|
+
'score 按上面的刻度取值;noul 是 0~1 的小数。', '', '材料:', str(state)]
|
|
201
|
+
return '\n'.join(lines)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parse_json_object(content, label='响应'):
|
|
205
|
+
"""从模型输出里取一个 JSON 对象。取不到就抛 `JevError`,**不猜、不修补**。"""
|
|
206
|
+
text = (content or '').strip()
|
|
207
|
+
if not text:
|
|
208
|
+
raise JevError('%s 里没有正文(空 content)——常见原因是推理 token 吃光了预算,'
|
|
209
|
+
'把 OPENROUTER_MAX_TOKENS 调大再试' % label)
|
|
210
|
+
start, end = text.find('{'), text.rfind('}')
|
|
211
|
+
if start < 0 or end <= start:
|
|
212
|
+
raise JevError('%s 里没有 JSON 对象:%s' % (label, text[:200]))
|
|
213
|
+
try:
|
|
214
|
+
parsed = json.loads(text[start:end + 1])
|
|
215
|
+
except ValueError as error:
|
|
216
|
+
raise JevError('%s 不是合法 JSON(%s):%s' % (label, error, text[start:end + 1][:200]))
|
|
217
|
+
if not isinstance(parsed, dict):
|
|
218
|
+
raise JevError('%s 的 JSON 顶层不是对象' % label)
|
|
219
|
+
return parsed
|
|
220
|
+
|
|
221
|
+
|
|
105
222
|
def resolve_key(explicit='', config=None):
|
|
106
223
|
"""key 的取值顺序:显式传入 → `TYPESAFE_API_KEY` → 配置里(自动解密)。
|
|
107
224
|
|
|
@@ -286,9 +403,224 @@ class JevClient:
|
|
|
286
403
|
}
|
|
287
404
|
|
|
288
405
|
|
|
289
|
-
|
|
290
|
-
"""
|
|
406
|
+
class ChatJevClient(JevClient):
|
|
407
|
+
"""**用任意 OpenAI 兼容的对话模型**充当决策模型(默认指 OpenRouter)。
|
|
408
|
+
|
|
409
|
+
与 `JevClient` **同一个鸭子类型**(`decide` / `decide_article` / `last_model`),所以
|
|
410
|
+
日报判断、检索重排、草稿回复、欠话台账那些调用方不需要知道换了通路。差别只有两处:
|
|
411
|
+
|
|
412
|
+
· 传输是 chat/completions(原生是 TypeSafe 自己的 `systemone` 端点);
|
|
413
|
+
· 契约由**我们**在提示里写死、再由 `_check_choice` / `_check_score` / `_check_noul`
|
|
414
|
+
逐条验(原生是服务端直接给结构化答案,客户端只做校验)。多出来的这条"自己保证契约"
|
|
415
|
+
正是**换通路真正的代价**:它不该被藏起来,所以提示词与校验都在这里,且漂移会当场抛。
|
|
416
|
+
|
|
417
|
+
**base_url 可换,是这条路的核心价值**:同一个实现既能指 OpenRouter,也能指**中国地区能通的**
|
|
418
|
+
国内端点(`https://api.deepseek.com/v1`、`https://dashscope.aliyuncs.com/compatible-mode/v1`)。
|
|
419
|
+
【实测 2026-10-17】契约都对得上:
|
|
420
|
+
|
|
421
|
+
· `typesafe/jev-router`(OpenRouter)→ 过;· `openai/gpt-4o-mini` → **403 地区封锁**;
|
|
422
|
+
· `deepseek-chat` → 过(**1.0s**/篇);· `deepseek-reasoner` → 过(7.2s);
|
|
423
|
+
· `qwen-plus`(百炼)→ 过(2.3s)。
|
|
424
|
+
|
|
425
|
+
⚠️ **契约一致不等于判断一致**:同一篇文章上 `deepseek-chat` 给 relevance=1 / noul=0.15,
|
|
426
|
+
`qwen-plus` 给 2 / 0.85。换判断器会改变取值分布,而下游阈值是对着 Jev 拍的——
|
|
427
|
+
这就是为什么原始分与 `usage.model` 都要落盘。
|
|
428
|
+
"""
|
|
429
|
+
|
|
430
|
+
def __init__(self, api_key, model=OPENROUTER_DEFAULT_MODEL, timeout=120,
|
|
431
|
+
max_tokens=OPENROUTER_MAX_TOKENS, base_url=OPENROUTER_BASE,
|
|
432
|
+
extra_headers=None, provider='openrouter', engine=None):
|
|
433
|
+
super().__init__(api_key, model=model, timeout=timeout)
|
|
434
|
+
self.base_url = (base_url or OPENROUTER_BASE).rstrip('/')
|
|
435
|
+
self.extra_headers = dict(extra_headers or {})
|
|
436
|
+
self.max_tokens = max_tokens
|
|
437
|
+
# 记录自己是谁,方便落盘/报错时区分"哪条通路判的"。
|
|
438
|
+
self.provider = provider
|
|
439
|
+
# `engine` 不为空时**走它**(`AIEngine.chat()`)—— 本地 Ollama / LM Studio 就是这条。
|
|
440
|
+
# 这条存在的原因:`judgeProvider=chat` 必须尊重 `aiEngine=local`(见 D-124)。
|
|
441
|
+
self.engine = engine
|
|
442
|
+
|
|
443
|
+
def _endpoint(self):
|
|
444
|
+
return self.base_url + '/chat/completions'
|
|
445
|
+
|
|
446
|
+
def _post_once(self, url, payload=None):
|
|
447
|
+
"""与原生同形,多带一份可配的头(OpenRouter 的归属头由预设给),并把**地区封锁**单独报出来。"""
|
|
448
|
+
data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
|
449
|
+
headers = {'Content-Type': 'application/json',
|
|
450
|
+
'Authorization': f'Bearer {self.api_key}'}
|
|
451
|
+
headers.update(self.extra_headers)
|
|
452
|
+
request = urllib.request.Request(
|
|
453
|
+
url, data=data, method='POST' if data else 'GET', headers=headers)
|
|
454
|
+
try:
|
|
455
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
456
|
+
return json.loads(response.read())
|
|
457
|
+
except urllib.error.HTTPError as error:
|
|
458
|
+
detail = ''
|
|
459
|
+
try:
|
|
460
|
+
detail = error.read().decode('utf-8', 'replace')[:400]
|
|
461
|
+
except Exception:
|
|
462
|
+
pass
|
|
463
|
+
# 403 在 OpenRouter 上有**两种**含义,混在一起会让人以为是 key 不对:
|
|
464
|
+
# "not available in your region"(地区封锁,换模型才有用)vs 鉴权被拒。
|
|
465
|
+
if error.code == 403 and 'region' in detail.lower():
|
|
466
|
+
raise JevError(f'这个模型在当前地区不可用(HTTP 403):{detail}')
|
|
467
|
+
if error.code == 402:
|
|
468
|
+
# 这条最容易被误读成"模型坏了"。实际是**预检额度**:OpenRouter 按
|
|
469
|
+
# max_tokens×单价 估一个上界,额度不够就拒,并告诉你负担得起多少。
|
|
470
|
+
raise JevError(f'OpenRouter 余额/额度不足(HTTP 402):{detail}')
|
|
471
|
+
if error.code in (401, 403):
|
|
472
|
+
raise JevError(f'鉴权被拒(HTTP {error.code})。key 可能不对或已过期:{detail}')
|
|
473
|
+
failure = JevError(f'HTTP {error.code}: {detail}')
|
|
474
|
+
failure.retryable = error.code in RETRY_STATUS
|
|
475
|
+
raise failure
|
|
476
|
+
except urllib.error.URLError as error:
|
|
477
|
+
failure = JevError(f'连不上 {url}: {error.reason}')
|
|
478
|
+
failure.retryable = True
|
|
479
|
+
raise failure
|
|
480
|
+
|
|
481
|
+
def models(self):
|
|
482
|
+
"""OpenRouter 的模型清单(id 列表)。换地区/换模型时先在这里确认名字。
|
|
483
|
+
|
|
484
|
+
只有 OpenRouter 有这条接口;国内端点没有同形的清单,所以命中非 OpenRouter 时返回空列表
|
|
485
|
+
而不是报错 —— 调用方(`jev_probe`)本来就只用它做"名字写错了吗"的自检。
|
|
486
|
+
"""
|
|
487
|
+
if 'openrouter.ai' not in self.base_url:
|
|
488
|
+
return []
|
|
489
|
+
body = self._post(OPENROUTER_MODELS_ENDPOINT)
|
|
490
|
+
return [m.get('id') for m in (body.get('data') or []) if isinstance(m, dict)]
|
|
491
|
+
|
|
492
|
+
def decide(self, state, questions):
|
|
493
|
+
"""返回 `(answers, usage)`,形状与原生一致;**任何契约不成立都抛 `JevError`**。
|
|
494
|
+
|
|
495
|
+
`usage` 多带一个 `cost`(OpenRouter 直接回报美元成本,原生没有这个字段),
|
|
496
|
+
并把 OpenRouter 的 token 键名对齐成原生那套(`prompt_tokens`→`input_tokens`)——
|
|
497
|
+
`decide.py` 与 `jev_probe.py` 读的是后一个名字,名字对不上会让成本统计静默变成 0。
|
|
498
|
+
"""
|
|
499
|
+
prompt = build_openrouter_prompt(state, questions)
|
|
500
|
+
content, usage = self._chat_once(prompt)
|
|
501
|
+
answers = _parse_json_object(content, 'chat 响应')
|
|
502
|
+
for key, question in (questions or {}).items():
|
|
503
|
+
if not isinstance(question, dict):
|
|
504
|
+
continue
|
|
505
|
+
answer = answers.get(key)
|
|
506
|
+
if not isinstance(answer, dict):
|
|
507
|
+
raise JevError('%s:响应里缺这一题的答案(%r)——契约漂移' % (key, answer))
|
|
508
|
+
_check_choice(key, question, answer)
|
|
509
|
+
_check_score(key, question, answer)
|
|
510
|
+
_check_noul(key, question, answer)
|
|
511
|
+
return answers, usage
|
|
512
|
+
|
|
513
|
+
def _chat_once(self, prompt):
|
|
514
|
+
"""取一次文字回复,返回 `(content, usage)`。
|
|
515
|
+
|
|
516
|
+
有 `engine` 就走它(`AIEngine.chat()`,本地 Ollama / LM Studio 是这条):那种情况
|
|
517
|
+
**拿不到 usage 里的 token 数与成本** —— 这是引擎层既有的边界(它只回字符串),
|
|
518
|
+
不是这里漏了;`model` 仍然会落盘,所以"谁判的"依然可查。
|
|
519
|
+
没有 `engine` 时走 OpenAI 兼容端点,token 与 cost 都能拿到。
|
|
520
|
+
"""
|
|
521
|
+
if self.engine is not None:
|
|
522
|
+
content = self.engine.chat(prompt, max_tokens=self.max_tokens)
|
|
523
|
+
served = getattr(self.engine, 'model', '') or self.model
|
|
524
|
+
self.last_model = served
|
|
525
|
+
return content or '', {'model': served}
|
|
526
|
+
body = self._post(self._endpoint(), {
|
|
527
|
+
'model': self.model,
|
|
528
|
+
'messages': [{'role': 'user', 'content': prompt}],
|
|
529
|
+
'temperature': 0,
|
|
530
|
+
'max_tokens': self.max_tokens,
|
|
531
|
+
})
|
|
532
|
+
message = ((body.get('choices') or [{}])[0] or {}).get('message') or {}
|
|
533
|
+
usage = dict(body.get('usage') or {})
|
|
534
|
+
if 'prompt_tokens' in usage and 'input_tokens' not in usage:
|
|
535
|
+
usage['input_tokens'] = usage['prompt_tokens']
|
|
536
|
+
if 'completion_tokens' in usage and 'output_tokens' not in usage:
|
|
537
|
+
usage['output_tokens'] = usage['completion_tokens']
|
|
538
|
+
if body.get('model'):
|
|
539
|
+
# 路由到谁**每次都可能不同**(实测 grok-4.7 / glm-5.3-flash),所以落盘这个值
|
|
540
|
+
# 才有意义:不然"线上跑的和测过的不是同一个东西"永远不可见。
|
|
541
|
+
usage['model'] = body['model']
|
|
542
|
+
self.last_model = body['model']
|
|
543
|
+
return message.get('content') or '', usage
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def create_client(api_key='', model='', timeout=120, config=None):
|
|
547
|
+
"""拿到 key 就返回客户端,没有就返回 None(调用方据此走老路)。
|
|
548
|
+
|
|
549
|
+
走哪条通路由配置 `judgeProvider` 决定,**默认 `native`**(与引入这个开关之前完全一致):
|
|
550
|
+
|
|
551
|
+
· `native` → `api.typesafe.ai`(本机被地区封锁,见 D-120);
|
|
552
|
+
· `chat` → **中国地区能通的对话模型**,走配置里已有的那套(`aiBaseUrl` 中转站 /
|
|
553
|
+
`aiModel` / `deepseekApiKey`)——一句话就能打开,不需要新厂商、新 key。
|
|
554
|
+
实测 `deepseek-chat` 1.0 秒/篇且契约通过,成本量级 ¥1/天;
|
|
555
|
+
· `openrouter` → OpenRouter 的 chat/completions,模型取 `openrouterJudgeModel`
|
|
556
|
+
(默认 `typesafe/jev-router`),key 取 `openrouterApiKey`。
|
|
557
|
+
"""
|
|
558
|
+
judge_provider = ''
|
|
559
|
+
openrouter_key = openrouter_model = ''
|
|
560
|
+
chat_key = ''
|
|
561
|
+
chat_base = chat_model = None
|
|
562
|
+
try:
|
|
563
|
+
import _utils
|
|
564
|
+
judge_provider = (getattr(_utils, 'get_judge_provider')(config) or '').strip().lower()
|
|
565
|
+
openrouter_key = (getattr(_utils, 'get_openrouter_key')(config) or '').strip()
|
|
566
|
+
openrouter_model = (getattr(_utils, 'get_openrouter_model')(config) or '').strip()
|
|
567
|
+
# 中转站口径与"配置了 aiBaseUrl 的对话模型"同源(`_utils.get_chat_endpoint` 委托
|
|
568
|
+
# `_custom_endpoint`):只配了 base 没配 model 时它会补 `deepseek-chat`。
|
|
569
|
+
chat_base, chat_model = getattr(_utils, 'get_chat_endpoint')(config) or (None, None)
|
|
570
|
+
chat_key = (getattr(_utils, 'get_api_key')(config) or '').strip()
|
|
571
|
+
except Exception:
|
|
572
|
+
# 配置读不出来就当没配(与 `get_typesafe_key` 同一态度:跨机器拷配置不该崩)。
|
|
573
|
+
# `judge_provider` 若已经读到而这里抛了,后面的分支会因为没有 key 返回 None ——
|
|
574
|
+
# 那是安全的方向(退回老路),不是静默用错通路。
|
|
575
|
+
pass
|
|
576
|
+
if judge_provider == 'chat':
|
|
577
|
+
# **先看用户选的是哪个引擎**:`judgeProvider=chat` 的语义是"用配置里的对话模型"。
|
|
578
|
+
# 无条件用 `deepseekApiKey` 会让 `aiEngine=local` 的用户被送出境外(D-124)。
|
|
579
|
+
engine_type = 'deepseek'
|
|
580
|
+
local_detect = None
|
|
581
|
+
try:
|
|
582
|
+
engine_type = (getattr(_utils, 'get_ai_engine')(config) or 'deepseek').strip().lower()
|
|
583
|
+
local_detect = getattr(_utils, 'detect_local_engine', None)
|
|
584
|
+
except Exception:
|
|
585
|
+
pass
|
|
586
|
+
if engine_type in ('local', 'ollama'):
|
|
587
|
+
ok, local_engine, desc = (False, None, '检测函数不可用')
|
|
588
|
+
if local_detect is not None:
|
|
589
|
+
try:
|
|
590
|
+
ok, local_engine, desc = local_detect()
|
|
591
|
+
except Exception as error: # noqa: BLE001
|
|
592
|
+
desc = '%s: %s' % (type(error).__name__, error)
|
|
593
|
+
if not ok:
|
|
594
|
+
# **不退回云端**:宁可这次没有判断,也不把正文发去用户没选的地方。
|
|
595
|
+
print('[WARN] aiEngine=%s 但没检测到本地推理服务(%s):判断器不会退回云端,'
|
|
596
|
+
'这次不可用,判断将走 LLM 路径' % (engine_type, desc), file=sys.stderr)
|
|
597
|
+
return None
|
|
598
|
+
return ChatJevClient('local', model=getattr(local_engine, 'model', '') or 'local',
|
|
599
|
+
base_url=getattr(local_engine, 'base_url', '') or '',
|
|
600
|
+
engine=local_engine, timeout=timeout, provider='chat-local')
|
|
601
|
+
if engine_type == 'claude':
|
|
602
|
+
print('[WARN] judgeProvider=chat 只支持 OpenAI 兼容端点,aiEngine=claude 不行:'
|
|
603
|
+
'要云端判断请用 judgeProvider=openrouter,或把 aiEngine 设回 deepseek',
|
|
604
|
+
file=sys.stderr)
|
|
605
|
+
return None
|
|
606
|
+
if not chat_key:
|
|
607
|
+
print('[WARN] judgeProvider=chat 但没配对话模型的 key(deepseekApiKey):'
|
|
608
|
+
'决策模型这次不可用,判断将退回 LLM 路径', file=sys.stderr)
|
|
609
|
+
return None
|
|
610
|
+
# 配了 `aiBaseUrl`(中转站)就用它,否则直连 DeepSeek。
|
|
611
|
+
return ChatJevClient(chat_key, model=model or chat_model or 'deepseek-chat',
|
|
612
|
+
base_url=chat_base or DEEPSEEK_BASE, timeout=timeout, provider='chat')
|
|
613
|
+
if judge_provider == 'openrouter':
|
|
614
|
+
if not openrouter_key:
|
|
615
|
+
print('[WARN] judgeProvider=openrouter 但没配 openrouterApiKey:'
|
|
616
|
+
'决策模型这次不可用,判断将退回 LLM 路径', file=sys.stderr)
|
|
617
|
+
return None
|
|
618
|
+
return ChatJevClient(openrouter_key,
|
|
619
|
+
model=model or openrouter_model or OPENROUTER_DEFAULT_MODEL,
|
|
620
|
+
timeout=timeout, provider='openrouter',
|
|
621
|
+
extra_headers={'HTTP-Referer': OPENROUTER_REFERER,
|
|
622
|
+
'X-Title': OPENROUTER_TITLE})
|
|
291
623
|
key = resolve_key(api_key, config)
|
|
292
624
|
if not key:
|
|
293
625
|
return None
|
|
294
|
-
return JevClient(key, model=model, timeout=timeout)
|
|
626
|
+
return JevClient(key, model=model or DEFAULT_MODEL, timeout=timeout)
|
package/scripts/nt_decrypt.py
CHANGED
|
@@ -795,6 +795,51 @@ def get_favorites(db_path, key_hex, limit=100, offset=0, keyword=None, fav_type=
|
|
|
795
795
|
return {"error": str(e).split('\n')[0][:200]}
|
|
796
796
|
|
|
797
797
|
|
|
798
|
+
#: 非文本消息在**列表预览**里的显示形态。与 `sqlcipherCore.processMessage` 的口径一致。
|
|
799
|
+
#: 列表预览只需要回答"这是什么",不需要内容 —— 而 49 类的 `source` 往往是被压缩的
|
|
800
|
+
#: appmsg,硬去剥 XML 标签只会得到 `(/`$` 这种碎片(2026-10-19 用户在 `sessions` 里看到)。
|
|
801
|
+
_PREVIEW_LABELS = {
|
|
802
|
+
3: '[图片]', 34: '[语音]', 43: '[视频]', 47: '[表情]', 48: '[位置]',
|
|
803
|
+
42: '[名片]', 49: '[链接/文件]', 10000: '[系统消息]', 10002: '[系统消息]',
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _clean_preview(text, limit=50):
|
|
808
|
+
"""预览用:清掉控制字符、压掉换行/连续空白、去掉行首的二进制残渣、截断。
|
|
809
|
+
|
|
810
|
+
行首清洗只针对 **ASCII 标点**:群消息的 `source` 有时前面带三四个字节的碎片
|
|
811
|
+
(实测一条广告消息长这样:`(/ }25984981651785937@openim:🔰周黑鸭…`),
|
|
812
|
+
不处理的话列表第一列就是这堆符号。中文标点与 emoji 开头**不动**——
|
|
813
|
+
`?好的`、`😂收到` 都是正常消息。
|
|
814
|
+
"""
|
|
815
|
+
if not isinstance(text, str):
|
|
816
|
+
return ''
|
|
817
|
+
cleaned = ''.join(ch for ch in text if ch >= ' ' or ch == '\t')
|
|
818
|
+
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
|
819
|
+
# 空格也要算在"垃圾前缀"里:实测前缀是 `(/ }`,中间那个空格会把
|
|
820
|
+
# 「连续 ASCII 标点」这条匹配打断,于是整段清理失效(2026-10-19 实测)。
|
|
821
|
+
cleaned = re.sub(r'^[\x20-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]{1,8}(?=[A-Za-z0-9\u4e00-\u9fff])', '', cleaned)
|
|
822
|
+
return cleaned[:limit]
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _looks_broken(text):
|
|
826
|
+
"""这段"文本"其实是被当成文本的二进制吗?
|
|
827
|
+
|
|
828
|
+
判据只看两样:UTF-8 解码时留下的替换字符、以及不可打印字符的占比。
|
|
829
|
+
实测样本:一条 openim 消息的 `message_content` 解出来是
|
|
830
|
+
`(/ }25984981651785937@openim:` 加一个替换字符 —— 直接显示就是垃圾。
|
|
831
|
+
"""
|
|
832
|
+
if not text:
|
|
833
|
+
return False
|
|
834
|
+
# 出现**任何**替换字符就说明原始字节不是合法 UTF-8 —— 正常中文/emoji 不会产生它。
|
|
835
|
+
# 阈值不能是"两个以上":实测那条 openim 消息只在结尾丢了一个字节,
|
|
836
|
+
# 前面全是可打印的压缩碎片,比例判据根本拦不住。
|
|
837
|
+
if '\ufffd' in text:
|
|
838
|
+
return True
|
|
839
|
+
printable = sum(1 for ch in text if ch.isprintable())
|
|
840
|
+
return printable / max(1, len(text)) < 0.9
|
|
841
|
+
|
|
842
|
+
|
|
798
843
|
def _summarise(row):
|
|
799
844
|
"""(last_time, summary) from the newest message row of a conversation."""
|
|
800
845
|
last_time = row[0] or 0
|
|
@@ -803,22 +848,42 @@ def _summarise(row):
|
|
|
803
848
|
content_text = row[2]
|
|
804
849
|
|
|
805
850
|
if msg_type == 1:
|
|
806
|
-
#
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
851
|
+
# 文本:`message_content` 与 `source` 里**哪个更像人话就用哪个**。
|
|
852
|
+
# 只认 `message_content` 会踩到"这一列其实是二进制"的消息(见 `_looks_broken`)。
|
|
853
|
+
candidates = []
|
|
854
|
+
for value in (content_text, source_text):
|
|
855
|
+
if isinstance(value, bytes):
|
|
856
|
+
value = value.decode('utf-8', errors='ignore')
|
|
857
|
+
if isinstance(value, str) and value.strip():
|
|
858
|
+
candidates.append(value)
|
|
859
|
+
if not candidates:
|
|
860
|
+
return last_time, ""
|
|
861
|
+
best = min(candidates, key=lambda s: s.count('\ufffd') / max(1, len(s)))
|
|
862
|
+
if _looks_broken(best):
|
|
863
|
+
return last_time, '[内容无法显示]'
|
|
864
|
+
return last_time, _clean_preview(best)
|
|
865
|
+
|
|
866
|
+
# 非文本:先按类型给标签。**不再从 `source` 里剥标签抠文字** —— 那条路只有在
|
|
867
|
+
# XML 是明文 appmsg 时才成立,而 4.x 上它经常是压缩的,抠出来就是二进制碎片。
|
|
868
|
+
#
|
|
869
|
+
# `local_type` 在 4.x 里是**打包值**:高位放 subtype,低 32 位才是基础类型
|
|
870
|
+
# (实测 `25769803825` = 0x6_00000031,低 8 位 0x31 = 49 = 链接/文件)。
|
|
871
|
+
# 不 mask 的话,一张「链接卡片」会被报成 `[消息类型 25769803825]`。
|
|
872
|
+
raw_type = int(msg_type) if msg_type else 0
|
|
873
|
+
base_type = raw_type & 0xFFFFFFFF
|
|
874
|
+
label = _PREVIEW_LABELS.get(base_type)
|
|
875
|
+
if label:
|
|
876
|
+
return last_time, label
|
|
877
|
+
|
|
878
|
+
if isinstance(source_text, str) and source_text.strip():
|
|
815
879
|
clean = re.sub(r'<[^>]+>', '', source_text)
|
|
816
|
-
lines = clean.split('\n')
|
|
817
|
-
if len(lines) > 1 and lines[1].strip():
|
|
818
|
-
return last_time, lines[1].strip()[:50]
|
|
819
880
|
if clean.strip():
|
|
820
|
-
|
|
821
|
-
|
|
881
|
+
# 兜底也要过一遍"这其实是二进制吗":某些消息两列都是压缩数据,
|
|
882
|
+
# 剥掉尖括号后看着像文字,实际是 `(/ }25984...` 这种碎片。
|
|
883
|
+
if _looks_broken(clean):
|
|
884
|
+
return last_time, '[内容无法显示]'
|
|
885
|
+
return last_time, _clean_preview(clean)
|
|
886
|
+
return last_time, ('[消息类型 %d]' % base_type) if base_type else ''
|
|
822
887
|
|
|
823
888
|
|
|
824
889
|
def get_sessions(conns):
|
package/scripts/pipeline.py
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
python scripts/pipeline.py --date 2026-06-09 --skip-classify
|
|
14
14
|
"""
|
|
15
15
|
import sys, os, subprocess, time, shutil
|
|
16
|
+
from datetime import datetime, timedelta
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
|
|
18
19
|
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
@@ -40,6 +41,10 @@ def main():
|
|
|
40
41
|
parser.add_argument('--engine', default='deepseek',
|
|
41
42
|
help='AI 引擎: deepseek(默认) / claude / ollama / local')
|
|
42
43
|
parser.add_argument('--date', help='日期 YYYY-MM-DD')
|
|
44
|
+
parser.add_argument('--yesterday', action='store_true',
|
|
45
|
+
help='跑**昨天**(等价于 --date 昨天)。给计划任务用的:公众号发到 24:00 才停,'
|
|
46
|
+
'当晚跑的那一天永远不全;而"跑昨天"与时刻无关 —— 机器当时在睡觉、'
|
|
47
|
+
'起来补跑,结果完全相同(见 OPERATIONS「让它每天自己跑」)')
|
|
43
48
|
parser.add_argument('--interest', default='AI', help='兴趣主题(默认 AI)')
|
|
44
49
|
parser.add_argument('--wiki-limit', type=int, default=20, help='概念编译数量(默认 20)')
|
|
45
50
|
parser.add_argument('--skip-classify', action='store_true', help='跳过后处理')
|
|
@@ -57,10 +62,20 @@ def main():
|
|
|
57
62
|
'注意:下游步骤(行动建议/概念编译/AI 报告)仍会用 LLM,'
|
|
58
63
|
'要全关请再加 --skip-classify --skip-ai-report')
|
|
59
64
|
parser.add_argument('--ai-report-range', type=int, default=1, help='AI 报告覆盖最近 N 天(默认 1=仅当天)')
|
|
65
|
+
parser.add_argument('--prefetch-bodies', nargs='?', type=int, const=8, default=0, metavar='N',
|
|
66
|
+
help='只转发给 biz_daily:Phase 1 之前先并发把这一天的正文抓进缓存'
|
|
67
|
+
'(N=并发数,默认 8),让 Phase 1 走缓存命中、不再逐篇 sleep。'
|
|
68
|
+
'450 篇约 75 分钟 → 秒级;对上游的请求次数不变(见 biz_daily.prefetch_bodies)')
|
|
60
69
|
parser.add_argument('--source', action='append', default=[], metavar='NAME',
|
|
61
70
|
help='仅处理指定公众号,可重复或用逗号分隔')
|
|
62
71
|
args = parser.parse_args()
|
|
63
72
|
|
|
73
|
+
if args.yesterday:
|
|
74
|
+
if args.date:
|
|
75
|
+
print('[ERROR] --date 与 --yesterday 不能同时给(前者是明确的一天,后者是"昨天")')
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
args.date = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
|
|
78
|
+
|
|
64
79
|
sys.path.insert(0, SCRIPTS_DIR)
|
|
65
80
|
from _utils import load_config, get_api_key
|
|
66
81
|
config = load_config()
|
|
@@ -109,6 +124,12 @@ def main():
|
|
|
109
124
|
% '、'.join(n for n, skipped in (('classify_daily(行动建议)', args.skip_classify),
|
|
110
125
|
('compile_wiki(概念编译)', not args.with_wiki))
|
|
111
126
|
if not skipped))
|
|
127
|
+
if args.prefetch_bodies:
|
|
128
|
+
# 原样转发:`--prefetch-bodies` 不带数字时 biz_daily 用自己的默认并发数,
|
|
129
|
+
# 所以这里不加数字(免得两处默认值各写一份、迟早漂移)。
|
|
130
|
+
step1_args += ['--prefetch-bodies']
|
|
131
|
+
if args.prefetch_bodies != 8: # 显式给了数字才转发数字
|
|
132
|
+
step1_args += [str(args.prefetch_bodies)]
|
|
112
133
|
if not run_step('biz_daily — 抓取+摘要', step1_args):
|
|
113
134
|
sys.exit(1)
|
|
114
135
|
|