weflow-cli 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +217 -1
  2. package/OPERATIONS.md +254 -19
  3. package/README.md +4 -0
  4. package/bin/weflow-cli.ts +260 -51
  5. package/dist/bin/weflow-cli.js +255 -32
  6. package/dist/bin/weflow-cli.js.map +1 -1
  7. package/dist/src/core/dbPathService.d.ts.map +1 -1
  8. package/dist/src/core/dbPathService.js +5 -0
  9. package/dist/src/core/dbPathService.js.map +1 -1
  10. package/dist/src/core/ntCore.d.ts +12 -1
  11. package/dist/src/core/ntCore.d.ts.map +1 -1
  12. package/dist/src/core/ntCore.js +23 -3
  13. package/dist/src/core/ntCore.js.map +1 -1
  14. package/dist/src/core/sqlcipherCore.d.ts +8 -0
  15. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  16. package/dist/src/core/sqlcipherCore.js +46 -11
  17. package/dist/src/core/sqlcipherCore.js.map +1 -1
  18. package/dist/src/services/configService.d.ts +3 -0
  19. package/dist/src/services/configService.d.ts.map +1 -1
  20. package/dist/src/services/configService.js +6 -3
  21. package/dist/src/services/configService.js.map +1 -1
  22. package/dist/src/services/diagnosticReport.d.ts +80 -0
  23. package/dist/src/services/diagnosticReport.d.ts.map +1 -0
  24. package/dist/src/services/diagnosticReport.js +191 -0
  25. package/dist/src/services/diagnosticReport.js.map +1 -0
  26. package/dist/src/services/wechatClient.d.ts +9 -3
  27. package/dist/src/services/wechatClient.d.ts.map +1 -1
  28. package/dist/src/services/wechatClient.js +55 -22
  29. package/dist/src/services/wechatClient.js.map +1 -1
  30. package/docs/DECISIONS.md +347 -1
  31. package/docs/NEXT_ACTIONS.md +7 -1
  32. package/docs/POSITIONING.md +68 -0
  33. package/docs/PROJECT_STATE.md +29 -8
  34. package/package.json +88 -86
  35. package/scripts/_utils.py +120 -4
  36. package/scripts/biz_daily.py +118 -11
  37. package/scripts/daily_task.ps1 +132 -0
  38. package/scripts/export_chat_html.py +12 -1
  39. package/scripts/install_tasks.cmd +32 -0
  40. package/scripts/install_tasks.ps1 +91 -0
  41. package/scripts/jev_client.py +335 -3
  42. package/scripts/nt_decrypt.py +79 -14
  43. package/scripts/pipeline.py +21 -0
  44. package/scripts/repro_customer_init.cmd +86 -0
  45. package/scripts/semantic_search.py +286 -24
  46. package/src/core/dbPathService.ts +5 -0
  47. package/src/core/ntCore.ts +34 -3
  48. package/src/core/sqlcipherCore.ts +47 -11
  49. package/src/services/configService.ts +12 -3
  50. package/src/services/diagnosticReport.ts +237 -0
  51. package/src/services/wechatClient.ts +418 -380
@@ -245,17 +245,28 @@ def fetch_messages_from_shards(db_path, key_hex, salt_hex, talker, date='', pass
245
245
  # 原先的 `if not shards: shards = [db_path]` 是给"可能返回空"的那一份实现擦屁股的。
246
246
  shards = discover_message_shards(db_path)
247
247
  messages = []
248
+ failed = []
248
249
  for shard in shards:
249
250
  shard_conn = None
250
251
  try:
251
252
  shard_key, shard_salt = derive_database_key(shard, key_hex, salt_hex, passphrase)
252
253
  shard_conn, _ = connect(shard, shard_key, shard_salt)
253
254
  messages.extend(fetch_messages(shard_conn, talker, date))
254
- except Exception:
255
+ except Exception as exc:
256
+ # **少读一个分片 = 导出里少一段聊天,而用户看到的是"导出成功"**。
257
+ # 必须留下分片名与原因:只有知道是哪个分片,才分得清"这个分片本来就空"、
258
+ # "密钥/权限不对"还是"文件损坏"。原先直接 continue,等于静默丢数据。
259
+ failed.append((os.path.basename(shard), '%s: %s' % (type(exc).__name__, exc)))
255
260
  continue
256
261
  finally:
257
262
  if shard_conn is not None:
258
263
  shard_conn.close()
264
+ if failed:
265
+ print('[WARN] %d/%d 个消息分片读不了,导出内容会缺这几段(不是"没有消息"):'
266
+ % (len(failed), len(shards)), file=sys.stderr)
267
+ for name, why in failed:
268
+ print(' - %s (%s)' % (name, why), file=sys.stderr)
269
+ sys.stderr.flush()
259
270
  messages.sort(key=lambda row: (int(row[5] or 0), int(row[0] or 0)))
260
271
  return messages
261
272
 
@@ -0,0 +1,32 @@
1
+ @echo off
2
+ chcp 65001 >nul
3
+ setlocal
4
+ rem One-click installer for the two daily-report scheduled tasks.
5
+ rem
6
+ rem Why this .cmd exists: agent sandboxes and restricted shells refuse Task Scheduler writes
7
+ rem as a class (schtasks /create, the Schedule.Service COM API, and even the in-memory
8
+ rem New-ScheduledTask* builders all return Access is denied while reads keep working).
9
+ rem A process started from your own desktop is outside that boundary, so this asks for
10
+ rem elevation itself and runs install_tasks.ps1 there.
11
+
12
+ set "PS1=%~dp0install_tasks.ps1"
13
+ if not exist "%PS1%" (
14
+ echo [ERROR] install_tasks.ps1 not found next to this file.
15
+ pause
16
+ exit /b 1
17
+ )
18
+
19
+ echo Requesting administrator privileges...
20
+ powershell -NoProfile -ExecutionPolicy Bypass -Command "try { Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-NoExit','-File','\"%PS1%\"') -Verb RunAs -ErrorAction Stop; exit 0 } catch { Write-Host ('Could not elevate: ' + $_.Exception.Message) -ForegroundColor Red; exit 1 }"
21
+ if errorlevel 1 (
22
+ echo.
23
+ echo If the elevation prompt was cancelled: right-click this file and pick "Run as administrator".
24
+ pause
25
+ exit /b 1
26
+ )
27
+
28
+ echo.
29
+ echo A new window opened and is registering the tasks. It stays open so you can read the result.
30
+ echo To remove them later: install_tasks.ps1 -Uninstall
31
+ echo.
32
+ pause
@@ -0,0 +1,91 @@
1
+ # Register / update the two scheduled tasks for the official-account daily report.
2
+ # Idempotent: running it again overwrites the definitions with the same values.
3
+ #
4
+ # Why this cannot always be done for you: writing to Task Scheduler is refused in a
5
+ # restricted shell / agent sandbox, and refused as a *class* - measured on 2026-10-19 with
6
+ # an elevated Administrator token, all three write paths said "Access is denied" while
7
+ # reads kept working:
8
+ # schtasks /create ... -> Access is denied
9
+ # (New-Object -ComObject Schedule.Service) -> RegisterTaskDefinition 0x80070005
10
+ # New-ScheduledTaskAction / -SettingsSet ... -> 拒绝访问 (even the in-memory builders)
11
+ # So "I can see the tasks but cannot create them" is not an illusion, and it is not fixed
12
+ # by "run as administrator". The one reliable route is a terminal the sandbox does not
13
+ # supervise: double-click scripts\install_tasks.cmd (it asks for elevation itself), or run
14
+ # this file from your own PowerShell window.
15
+ #
16
+ # Usage:
17
+ # powershell -NoProfile -ExecutionPolicy Bypass -File <repo>\scripts\install_tasks.ps1
18
+ # powershell -NoProfile -ExecutionPolicy Bypass -File <repo>\scripts\install_tasks.ps1 -Uninstall
19
+ #
20
+ # The two tasks:
21
+ # WeFlow Daily 00:05 + at logon -> YESTERDAY (the last complete day)
22
+ # WeFlow Daily Noon 13:00 -> TODAY so far (half a day of pushes)
23
+ #
24
+ # Why 00:05 the next day instead of 24:00 tonight: it is the same instant, but "yesterday"
25
+ # then lines up with the date directory the run writes into, and it does not collide with
26
+ # other jobs on the hour. Official accounts keep pushing until 24:00, so this is the only
27
+ # moment that has the complete day.
28
+ #
29
+ # This file is intentionally ASCII-only: Windows PowerShell 5.1 reads a BOM-less file as
30
+ # the ANSI codepage, so non-ASCII text here turns into mojibake *and* a parse error.
31
+ param(
32
+ [switch]$Uninstall
33
+ )
34
+
35
+ $ErrorActionPreference = 'Stop'
36
+
37
+ $repo = Split-Path -Parent $PSScriptRoot
38
+ $script = Join-Path $repo 'scripts\daily_task.ps1'
39
+ if (-not (Test-Path $script)) { throw "not found: $script (this script belongs in the repo's scripts\ folder)" }
40
+
41
+ $isAdmin = (New-Object Security.Principal.WindowsPrincipal(
42
+ [Security.Principal.WindowsIdentity]::GetCurrent())
43
+ ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
44
+ if (-not $isAdmin) {
45
+ Write-Host 'Need elevation: reopen PowerShell as administrator, then run this again.' -ForegroundColor Yellow
46
+ exit 1
47
+ }
48
+
49
+ if ($Uninstall) {
50
+ foreach ($name in @('WeFlow Daily Noon', 'WeFlow Daily')) {
51
+ if (Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue) {
52
+ Unregister-ScheduledTask -TaskName $name -Confirm:$false
53
+ Write-Host "removed: $name"
54
+ }
55
+ }
56
+ exit 0
57
+ }
58
+
59
+ $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew
60
+ $psExe = 'powershell.exe'
61
+ $common = '-NoProfile -ExecutionPolicy Bypass -File ' + $script
62
+
63
+ # End of day: yesterday. The at-logon trigger is the catch-up: this machine slept for
64
+ # 37 hours and StartWhenAvailable did NOT actually start the missed run (NumberOfMissedRuns=2,
65
+ # no run record), so "it will catch up by itself" is not something to rely on.
66
+ Register-ScheduledTask -TaskName 'WeFlow Daily' `
67
+ -Action (New-ScheduledTaskAction -Execute $psExe -Argument $common) `
68
+ -Trigger @((New-ScheduledTaskTrigger -Daily -At '00:05'),
69
+ (New-ScheduledTaskTrigger -AtLogOn)) `
70
+ -Settings $settings `
71
+ -Description 'WeChat daily report for yesterday (00:05 + at logon, idempotent)' -Force | Out-Null
72
+
73
+ # Noon: today so far. Deliberately NO at-logon trigger: a morning logon would produce a
74
+ # very thin "noon" report and write the .noon_done marker, which would then make the real
75
+ # 13:00 run skip itself (the guard keys on that marker).
76
+ Register-ScheduledTask -TaskName 'WeFlow Daily Noon' `
77
+ -Action (New-ScheduledTaskAction -Execute $psExe -Argument ($common + ' noon')) `
78
+ -Trigger (New-ScheduledTaskTrigger -Daily -At '13:00') `
79
+ -Settings $settings `
80
+ -Description 'WeChat daily report for today so far (13:00)' -Force | Out-Null
81
+
82
+ foreach ($name in @('WeFlow Daily', 'WeFlow Daily Noon')) {
83
+ $t = Get-ScheduledTask -TaskName $name
84
+ $i = Get-ScheduledTaskInfo -TaskName $name
85
+ Write-Host ("{0,-20} {1,-8} next {2}" -f $t.TaskName, $t.State, $i.NextRunTime) -ForegroundColor Green
86
+ }
87
+ Write-Host ''
88
+ Write-Host 'Run one now instead of waiting:'
89
+ Write-Host ' Start-ScheduledTask -TaskName "WeFlow Daily Noon" # today so far'
90
+ Write-Host ' Start-ScheduledTask -TaskName "WeFlow Daily" # yesterday, complete'
91
+ Write-Host 'Logs: <repo>\logs\daily-<date>[-noon].log'
@@ -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
- def create_client(api_key='', model=DEFAULT_MODEL, timeout=120, config=None):
290
- """拿到 key 就返回客户端,没有就返回 None(调用方据此走老路)。"""
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)
@@ -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
- # Text message: use message_content
807
- if isinstance(content_text, str) and content_text:
808
- return last_time, content_text[:50]
809
- if isinstance(content_text, bytes):
810
- return last_time, content_text.decode('utf-8', errors='ignore')[:50]
811
- return last_time, ""
812
-
813
- if isinstance(source_text, str) and source_text:
814
- # Non-text: try to extract from source, stripping XML tags
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
- return last_time, clean.strip()[:50]
821
- return last_time, ""
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):