weflow-cli 1.6.4 → 1.8.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/ARCHITECTURE.md +3 -2
- package/CHANGELOG.md +1966 -0
- package/OPERATIONS.md +808 -0
- package/README.en.md +93 -7
- package/README.md +31 -6
- package/bin/weflow-cli.ts +1245 -26
- package/dist/bin/weflow-cli.js +1349 -72
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.js +39 -13
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/appMsgFormat.d.ts +37 -0
- package/dist/src/core/appMsgFormat.d.ts.map +1 -0
- package/dist/src/core/appMsgFormat.js +106 -0
- package/dist/src/core/appMsgFormat.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +43 -0
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +31 -0
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts +0 -12
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +5 -64
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/core/wcdbCore.d.ts +1 -1
- package/dist/src/core/wcdbCore.d.ts.map +1 -1
- package/dist/src/core/wcdbCore.js +5 -1
- package/dist/src/core/wcdbCore.js.map +1 -1
- package/dist/src/panel/client.d.ts +25 -0
- package/dist/src/panel/client.d.ts.map +1 -0
- package/dist/src/panel/client.js +34 -0
- package/dist/src/panel/client.js.map +1 -0
- package/dist/src/panel/endpoint.d.ts +45 -0
- package/dist/src/panel/endpoint.d.ts.map +1 -0
- package/dist/src/panel/endpoint.js +132 -0
- package/dist/src/panel/endpoint.js.map +1 -0
- package/dist/src/panel/launch.d.ts +50 -0
- package/dist/src/panel/launch.d.ts.map +1 -0
- package/dist/src/panel/launch.js +74 -0
- package/dist/src/panel/launch.js.map +1 -0
- package/dist/src/panel/server.d.ts +23 -0
- package/dist/src/panel/server.d.ts.map +1 -0
- package/dist/src/panel/server.js +413 -0
- package/dist/src/panel/server.js.map +1 -0
- package/dist/src/panel/userId.d.ts +37 -0
- package/dist/src/panel/userId.d.ts.map +1 -0
- package/dist/src/panel/userId.js +56 -0
- package/dist/src/panel/userId.js.map +1 -0
- package/dist/src/services/assistantDaemon.d.ts +20 -3
- package/dist/src/services/assistantDaemon.d.ts.map +1 -1
- package/dist/src/services/assistantDaemon.js +46 -9
- package/dist/src/services/assistantDaemon.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +141 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -0
- package/dist/src/services/assistantEval.js +497 -0
- package/dist/src/services/assistantEval.js.map +1 -0
- package/dist/src/services/assistantMemory.d.ts +106 -4
- package/dist/src/services/assistantMemory.d.ts.map +1 -1
- package/dist/src/services/assistantMemory.js +396 -61
- package/dist/src/services/assistantMemory.js.map +1 -1
- package/dist/src/services/assistantPrivacy.d.ts +15 -2
- package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
- package/dist/src/services/assistantPrivacy.js +18 -4
- package/dist/src/services/assistantPrivacy.js.map +1 -1
- package/dist/src/services/assistantRouter.d.ts +41 -0
- package/dist/src/services/assistantRouter.d.ts.map +1 -0
- package/dist/src/services/assistantRouter.js +151 -0
- package/dist/src/services/assistantRouter.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +209 -3
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +590 -75
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +205 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +1089 -58
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +47 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -0
- package/dist/src/services/assistantTrace.js +150 -0
- package/dist/src/services/assistantTrace.js.map +1 -0
- package/dist/src/services/chatService.d.ts +13 -0
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +23 -0
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +15 -8
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +35 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +61 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/pythonBridge.d.ts +41 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -0
- package/dist/src/services/pythonBridge.js +119 -0
- package/dist/src/services/pythonBridge.js.map +1 -0
- package/dist/src/services/syncService.d.ts +127 -0
- package/dist/src/services/syncService.d.ts.map +1 -0
- package/dist/src/services/syncService.js +263 -0
- package/dist/src/services/syncService.js.map +1 -0
- package/dist/src/services/syncState.d.ts +113 -0
- package/dist/src/services/syncState.d.ts.map +1 -0
- package/dist/src/services/syncState.js +214 -0
- package/dist/src/services/syncState.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +25 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +69 -7
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/utils/atomicWrite.d.ts +4 -0
- package/dist/src/utils/atomicWrite.d.ts.map +1 -0
- package/dist/src/utils/atomicWrite.js +60 -0
- package/dist/src/utils/atomicWrite.js.map +1 -0
- package/dist/src/utils/dateRange.d.ts +15 -0
- package/dist/src/utils/dateRange.d.ts.map +1 -1
- package/dist/src/utils/dateRange.js +47 -0
- package/dist/src/utils/dateRange.js.map +1 -1
- package/dist/src/utils/text.d.ts +18 -0
- package/dist/src/utils/text.d.ts.map +1 -0
- package/dist/src/utils/text.js +20 -0
- package/dist/src/utils/text.js.map +1 -0
- package/docs/AI_INTERFACE.md +2 -0
- package/docs/DECISIONS.md +1484 -20
- package/docs/EXTENDING.md +187 -0
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
- package/docs/MCP.md +23 -4
- package/docs/PROJECT_STATE.md +299 -96
- package/docs/SETUP.md +22 -0
- package/docs/SYNC_CONTRACT.md +180 -0
- package/mcp-server/index.ts +681 -657
- package/package.json +84 -83
- package/resources/panel/ball-position.cjs +153 -0
- package/resources/panel/index.html +46 -0
- package/resources/panel/main.cjs +447 -0
- package/resources/panel/mascot-focus.png +0 -0
- package/resources/panel/mascot-happy.png +0 -0
- package/resources/panel/mascot-sorry.png +0 -0
- package/resources/panel/mascot-tired.png +0 -0
- package/resources/panel/mascot.png +0 -0
- package/resources/panel/package.json +7 -0
- package/resources/panel/panel.css +407 -0
- package/resources/panel/preload.cjs +72 -0
- package/resources/panel/quick-menu.cjs +197 -0
- package/resources/panel/renderer.js +441 -0
- package/resources/panel/tray-menu.cjs +40 -0
- package/resources/panel/tray.png +0 -0
- package/scripts/_utils.py +403 -54
- package/scripts/annual_report.py +4 -2
- package/scripts/article_notes.py +423 -0
- package/scripts/backfill_articles.py +438 -0
- package/scripts/biz_daily.py +590 -105
- package/scripts/chat_notes.py +470 -0
- package/scripts/classify_daily.py +10 -18
- package/scripts/compile_wiki.py +1059 -46
- package/scripts/create_reading_notes.py +199 -14
- package/scripts/decide.py +253 -0
- package/scripts/draft_reply.py +671 -0
- package/scripts/export_chat_html.py +212 -46
- package/scripts/extract_todos.py +31 -5
- package/scripts/fav_notes.py +318 -0
- package/scripts/fix_topics.py +2 -1
- package/scripts/generate_ai_report.py +214 -9
- package/scripts/generate_html.py +18 -3
- package/scripts/generate_review.py +2 -2
- package/scripts/jev_client.py +294 -0
- package/scripts/jev_probe.py +440 -0
- package/scripts/nt_common.py +82 -0
- package/scripts/nt_decrypt.py +1755 -1352
- package/scripts/pipeline.py +27 -2
- package/scripts/promote_ideas.py +19 -4
- package/scripts/quality_eval.py +707 -0
- package/scripts/rag_chat.py +4 -4
- package/scripts/read_image.py +187 -0
- package/scripts/reply_debt.py +488 -0
- package/scripts/route_cards.py +453 -0
- package/scripts/semantic_search.py +208 -49
- package/scripts/user_notes.py +315 -0
- package/scripts/vault_rag.py +13 -7
- package/scripts/vault_search.py +21 -4
- package/scripts/wiki_lint.py +397 -0
- package/src/core/appMsgFormat.ts +117 -0
- package/src/core/ntCore.ts +67 -0
- package/src/core/sqlcipherCore.ts +5 -61
- package/src/core/wcdbCore.ts +6 -2
- package/src/panel/client.ts +49 -0
- package/src/panel/endpoint.ts +144 -0
- package/src/panel/launch.ts +101 -0
- package/src/panel/server.ts +424 -0
- package/src/panel/userId.ts +72 -0
- package/src/services/assistantDaemon.ts +57 -9
- package/src/services/assistantEval.ts +611 -0
- package/src/services/assistantMemory.ts +413 -57
- package/src/services/assistantPrivacy.ts +18 -4
- package/src/services/assistantRouter.ts +191 -0
- package/src/services/assistantService.ts +671 -74
- package/src/services/assistantTools.ts +1118 -57
- package/src/services/assistantTrace.ts +175 -0
- package/src/services/chatService.ts +30 -0
- package/src/services/configService.ts +403 -385
- package/src/services/messageQuery.ts +97 -0
- package/src/services/pythonBridge.ts +139 -0
- package/src/services/syncService.ts +363 -0
- package/src/services/syncState.ts +275 -0
- package/src/services/wechatMessageService.ts +73 -7
- package/src/utils/atomicWrite.ts +56 -0
- package/src/utils/dateRange.ts +46 -0
- package/src/utils/text.ts +20 -0
|
@@ -9,7 +9,7 @@ import sys, os, json, re, time, subprocess
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
11
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
12
|
-
from _utils import create_engine, parse_frontmatter
|
|
12
|
+
from _utils import create_engine, parse_frontmatter, DEFAULT_TOPIC
|
|
13
13
|
|
|
14
14
|
DAILY_PROMPT = """你是知识管理助手,基于今日文章内容生成学习日报。
|
|
15
15
|
|
|
@@ -67,7 +67,7 @@ def scan_today_articles(target_date: str, source='output/biz-daily') -> list[dic
|
|
|
67
67
|
articles.append({
|
|
68
68
|
'title': fm.get('title', md_file.stem),
|
|
69
69
|
'source': fm.get('source', ''),
|
|
70
|
-
'topic': fm.get('topic'
|
|
70
|
+
'topic': fm.get('topic') or DEFAULT_TOPIC,
|
|
71
71
|
'tags': fm.get('tags', []),
|
|
72
72
|
'summary': summary,
|
|
73
73
|
})
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Jev(TypeSafe System One)决策客户端 —— **只做判断,不生成文本**。
|
|
3
|
+
|
|
4
|
+
日报现在把"主题 + 相关度"塞在同一个散文 prompt 里让 LLM 顺手答,再用两条正则抠回来。
|
|
5
|
+
实测(60 篇真实中文文章)结果是:`relevance` 全库 2199/2201 都是默认值「中」,
|
|
6
|
+
`tags` 全库都是 `[主题名]`,`topic` 靠关键词兜底猜。这个模块把**判断**换成类型化的
|
|
7
|
+
决策调用:输入 state,返回带概率的 choice/score/noul,没有自由文本需要解析。
|
|
8
|
+
|
|
9
|
+
契约不是转述的,来自服务自己的 OpenAPI(`GET https://api.typesafe.ai/openapi.json`)
|
|
10
|
+
加上实测:
|
|
11
|
+
|
|
12
|
+
POST https://api.typesafe.ai/v1/systemone Authorization: Bearer <key>
|
|
13
|
+
request {model, state, questions}
|
|
14
|
+
response {model, answers, usage}
|
|
15
|
+
|
|
16
|
+
choice: criteria 是**对象** {选项名: 何时选它},答案 {type, choice, confidence, probabilities}
|
|
17
|
+
score : criteria 是**有序数组**,位置即分值(零索引),答案多一个 score + legend
|
|
18
|
+
noul : 不需要 criteria,答案 {type, noul} —— **没有 confidence**
|
|
19
|
+
|
|
20
|
+
失败取向:**本模块一律抛 `JevError`**(fail-loud),由调用方决定退到哪条老路
|
|
21
|
+
(fail-soft)。分类被问不出来,不该让整天的日报崩掉;但客户端自己不许把失败
|
|
22
|
+
伪装成一个看起来正常的答案。
|
|
23
|
+
"""
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import time
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
|
|
30
|
+
ENDPOINT = 'https://api.typesafe.ai/v1/systemone'
|
|
31
|
+
MODELS_ENDPOINT = 'https://api.typesafe.ai/v1/models'
|
|
32
|
+
DEFAULT_MODEL = 'jev-latest'
|
|
33
|
+
# 瞬时故障:官方参考实现(AI SDK 的 evaluate)默认对它们重试 2 次,这里同样。
|
|
34
|
+
# 实测遇到过 529 `system_overloaded`——服务 2026-09-15 才发布,被打满是常态。
|
|
35
|
+
RETRY_STATUS = frozenset({429, 500, 502, 503, 504, 529})
|
|
36
|
+
INPUT_USD_PER_TOKEN = 0.042 / 1_000_000 # 输出 token 目前不计费
|
|
37
|
+
|
|
38
|
+
# 分类法(列表 + 每类的判据)只有一份,在 `_utils` 里。这里直接引用它,
|
|
39
|
+
# 所以提示词那条路和决策模型这条路**判的是同一套定义**——回退时不会换标准。
|
|
40
|
+
from _utils import TOPIC_CRITERIA, RELEVANCE_NAMES # noqa: E402 (scripts/ 在 sys.path 上)
|
|
41
|
+
|
|
42
|
+
# **顺序即语义**:score.criteria 零索引,第一个元素是 0 分。
|
|
43
|
+
# 两件事必须分开:判据文本是**给模型看**的(照抄 TOPIC_PROMPT 里对高/中/低的定义,
|
|
44
|
+
# 少了这段说明模型就没有判档依据),而落盘的值必须是**裸的三个字**——下游
|
|
45
|
+
# generate_ai_report.py:123、enrich_backlinks.py:75,87、generate_html.py:817-820
|
|
46
|
+
# 都在做字面量比较。名字表在 `_utils`(词表与 TOPICS 同处),下面这份判据文本
|
|
47
|
+
# 按下标与它一一对应(有测试钉住),所以两者都不能重排。
|
|
48
|
+
RELEVANCE_LEVELS = [
|
|
49
|
+
'低:信息性阅读(纯新闻/娱乐/文学)',
|
|
50
|
+
'中:有启发性(思路/趋势/跨领域技术)',
|
|
51
|
+
'高:可直接用于科研(新工具/新方法/数据源/代码库)',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class JevError(RuntimeError):
|
|
56
|
+
"""调用或契约出问题。消息面向人,**不含 API key**。"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _check_choice(key, question, answer):
|
|
60
|
+
"""对 `choice` 答案做硬校验:不合法就抛 `JevError`,**不放行**。
|
|
61
|
+
|
|
62
|
+
四条断言(概率的键集 == criteria 的键集、取值都在 [0,1]、和 ≈ 1、`choice`
|
|
63
|
+
就是 argmax)来自 browser-use × TypeSafe 官方示范仓库的 `validate_choice`;
|
|
64
|
+
我按它挑的这四条原样实现,并把**实测**过的真实响应作为基准(`jev-1.13.0`:
|
|
65
|
+
`{"看电影":0.0,"散步":1.0,"睡觉":0.0}` + `choice="散步"`,四条全过)。
|
|
66
|
+
|
|
67
|
+
为什么值得在客户端做:契约漂移的失效方式不是报错,而是**选错了但看起来正常**。
|
|
68
|
+
`argmax` 那条尤其关键——我们此前踩过一个同型的坑:把 `noul`(概率浮点)当布尔
|
|
69
|
+
读,每个问题都读成"否",而输出还把责任推给了模型。
|
|
70
|
+
"""
|
|
71
|
+
if question.get('type') != 'choice':
|
|
72
|
+
# noul / score 没有"criteria ↔ probabilities"这层对应关系,不该被这套检查误伤。
|
|
73
|
+
# `decide` 已经按类型过滤,这里再挡一次是为了让这个函数单独调用也安全。
|
|
74
|
+
return
|
|
75
|
+
criteria = question.get('criteria') or {}
|
|
76
|
+
probs = answer.get('probabilities')
|
|
77
|
+
if not isinstance(probs, dict):
|
|
78
|
+
raise JevError('%s:choice 答案里没有 probabilities:%s'
|
|
79
|
+
% (key, json.dumps(answer, ensure_ascii=False)[:200]))
|
|
80
|
+
if criteria:
|
|
81
|
+
missing = sorted(set(criteria) - set(probs))
|
|
82
|
+
extra = sorted(set(probs) - set(criteria))
|
|
83
|
+
if missing or extra:
|
|
84
|
+
raise JevError('%s:probabilities 的键与 criteria 不一致(缺 %s,多 %s)'
|
|
85
|
+
% (key, missing, extra))
|
|
86
|
+
out_of_range = {k: v for k, v in probs.items()
|
|
87
|
+
if not isinstance(v, (int, float)) or isinstance(v, bool)
|
|
88
|
+
or not 0.0 <= float(v) <= 1.0}
|
|
89
|
+
if out_of_range:
|
|
90
|
+
raise JevError('%s:概率不在 [0,1]:%s' % (key, out_of_range))
|
|
91
|
+
total = sum(float(v) for v in probs.values())
|
|
92
|
+
if probs and abs(total - 1.0) > 0.02:
|
|
93
|
+
raise JevError('%s:概率和是 %.3f,不是 1(差得太多说明契约变了)' % (key, total))
|
|
94
|
+
picked = answer.get('choice')
|
|
95
|
+
if criteria and picked not in criteria:
|
|
96
|
+
raise JevError('%s:choice=%r 不在 criteria 里' % (key, picked))
|
|
97
|
+
if probs:
|
|
98
|
+
top = max(probs, key=lambda k: probs[k])
|
|
99
|
+
if picked != top:
|
|
100
|
+
raise JevError('%s:choice=%r 不是概率的 argmax(%r 才是)——'
|
|
101
|
+
'这种情况最危险,它看起来是个正常答案'
|
|
102
|
+
% (key, picked, top))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_key(explicit='', config=None):
|
|
106
|
+
"""key 的取值顺序:显式传入 → `TYPESAFE_API_KEY` → 配置里(自动解密)。
|
|
107
|
+
|
|
108
|
+
不落盘、不打印。配置那条走 `_utils.decrypt_lock`,所以 `config set` 写的
|
|
109
|
+
机器绑定密文能直接用。
|
|
110
|
+
"""
|
|
111
|
+
key = (explicit or os.environ.get('TYPESAFE_API_KEY') or '').strip()
|
|
112
|
+
if key:
|
|
113
|
+
return key
|
|
114
|
+
try:
|
|
115
|
+
import _utils
|
|
116
|
+
return (getattr(_utils, 'get_typesafe_key')(config) or '').strip()
|
|
117
|
+
except Exception:
|
|
118
|
+
# 配置读不到、密文解不开(比如 config.json 是从别的机器拷来的)——
|
|
119
|
+
# 回空串让调用方走老路,而不是把日报打挂。
|
|
120
|
+
return ''
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def score_to_relevance(score):
|
|
124
|
+
"""把 `score` 期望值映射回「低/中/高」。
|
|
125
|
+
|
|
126
|
+
criteria 是零索引的有序量表,所以 `score` 落在 [0, len-1] 区间里。
|
|
127
|
+
用 `int(score + 0.5)` 取最近档而不是 `round()`:Python 的 round 是银行家舍入,
|
|
128
|
+
`round(0.5)` 得 0、`round(1.5)` 得 2,边界会跳档。
|
|
129
|
+
|
|
130
|
+
阈值是**暂定**的(没有金标准可校准),所以原始分要一起落盘(`relevanceScore`),
|
|
131
|
+
将来重新校准不用重跑整天的日报。
|
|
132
|
+
"""
|
|
133
|
+
if score is None:
|
|
134
|
+
raise JevError('score 答案缺少 score 字段')
|
|
135
|
+
index = int(float(score) + 0.5)
|
|
136
|
+
index = max(0, min(len(RELEVANCE_NAMES) - 1, index))
|
|
137
|
+
return RELEVANCE_NAMES[index]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_questions(topics, criteria=None):
|
|
141
|
+
"""与 `TOPIC_PROMPT` 等价的类型化版本:一个 choice + 一个 score + 一个 noul。
|
|
142
|
+
|
|
143
|
+
`worth_including` 是**回答一个以前没人问过的问题**。日报原来拿 `relevance != '高'`
|
|
144
|
+
当收录门,而那是在用"对读者的实用价值"回答"该不该进今天的日报"——两件事不一样,
|
|
145
|
+
阈值怎么调都调不准,因为问题本身错了。多问一个问题的边际成本近零(实测 2 个问题
|
|
146
|
+
0.84s,12 个问题 0.91s,`state` 才是开销大头),所以这个判断基本是白送的。
|
|
147
|
+
|
|
148
|
+
这里刻意不放"探测型"问题(比如"是不是科研论文"):问完没人读,就是
|
|
149
|
+
`COVER_STATE` 那种"算了就扔",不如不加。
|
|
150
|
+
"""
|
|
151
|
+
table = criteria or TOPIC_CRITERIA
|
|
152
|
+
# 判据必须覆盖传入的每个主题:choice 的 criteria 就是选项集合本身,
|
|
153
|
+
# 少一个选项,模型就永远选不到它。
|
|
154
|
+
choice_criteria = {topic: table.get(topic, topic) for topic in topics}
|
|
155
|
+
return {
|
|
156
|
+
'topic': {'type': 'choice',
|
|
157
|
+
'instructions': '这篇文章属于哪个主题?',
|
|
158
|
+
'criteria': choice_criteria},
|
|
159
|
+
'relevance': {'type': 'score',
|
|
160
|
+
'instructions': '对「环境科学研究生,做计算机与环境交叉」的实用价值有多高?',
|
|
161
|
+
'criteria': list(RELEVANCE_LEVELS)},
|
|
162
|
+
'worth_including': {
|
|
163
|
+
'type': 'noul',
|
|
164
|
+
'instructions': '这篇文章含有读者今天就能用上的具体内容'
|
|
165
|
+
'(新工具/新方法/数据源/代码库/可复现的结论),'
|
|
166
|
+
'而不是仅有信息性的新闻、观点或生活随笔?'},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class JevClient:
|
|
171
|
+
"""一次请求问多个问题,共享同一份 state。"""
|
|
172
|
+
|
|
173
|
+
def __init__(self, api_key, model=DEFAULT_MODEL, timeout=120):
|
|
174
|
+
if not api_key:
|
|
175
|
+
raise JevError('缺少 TypeSafe API key')
|
|
176
|
+
self.api_key = api_key
|
|
177
|
+
self.model = model
|
|
178
|
+
self.timeout = timeout
|
|
179
|
+
# 最近一次响应里**实际服务的模型**(如 `jev-1.13.0`),不是请求的别名。
|
|
180
|
+
self.last_model = None
|
|
181
|
+
|
|
182
|
+
def _post(self, url, payload=None, attempts=3, backoff=0.8):
|
|
183
|
+
"""带重试的请求。
|
|
184
|
+
|
|
185
|
+
退避故意很短(最坏约 2.4s):调用方是**逐篇**分类日报的,服务整体过载时
|
|
186
|
+
长退避只会把一整天的日报拖成几十分钟,而那时调用方本来就该退回老路。
|
|
187
|
+
"""
|
|
188
|
+
last_error = None
|
|
189
|
+
for attempt in range(attempts):
|
|
190
|
+
try:
|
|
191
|
+
return self._post_once(url, payload)
|
|
192
|
+
except JevError as error:
|
|
193
|
+
if attempt == attempts - 1 or not getattr(error, 'retryable', False):
|
|
194
|
+
raise
|
|
195
|
+
last_error = error
|
|
196
|
+
time.sleep(backoff * (2 ** attempt))
|
|
197
|
+
raise last_error # pragma: no cover - 循环内已覆盖所有出口
|
|
198
|
+
|
|
199
|
+
def _post_once(self, url, payload=None):
|
|
200
|
+
data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
|
201
|
+
request = urllib.request.Request(
|
|
202
|
+
url, data=data, method='POST' if data else 'GET',
|
|
203
|
+
headers={'Content-Type': 'application/json',
|
|
204
|
+
'Authorization': f'Bearer {self.api_key}'})
|
|
205
|
+
try:
|
|
206
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
207
|
+
return json.loads(response.read())
|
|
208
|
+
except urllib.error.HTTPError as error:
|
|
209
|
+
detail = ''
|
|
210
|
+
try:
|
|
211
|
+
# 截断:别把整个响应体(可能很长)带进日志。
|
|
212
|
+
detail = error.read().decode('utf-8', 'replace')[:400]
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
if error.code in (401, 403):
|
|
216
|
+
raise JevError(f'鉴权被拒(HTTP {error.code})。key 可能不对或已过期:{detail}')
|
|
217
|
+
if error.code == 422:
|
|
218
|
+
# 422 说明我理解的契约和服务端不一致,是最值得看的一种失败。
|
|
219
|
+
raise JevError(f'请求被拒(HTTP 422),契约与预期不符:{detail}')
|
|
220
|
+
failure = JevError(f'HTTP {error.code}: {detail}')
|
|
221
|
+
failure.retryable = error.code in RETRY_STATUS
|
|
222
|
+
raise failure
|
|
223
|
+
except urllib.error.URLError as error:
|
|
224
|
+
failure = JevError(f'连不上 {url}: {error.reason}')
|
|
225
|
+
failure.retryable = True # 连接层失败通常是瞬时的
|
|
226
|
+
raise failure
|
|
227
|
+
|
|
228
|
+
def models(self):
|
|
229
|
+
"""可用别名。用它可以确认 `--model` 没写错。"""
|
|
230
|
+
return self._post(MODELS_ENDPOINT).get('models', [])
|
|
231
|
+
|
|
232
|
+
def decide(self, state, questions):
|
|
233
|
+
"""返回 (answers, usage)。state 可以是字符串、JSON 对象或数组。
|
|
234
|
+
|
|
235
|
+
`usage` 里除了 token 数还带 `model`——那是**实际服务的模型**(实测
|
|
236
|
+
`jev-1.13.0`),不是你请求的别名(`jev-latest`)。落盘它,是为了让
|
|
237
|
+
"线上跑的和我测过的不是同一个东西"这件事**可见**,而不是等到结果不对
|
|
238
|
+
才去猜。
|
|
239
|
+
|
|
240
|
+
**每个 choice 答案都过一遍 `_check_choice`**:键集、取值域、概率和、
|
|
241
|
+
argmax 全对不上就抛 `JevError`。契约漂移应当当场炸——漂移的失效方式是
|
|
242
|
+
"看起来正常但选错了",那正是最难发现的。
|
|
243
|
+
"""
|
|
244
|
+
body = self._post(ENDPOINT, {'model': self.model,
|
|
245
|
+
'state': state, 'questions': questions})
|
|
246
|
+
answers = body.get('answers')
|
|
247
|
+
if not isinstance(answers, dict):
|
|
248
|
+
raise JevError(f'响应里没有 answers:{json.dumps(body, ensure_ascii=False)[:300]}')
|
|
249
|
+
for key, question in (questions or {}).items():
|
|
250
|
+
if isinstance(question, dict) and question.get('type') == 'choice':
|
|
251
|
+
_check_choice(key, question, answers.get(key) or {})
|
|
252
|
+
usage = dict(body.get('usage') or {})
|
|
253
|
+
if body.get('model'):
|
|
254
|
+
usage['model'] = body['model']
|
|
255
|
+
self.last_model = body['model']
|
|
256
|
+
return answers, usage
|
|
257
|
+
|
|
258
|
+
def decide_article(self, title, body, topics, criteria=None, max_chars=4000):
|
|
259
|
+
"""给一篇文章定主题与相关度。**问不出来就抛 `JevError`。**
|
|
260
|
+
|
|
261
|
+
state 只放生产环境真会给模型的东西(标题 + 正文),不放日期、来源、
|
|
262
|
+
也不放 frontmatter 里的 topic/relevance —— 那些是答案的味道。
|
|
263
|
+
"""
|
|
264
|
+
state = f'标题:{title}\n\n正文:\n{(body or "")[:max_chars]}'
|
|
265
|
+
answers, usage = self.decide(state, build_questions(topics, criteria))
|
|
266
|
+
|
|
267
|
+
topic_answer = answers.get('topic') or {}
|
|
268
|
+
relevance_answer = answers.get('relevance') or {}
|
|
269
|
+
picked = topic_answer.get('choice')
|
|
270
|
+
if picked not in topics:
|
|
271
|
+
# criteria 就是 topics,正常不该发生。真发生了说明契约变了,
|
|
272
|
+
# 与其把一个陌生主题写进 frontmatter 污染下游,不如报错让调用方回退。
|
|
273
|
+
raise JevError(f'返回的主题不在候选里:{picked!r}')
|
|
274
|
+
|
|
275
|
+
score = relevance_answer.get('score')
|
|
276
|
+
return {
|
|
277
|
+
'topic': picked,
|
|
278
|
+
'topicConfidence': topic_answer.get('confidence'),
|
|
279
|
+
'topicProbabilities': topic_answer.get('probabilities'),
|
|
280
|
+
'relevance': score_to_relevance(score),
|
|
281
|
+
'relevanceScore': score,
|
|
282
|
+
# 「该不该收录」的原始概率。落盘而不是在这儿切成布尔:切点是暂定的,
|
|
283
|
+
# 留着原始值,改阈值不用重跑。
|
|
284
|
+
'includeScore': (answers.get('worth_including') or {}).get('noul'),
|
|
285
|
+
'usage': usage,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def create_client(api_key='', model=DEFAULT_MODEL, timeout=120, config=None):
|
|
290
|
+
"""拿到 key 就返回客户端,没有就返回 None(调用方据此走老路)。"""
|
|
291
|
+
key = resolve_key(api_key, config)
|
|
292
|
+
if not key:
|
|
293
|
+
return None
|
|
294
|
+
return JevClient(key, model=model, timeout=timeout)
|