weflow-cli 1.13.1 → 1.14.1

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.
@@ -38,7 +38,7 @@ INPUT_USD_PER_TOKEN = 0.042 / 1_000_000 # 输出 token 目前不计费
38
38
 
39
39
  # 分类法(列表 + 每类的判据)只有一份,在 `_utils` 里。这里直接引用它,
40
40
  # 所以提示词那条路和决策模型这条路**判的是同一套定义**——回退时不会换标准。
41
- from _utils import TOPIC_CRITERIA, RELEVANCE_NAMES # noqa: E402 (scripts/ 在 sys.path 上)
41
+ from _utils import TOPIC_CRITERIA, RELEVANCE_NAMES, DEFAULT_JUDGE_LOCAL_URL # noqa: E402 (scripts/ 在 sys.path 上)
42
42
 
43
43
  # **顺序即语义**:score.criteria 零索引,第一个元素是 0 分。
44
44
  # 两件事必须分开:判据文本是**给模型看**的(照抄 TOPIC_PROMPT 里对高/中/低的定义,
@@ -543,6 +543,131 @@ class ChatJevClient(JevClient):
543
543
  return message.get('content') or '', usage
544
544
 
545
545
 
546
+ class LocalJevClient(JevClient):
547
+ """**用本机决策模型当判断器**(D-127):`decide.py serve` 的 `/decide`,不出网、无 key。
548
+
549
+ 与 `JevClient` 同一个鸭子类型(`decide` / `decide_article` / `last_model`),差别有三处:
550
+
551
+ · **一个问题一次请求**:本机服务的 `/decide` 只答一个 primitive,而原生 `systemone`
552
+ 一次答一组问题。日报是 3 个问题 ⇒ 3 次调用(本机 0.2–0.6 s/次,整篇仍在 2 s 内);
553
+ · `score` **没有连续分**:用 `choice` 在有序档位上选,把选中档位的下标当分数。
554
+ 下游 `score_to_relevance` 本来就是四舍五入取整,所以语义等价;
555
+ · 概率是**候选集上的相对 softmax、未校准**(APUS 契约原文),`confidence` 只作参考,
556
+ 不要直接拿来做自动放行阈值。
557
+
558
+ 契约不成立时仍由 `_check_choice` / `_check_score` / `_check_noul` 当场抛 ——
559
+ 换通路不降低校验标准。
560
+ """
561
+
562
+ #: noul / score_level 必须使用固定的 yes/no 候选(本机服务的契约要求)。
563
+ BINARY = [{'id': 'yes', 'description': 'The stated proposition is true.'},
564
+ {'id': 'no', 'description': 'The stated proposition is false.'}]
565
+
566
+ #: 主题 → 相关度下限(见 `_calibrate` 的实测依据)。空表就是不做校准。
567
+ TOPIC_RELEVANCE_FLOOR = {'AI': 1}
568
+
569
+ def __init__(self, base_url=DEFAULT_JUDGE_LOCAL_URL, model='apus-reflex-4b', timeout=120):
570
+ super().__init__('local', model=model, timeout=timeout)
571
+ self.base_url = (base_url or DEFAULT_JUDGE_LOCAL_URL).rstrip('/')
572
+ self.provider = 'local'
573
+
574
+ def models(self):
575
+ """本机服务没有模型清单接口;调用方(`jev_probe`)只拿它做"名字写错了吗"的自检。"""
576
+ return []
577
+
578
+ def _decide_one(self, state, question):
579
+ qtype = (question or {}).get('type')
580
+ instructions = (question or {}).get('instructions') or ''
581
+ if isinstance(state, (dict, list)):
582
+ state = json.dumps(state, ensure_ascii=False)
583
+ if qtype == 'choice':
584
+ criteria = question.get('criteria') or {}
585
+ body = self._post(self.base_url + '/decide', {
586
+ 'state': state, 'instructions': instructions, 'primitive': 'choice',
587
+ 'criteria': [{'id': str(k), 'description': str(v)} for k, v in criteria.items()]})
588
+ return {'choice': body.get('choice'),
589
+ 'probabilities': body.get('probabilities') or {},
590
+ 'confidence': body.get('confidence')}
591
+ if qtype == 'score':
592
+ levels = question.get('criteria') or []
593
+ if len(levels) < 2:
594
+ raise JevError('score:有序档位至少要有 2 档')
595
+ # 把原问题("…的实用价值有多高?")改写成陈述式命题:
596
+ # "…的实用价值,达到「中」或更高吗?(中:…;高:…)"。
597
+ # 实测三种写法(同一批 44 篇):三选一 45.5%、纯判据句 59.1%、
598
+ # 这种"保留主语 + 阈值 + 判据" 70.5%。**保留主语**是关键——
599
+ # 只给判据时模型不知道在判什么。
600
+ stem = instructions.strip().rstrip('??').strip()
601
+ if stem.endswith('有多高'):
602
+ stem = stem[:-3].rstrip(',, ')
603
+ hits = 0
604
+ last_name = str(levels[-1]).split(':')[0].split(':')[0]
605
+ for index in range(1, len(levels)):
606
+ name = str(levels[index]).split(':')[0].split(':')[0]
607
+ criteria_text = ';'.join(str(x) for x in levels[index:])
608
+ body = self._post(self.base_url + '/decide', {
609
+ 'state': state,
610
+ 'instructions': '这篇文章%s,达到「%s」或「%s」吗?(%s)'
611
+ % (stem, name, last_name, criteria_text),
612
+ 'primitive': 'noul', 'criteria': self.BINARY})
613
+ probability = body.get('yes_probability')
614
+ if probability is None:
615
+ raise JevError('score:第 %d 档阈值没给 yes_probability(%s)'
616
+ % (index + 1, json.dumps(body, ensure_ascii=False)[:200]))
617
+ hits += float(probability) >= 0.5
618
+ return {'score': float(hits)}
619
+ if qtype == 'noul':
620
+ body = self._post(self.base_url + '/decide', {
621
+ 'state': state, 'instructions': instructions, 'primitive': 'noul',
622
+ 'criteria': self.BINARY})
623
+ if body.get('yes_probability') is None:
624
+ raise JevError('noul:本机服务没给 yes_probability(%s)'
625
+ % json.dumps(body, ensure_ascii=False)[:200])
626
+ return {'noul': float(body['yes_probability'])}
627
+ raise JevError('本机判断器不支持的问题类型:%r' % (qtype,))
628
+
629
+ def decide(self, state, questions):
630
+ """返回 `(answers, usage)`,形状与另外两条通路一致;每个答案都过一遍校验。"""
631
+ answers = {}
632
+ started = time.time()
633
+ for key, question in (questions or {}).items():
634
+ if not isinstance(question, dict):
635
+ continue
636
+ answer = self._decide_one(state, question)
637
+ _check_choice(key, question, answer)
638
+ _check_score(key, question, answer)
639
+ _check_noul(key, question, answer)
640
+ answers[key] = answer
641
+ self._calibrate(answers)
642
+ usage = {'model': self.model, 'provider': 'local',
643
+ 'elapsed_ms': round((time.time() - started) * 1000),
644
+ 'calibration': dict(self.TOPIC_RELEVANCE_FLOOR)}
645
+ self.last_model = self.model
646
+ return answers, usage
647
+
648
+ def _calibrate(self, answers):
649
+ """按主题给相关度加下限 —— **这是实测出来的,不是拍脑袋**。
650
+
651
+ 211 篇同题对比(2026-10-16~21,Jev 实判):本机模型对 `AI` 类文章的相关度
652
+ 系统性低估一档——`低→中` 33 次、`中→中` 仅 5 次,该类一致率 20.0%,
653
+ 而政治/文学/新闻都在 90% 以上。加下限后 AI 类 77.5%、总体 73.9% → 84.4%。
654
+
655
+ 为什么是「下限」而不是「整体 +1」:统一 +1 只有 82.9%,且会把本来判 `高`
656
+ 的压成越界值;只把 `低` 抬到 `中` 才对得上 Jev 的分布。
657
+ """
658
+ topic_answer = answers.get('topic') or {}
659
+ score_answer = answers.get('relevance') or {}
660
+ topic = topic_answer.get('choice')
661
+ if topic is None or 'score' not in score_answer:
662
+ return
663
+ floor = self.TOPIC_RELEVANCE_FLOOR.get(topic)
664
+ if floor is None:
665
+ return
666
+ if float(score_answer['score']) < floor:
667
+ score_answer['score'] = float(floor)
668
+ score_answer['calibrated'] = True
669
+
670
+
546
671
  def create_client(api_key='', model='', timeout=120, config=None):
547
672
  """拿到 key 就返回客户端,没有就返回 None(调用方据此走老路)。
548
673
 
@@ -553,7 +678,9 @@ def create_client(api_key='', model='', timeout=120, config=None):
553
678
  `aiModel` / `deepseekApiKey`)——一句话就能打开,不需要新厂商、新 key。
554
679
  实测 `deepseek-chat` 1.0 秒/篇且契约通过,成本量级 ¥1/天;
555
680
  · `openrouter` → OpenRouter 的 chat/completions,模型取 `openrouterJudgeModel`
556
- (默认 `typesafe/jev-router`),key 取 `openrouterApiKey`。
681
+ (默认 `typesafe/jev-router`),key 取 `openrouterApiKey`;
682
+ · `local` → **本机决策模型服务**(`judgeLocalUrl`,默认 `http://127.0.0.1:8090`,
683
+ 即 `decide.py serve`),不需要 key、不出网(D-127)。
557
684
  """
558
685
  judge_provider = ''
559
686
  openrouter_key = openrouter_model = ''
@@ -620,6 +747,13 @@ def create_client(api_key='', model='', timeout=120, config=None):
620
747
  timeout=timeout, provider='openrouter',
621
748
  extra_headers={'HTTP-Referer': OPENROUTER_REFERER,
622
749
  'X-Title': OPENROUTER_TITLE})
750
+ if judge_provider == 'local':
751
+ local_url = DEFAULT_JUDGE_LOCAL_URL
752
+ try:
753
+ local_url = getattr(_utils, 'get_judge_local_url')(config) or DEFAULT_JUDGE_LOCAL_URL
754
+ except Exception:
755
+ pass
756
+ return LocalJevClient(local_url, timeout=timeout)
623
757
  key = resolve_key(api_key, config)
624
758
  if not key:
625
759
  return None
@@ -1235,7 +1235,15 @@ def build_phrase_index(line: str = 'all', db_path=None, meta_path=None):
1235
1235
  conn.executemany('INSERT INTO hits(body, rid, line, label, date, title)'
1236
1236
  ' VALUES (?, ?, ?, ?, ?, ?)', rows)
1237
1237
  conn.commit()
1238
- conn.execute('VACUUM')
1238
+ # `VACUUM` 要写临时文件;在受限环境(沙箱 / %TEMP% 不可写)它会报
1239
+ # `unable to open database file`,而**数据其实已经写进去了** —— 那一步只是把库压实。
1240
+ # 所以它失败不能被当成"索引没建好":2026-10-22 实测 155MB 的库内容完整,
1241
+ # 却因为这一行让整个 phrase-build 的退出码变成非 0。
1242
+ try:
1243
+ conn.execute('VACUUM')
1244
+ except Exception as exc:
1245
+ print(f'[WARN] 短语索引已写入,但 VACUUM 失败({type(exc).__name__}: {exc})'
1246
+ '——只是没压实,查询不受影响', file=sys.stderr)
1239
1247
  finally:
1240
1248
  conn.close()
1241
1249
  return {'records': len(rows), 'byLine': by_line, 'textMissing': text_missing,
@@ -49,8 +49,10 @@ interface CliConfig {
49
49
  typesafeApiKey: string
50
50
  // 阿里云百炼(DashScope)API Key —— 语义检索 / RAG 的 embedding 用它
51
51
  dashscopeApiKey: string
52
- // 决策模型走哪条通路:native(默认,api.typesafe.ai)/ openrouter(本机原生被地区封锁时的第二条路)
52
+ // 决策模型走哪条通路:native(默认,api.typesafe.ai)/ openrouter / chat / local(本机决策模型)
53
53
  judgeProvider: string
54
+ // 本地决策模型服务地址 —— judgeProvider=local 时用它(默认 http://127.0.0.1:8090,见 D-127)
55
+ judgeLocalUrl: string
54
56
  // OpenRouter API Key —— judgeProvider=openrouter 时用它(D-120)
55
57
  openrouterApiKey: string
56
58
  // OpenRouter 上用的模型 id(默认 typesafe/jev-router,即 TypeSafe 发布的决策模型)
@@ -97,6 +99,8 @@ interface CliConfig {
97
99
  /** 不在日报里出现的主题,逗号分隔(如 新闻,投资);展示层开关,改它不用重抓 */
98
100
  dailyExcludeTopics: string
99
101
  dailyAiEnabled: string
102
+ /** 日报邮件的收件人(`daily --email` 与定时任务用);空则发给 SMTP 账号自己 */
103
+ dailyMailTo?: string
100
104
  }
101
105
 
102
106
  export interface ListEntry {
@@ -117,7 +121,7 @@ const CONFIG_DIR = join(homedir(), '.weflow-cli')
117
121
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
118
122
 
119
123
  export class ConfigService {
120
- private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', judgeProvider: 'native', openrouterApiKey: '', openrouterJudgeModel: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
124
+ private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', judgeProvider: 'native', openrouterApiKey: '', openrouterJudgeModel: '', judgeLocalUrl: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
121
125
 
122
126
  /** 本进程修改过、待回写的字段 (多进程并发写保护) */
123
127
  private dirty = new Set<keyof CliConfig>()
@@ -169,6 +173,7 @@ export class ConfigService {
169
173
  typesafeApiKey: data.typesafeApiKey || '',
170
174
  dashscopeApiKey: data.dashscopeApiKey || '',
171
175
  judgeProvider: data.judgeProvider || 'native',
176
+ judgeLocalUrl: data.judgeLocalUrl || '',
172
177
  openrouterApiKey: data.openrouterApiKey || '',
173
178
  openrouterJudgeModel: data.openrouterJudgeModel || '',
174
179
  snsDbPath: data.snsDbPath || '',
@@ -378,7 +383,7 @@ export class ConfigService {
378
383
  }
379
384
 
380
385
  clear(): void {
381
- this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', judgeProvider: 'native', openrouterApiKey: '', openrouterJudgeModel: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
386
+ this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', judgeProvider: 'native', openrouterApiKey: '', openrouterJudgeModel: '', judgeLocalUrl: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
382
387
  // clear 意图是全量重置: 所有字段标记为脏, 覆盖磁盘上的全部旧值
383
388
  this.dirty = new Set(Object.keys(this.config) as (keyof CliConfig)[])
384
389
  this.save()