mingpan 0.1.0__py3-none-any.whl
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.
- mingpan/__init__.py +65 -0
- mingpan/client.py +293 -0
- mingpan/errors.py +99 -0
- mingpan/py.typed +0 -0
- mingpan/questions.py +73 -0
- mingpan/types.py +222 -0
- mingpan-0.1.0.dist-info/METADATA +190 -0
- mingpan-0.1.0.dist-info/RECORD +10 -0
- mingpan-0.1.0.dist-info/WHEEL +5 -0
- mingpan-0.1.0.dist-info/top_level.txt +1 -0
mingpan/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""明判(MingPan)Python SDK —— 三原语判断接口的类型安全客户端。
|
|
2
|
+
|
|
3
|
+
from mingpan import Client, choice, score, noul
|
|
4
|
+
|
|
5
|
+
client = Client(api_key="sk-xxx") # 控制台创建
|
|
6
|
+
result = client.judge(
|
|
7
|
+
state="工单:客户反映发票抬头开错,要求尽快重开,语气着急。",
|
|
8
|
+
questions={
|
|
9
|
+
"intent": choice({"售后": "退换货", "物流": "发货配送", "计费": "账单退款"}),
|
|
10
|
+
"urgency": score(["平静", "略不满", "明显不满", "情绪激烈", "准备投诉"]),
|
|
11
|
+
"is_vip": noul("该用户是 VIP 客户"),
|
|
12
|
+
},
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
result.choice("intent").choice # '计费'
|
|
16
|
+
result.score("urgency").score # 1.5
|
|
17
|
+
result.noul("is_vip").noul # 0.58
|
|
18
|
+
|
|
19
|
+
三个原语的响应形状不同,SDK 如实分派成三个类 —— 尤其 noul **没有 confidence**,
|
|
20
|
+
所以 `NoulAnswer` 上没有这个属性。别指望它「统一」:给不存在的字段补 0.0
|
|
21
|
+
只会让路由代码静默走错分支。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from .client import DEFAULT_BASE_URL, DEFAULT_MODEL, DEFAULT_PATH, Client
|
|
25
|
+
from .errors import (
|
|
26
|
+
AuthenticationError,
|
|
27
|
+
InvalidRequestError,
|
|
28
|
+
MingpanError,
|
|
29
|
+
RateLimitError,
|
|
30
|
+
ServiceUnavailableError,
|
|
31
|
+
TransportError,
|
|
32
|
+
TypeMismatchError,
|
|
33
|
+
)
|
|
34
|
+
from .questions import choice, noul, score
|
|
35
|
+
from .types import Answer, ChoiceAnswer, JudgeResult, NoulAnswer, ScoreAnswer, Usage
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
# 客户端
|
|
41
|
+
"Client",
|
|
42
|
+
"DEFAULT_BASE_URL",
|
|
43
|
+
"DEFAULT_MODEL",
|
|
44
|
+
"DEFAULT_PATH",
|
|
45
|
+
# 原语构造
|
|
46
|
+
"choice",
|
|
47
|
+
"score",
|
|
48
|
+
"noul",
|
|
49
|
+
# 结果类型
|
|
50
|
+
"JudgeResult",
|
|
51
|
+
"ChoiceAnswer",
|
|
52
|
+
"ScoreAnswer",
|
|
53
|
+
"NoulAnswer",
|
|
54
|
+
"Answer",
|
|
55
|
+
"Usage",
|
|
56
|
+
# 异常
|
|
57
|
+
"MingpanError",
|
|
58
|
+
"TransportError",
|
|
59
|
+
"AuthenticationError",
|
|
60
|
+
"InvalidRequestError",
|
|
61
|
+
"RateLimitError",
|
|
62
|
+
"ServiceUnavailableError",
|
|
63
|
+
"TypeMismatchError",
|
|
64
|
+
"__version__",
|
|
65
|
+
]
|
mingpan/client.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""判断接口客户端。
|
|
2
|
+
|
|
3
|
+
只做三件事:拼 JSON、发 POST、把响应派发成类型化对象。**没有任何隐藏逻辑** ——
|
|
4
|
+
不重试、不缓存、不改写参数、不吞错误。每一层行为都能从代码上一眼确认。
|
|
5
|
+
|
|
6
|
+
from mingpan import Client, choice, score, noul
|
|
7
|
+
|
|
8
|
+
client = Client(api_key="sk-xxx")
|
|
9
|
+
result = client.judge(
|
|
10
|
+
state="工单:客户反映发票抬头开错,要求尽快重开,语气着急。",
|
|
11
|
+
questions={
|
|
12
|
+
"intent": choice({"售后": "退换货", "物流": "发货配送", "计费": "账单退款"}),
|
|
13
|
+
"urgency": score(["平静", "略不满", "明显不满", "情绪激烈", "准备投诉"]),
|
|
14
|
+
"is_vip": noul("该用户是 VIP 客户"),
|
|
15
|
+
},
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
print(result.choice("intent").choice) # 类型确定,不是 dict
|
|
19
|
+
print(result.score("urgency").score) # float
|
|
20
|
+
print(result.noul("is_vip").is_true(0.6)) # bool
|
|
21
|
+
|
|
22
|
+
为什么用标准库 urllib 而不是 requests / httpx:
|
|
23
|
+
SDK 的价值在于「薄」。多一个运行时依赖就多一份版本冲突,而它要做的只是
|
|
24
|
+
发一个 POST。代价是少了连接池复用 —— 但判断是**同步请求-响应**,
|
|
25
|
+
单次 700ms 量级,连接池收益远小于依赖成本;真要高并发请用 `judge_batch`。
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import json
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
35
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence, Union
|
|
36
|
+
|
|
37
|
+
from .errors import MingpanError, TransportError, error_for
|
|
38
|
+
from .types import JudgeResult, Usage, parse_answers
|
|
39
|
+
|
|
40
|
+
__all__ = ["Client", "DEFAULT_BASE_URL", "DEFAULT_PATH", "DEFAULT_MODEL"]
|
|
41
|
+
|
|
42
|
+
# 判断接口的正式入口。走 BFF 的统一入口,用户无需关心底层是哪台网关。
|
|
43
|
+
DEFAULT_BASE_URL = "https://mingpan.oneapis.cn"
|
|
44
|
+
DEFAULT_PATH = "/v1/judge"
|
|
45
|
+
|
|
46
|
+
# 滚动别名:上游会把它解析成具体版本号(如 jev-1.13.0)并回填在响应的 model 字段里。
|
|
47
|
+
DEFAULT_MODEL = "jev-latest"
|
|
48
|
+
|
|
49
|
+
DEFAULT_TIMEOUT = 90.0 # 输入上限 64k token,网关侧还有一层转发,给足余量
|
|
50
|
+
|
|
51
|
+
_VERSION = "0.1.0"
|
|
52
|
+
_USER_AGENT = f"mingpan-python/{_VERSION}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _with_sk(key: str) -> str:
|
|
56
|
+
"""补齐 `sk-` 前缀(幂等)。
|
|
57
|
+
|
|
58
|
+
上游存的 Key 是纯随机串,但**调用必须带 `sk-`**。缺前缀时服务端返回 401
|
|
59
|
+
「令牌无效」,而 Key 本身看起来完全正常 —— 这是最难自己发现的一类错,
|
|
60
|
+
所以在这里兜住,而不是等用户去比对字符。
|
|
61
|
+
"""
|
|
62
|
+
if not key or not str(key).strip():
|
|
63
|
+
raise ValueError("api_key 不能为空(控制台「API Key」页可创建)")
|
|
64
|
+
key = str(key).strip()
|
|
65
|
+
return key if key.startswith("sk-") else "sk-" + key
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_message(body: Any, status: int) -> str:
|
|
69
|
+
"""从各种错误信封里取一句可读的 message。
|
|
70
|
+
|
|
71
|
+
服务端在不同层次会返回不同形状,逐个探测、只在明确命中时取值:
|
|
72
|
+
{"error": {"message": ...}} —— 对外接口(OpenAI 风格)
|
|
73
|
+
{"success": false, "message": ...}
|
|
74
|
+
{"detail": ...} —— FastAPI 校验错误
|
|
75
|
+
"""
|
|
76
|
+
if isinstance(body, dict):
|
|
77
|
+
err = body.get("error")
|
|
78
|
+
if isinstance(err, dict) and err.get("message"):
|
|
79
|
+
return str(err["message"])
|
|
80
|
+
if isinstance(err, str) and err:
|
|
81
|
+
return err
|
|
82
|
+
if body.get("message"):
|
|
83
|
+
return str(body["message"])
|
|
84
|
+
if body.get("detail"):
|
|
85
|
+
detail = body["detail"]
|
|
86
|
+
if isinstance(detail, str):
|
|
87
|
+
return detail
|
|
88
|
+
if isinstance(detail, list) and detail:
|
|
89
|
+
parts = []
|
|
90
|
+
for d in detail[:4]:
|
|
91
|
+
if isinstance(d, dict):
|
|
92
|
+
loc = ".".join(str(x) for x in (d.get("loc") or []))
|
|
93
|
+
parts.append(f"{loc}: {d.get('msg', '')}".strip(": "))
|
|
94
|
+
if parts:
|
|
95
|
+
return "; ".join(parts)
|
|
96
|
+
if isinstance(body, str) and body.strip():
|
|
97
|
+
return body.strip()[:300]
|
|
98
|
+
return f"HTTP {status}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _to_result(body: Mapping[str, Any]) -> JudgeResult:
|
|
102
|
+
usage = body.get("usage") or {}
|
|
103
|
+
return JudgeResult(
|
|
104
|
+
model=str(body.get("model") or ""),
|
|
105
|
+
answers=parse_answers(body.get("answers")),
|
|
106
|
+
usage=Usage(
|
|
107
|
+
input_tokens=int(usage.get("input_tokens") or 0),
|
|
108
|
+
output_tokens=int(usage.get("output_tokens") or 0),
|
|
109
|
+
),
|
|
110
|
+
raw=body,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Client:
|
|
115
|
+
"""判断接口客户端。
|
|
116
|
+
|
|
117
|
+
:param api_key: 控制台创建的 API Key(`sk-` 开头)
|
|
118
|
+
:param base_url: 服务地址,默认正式环境;自建网关时改成自己的域名
|
|
119
|
+
:param path: 判断接口路径。只有直连插件网关时才需要改,例如
|
|
120
|
+
`Client(key, base_url="https://your-gateway", path="/typesafe/v1/systemone")`
|
|
121
|
+
:param model: 默认模型,可在单次调用里用 `model=` 覆盖
|
|
122
|
+
:param timeout: 单次请求超时(秒)
|
|
123
|
+
:param use_env_proxy: 是否沿用环境变量里的 HTTP(S)_PROXY。
|
|
124
|
+
**默认 True**(企业网络下通常必须走代理);置 False 可绕过本机代理 ——
|
|
125
|
+
代理配错时的症状是「同一个 Key 在 curl 里能通、在脚本里连不上」。
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
api_key: str,
|
|
131
|
+
*,
|
|
132
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
133
|
+
path: str = DEFAULT_PATH,
|
|
134
|
+
model: Optional[str] = None,
|
|
135
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
136
|
+
use_env_proxy: bool = True,
|
|
137
|
+
user_agent: Optional[str] = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
self.api_key = _with_sk(api_key)
|
|
140
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
141
|
+
self.path = path if path.startswith("/") else "/" + path
|
|
142
|
+
self.model = model or DEFAULT_MODEL
|
|
143
|
+
self.timeout = float(timeout)
|
|
144
|
+
self.user_agent = user_agent or _USER_AGENT
|
|
145
|
+
# 显式构造 opener:urllib 默认会读 http_proxy/https_proxy,
|
|
146
|
+
# 这里把它变成一个可见的选择,而不是一个难以定位的隐式行为。
|
|
147
|
+
if use_env_proxy:
|
|
148
|
+
self._opener = urllib.request.build_opener()
|
|
149
|
+
else:
|
|
150
|
+
self._opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
151
|
+
|
|
152
|
+
# ---------- 公开 API ----------
|
|
153
|
+
def judge(
|
|
154
|
+
self,
|
|
155
|
+
*,
|
|
156
|
+
questions: Mapping[str, Any],
|
|
157
|
+
state: Any = None,
|
|
158
|
+
model: Optional[str] = None,
|
|
159
|
+
timeout: Optional[float] = None,
|
|
160
|
+
) -> JudgeResult:
|
|
161
|
+
"""发起一次判断。
|
|
162
|
+
|
|
163
|
+
:param questions: 问题表,键为自定义 ID,值用 `choice()` / `score()` / `noul()` 构造
|
|
164
|
+
:param state: 被判断的材料。文本、JSON 对象、数组均可;模型只读它、不续写它
|
|
165
|
+
:param model: 覆盖默认模型
|
|
166
|
+
:return: `JudgeResult`
|
|
167
|
+
:raises AuthenticationError: Key 无效或无权限
|
|
168
|
+
:raises InvalidRequestError: 参数不符合契约(message 会指出正确写法)
|
|
169
|
+
:raises RateLimitError: 限流或额度用尽
|
|
170
|
+
:raises ServiceUnavailableError: 服务端/上游暂时不可用(不计费,可退避重试)
|
|
171
|
+
:raises TransportError: 网络层失败
|
|
172
|
+
"""
|
|
173
|
+
if not isinstance(questions, Mapping) or not questions:
|
|
174
|
+
raise ValueError("questions 必须是非空映射,例如 {'intent': choice([...])}")
|
|
175
|
+
|
|
176
|
+
payload: Dict[str, Any] = {
|
|
177
|
+
"model": model or self.model,
|
|
178
|
+
"questions": dict(questions),
|
|
179
|
+
}
|
|
180
|
+
if state is not None:
|
|
181
|
+
payload["state"] = state
|
|
182
|
+
|
|
183
|
+
return _to_result(self._post(payload, timeout))
|
|
184
|
+
|
|
185
|
+
async def judge_async(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
questions: Mapping[str, Any],
|
|
189
|
+
state: Any = None,
|
|
190
|
+
model: Optional[str] = None,
|
|
191
|
+
timeout: Optional[float] = None,
|
|
192
|
+
) -> JudgeResult:
|
|
193
|
+
"""异步版本。
|
|
194
|
+
|
|
195
|
+
实现是 `asyncio.to_thread` 包装同步调用 —— 目的是**不阻塞事件循环**,
|
|
196
|
+
而不是并发(单次判断本身就是同步请求-响应)。要并发多个 state 用 `judge_batch`。
|
|
197
|
+
"""
|
|
198
|
+
return await asyncio.to_thread(
|
|
199
|
+
self.judge, questions=questions, state=state, model=model, timeout=timeout
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def judge_batch(
|
|
203
|
+
self,
|
|
204
|
+
*,
|
|
205
|
+
states: Sequence[Any],
|
|
206
|
+
questions: Mapping[str, Any],
|
|
207
|
+
model: Optional[str] = None,
|
|
208
|
+
timeout: Optional[float] = None,
|
|
209
|
+
max_workers: int = 8,
|
|
210
|
+
return_exceptions: bool = False,
|
|
211
|
+
) -> List[Union[JudgeResult, MingpanError]]:
|
|
212
|
+
"""批量:多个 state 共享同一套 questions。
|
|
213
|
+
|
|
214
|
+
返回值与 `states` **按下标一一对应**(不是完成顺序)。
|
|
215
|
+
|
|
216
|
+
:param max_workers: 并发上限。默认 8 —— 再高只会先撞上游限流,
|
|
217
|
+
换来一批 429,不如小并发稳跑。
|
|
218
|
+
:param return_exceptions: True 时把单条失败作为异常对象放进结果列表,
|
|
219
|
+
便于「一批里坏了一条不影响其余」的离线批处理。默认 False(任一失败即抛)。
|
|
220
|
+
"""
|
|
221
|
+
items = list(states)
|
|
222
|
+
if not items:
|
|
223
|
+
return []
|
|
224
|
+
|
|
225
|
+
results: List[Any] = [None] * len(items)
|
|
226
|
+
|
|
227
|
+
def _one(st: Any):
|
|
228
|
+
return self.judge(questions=questions, state=st, model=model, timeout=timeout)
|
|
229
|
+
|
|
230
|
+
with ThreadPoolExecutor(max_workers=max(1, min(max_workers, len(items)))) as ex:
|
|
231
|
+
futures = [ex.submit(_one, st) for st in items]
|
|
232
|
+
for i, fut in enumerate(futures):
|
|
233
|
+
try:
|
|
234
|
+
results[i] = fut.result()
|
|
235
|
+
except MingpanError as e:
|
|
236
|
+
if not return_exceptions:
|
|
237
|
+
# 别让剩下的请求继续跑:多半是同一个原因(限流/Key 失效),
|
|
238
|
+
# 继续只会把限流窗口打得更深。
|
|
239
|
+
for f in futures:
|
|
240
|
+
f.cancel()
|
|
241
|
+
raise
|
|
242
|
+
results[i] = e
|
|
243
|
+
return results
|
|
244
|
+
|
|
245
|
+
# ---------- 内部 ----------
|
|
246
|
+
def _post(self, payload: Mapping[str, Any], timeout: Optional[float]) -> Mapping[str, Any]:
|
|
247
|
+
url = self.base_url + self.path
|
|
248
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
249
|
+
req = urllib.request.Request(
|
|
250
|
+
url,
|
|
251
|
+
data=data,
|
|
252
|
+
method="POST",
|
|
253
|
+
headers={
|
|
254
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
255
|
+
"Content-Type": "application/json",
|
|
256
|
+
"Accept": "application/json",
|
|
257
|
+
"User-Agent": self.user_agent,
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
with self._opener.open(req, timeout=timeout or self.timeout) as resp:
|
|
263
|
+
raw = resp.read()
|
|
264
|
+
status = resp.status
|
|
265
|
+
except urllib.error.HTTPError as e:
|
|
266
|
+
raw = e.read()
|
|
267
|
+
status = e.code
|
|
268
|
+
except urllib.error.URLError as e:
|
|
269
|
+
raise TransportError(f"无法连接判断服务({url}):{e.reason}") from e
|
|
270
|
+
except (TimeoutError, OSError) as e:
|
|
271
|
+
raise TransportError(f"请求判断服务失败({url}):{e}") from e
|
|
272
|
+
|
|
273
|
+
body: Any = None
|
|
274
|
+
if raw:
|
|
275
|
+
try:
|
|
276
|
+
body = json.loads(raw.decode("utf-8"))
|
|
277
|
+
except (ValueError, UnicodeDecodeError):
|
|
278
|
+
# 拿到 HTML 而不是 JSON,几乎总是「路径写错」或「服务没起」,
|
|
279
|
+
# 把开头一段带出来,比一句「解析失败」有用得多。
|
|
280
|
+
snippet = raw[:200].decode("utf-8", "replace").strip()
|
|
281
|
+
raise MingpanError(
|
|
282
|
+
f"服务返回了非 JSON 内容(HTTP {status}),请检查地址 {url} 是否正确:"
|
|
283
|
+
f"{snippet}",
|
|
284
|
+
status,
|
|
285
|
+
raw,
|
|
286
|
+
) from None
|
|
287
|
+
|
|
288
|
+
if status != 200:
|
|
289
|
+
raise error_for(status, _extract_message(body, status), body)
|
|
290
|
+
|
|
291
|
+
if not isinstance(body, Mapping):
|
|
292
|
+
raise MingpanError("服务返回的响应体不是 JSON 对象", status, body)
|
|
293
|
+
return body
|
mingpan/errors.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""异常类型。
|
|
2
|
+
|
|
3
|
+
设计原则:**每一类失败对应一个可分别捕获的类型**,且异常自带可读的中文 message。
|
|
4
|
+
|
|
5
|
+
为什么不用一个笼统的 `MingpanError` 打天下:
|
|
6
|
+
判断接口最常见的两种失败是「Key 不对」(要换 Key)和「参数写错」(要改代码),
|
|
7
|
+
它们的处理方式完全不同 —— 前者重试一万次也没用,后者重试一次就成功。
|
|
8
|
+
调用方需要能用 `except` 把它们分开,而不是去 parse 错误字符串。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MingpanError(Exception):
|
|
17
|
+
"""所有 SDK 异常的基类。
|
|
18
|
+
|
|
19
|
+
:param message: 可直接展示给终端用户的中文说明
|
|
20
|
+
:param status: HTTP 状态码(网络层失败时为 0)
|
|
21
|
+
:param body: 服务端返回的原始响应体,便于排查
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
message: str,
|
|
27
|
+
status: int = 0,
|
|
28
|
+
body: Optional[Any] = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.message = message
|
|
32
|
+
self.status = status
|
|
33
|
+
self.body = body
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str: # pragma: no cover - 仅影响打印
|
|
36
|
+
return f"[{self.status}] {self.message}" if self.status else self.message
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TransportError(MingpanError):
|
|
40
|
+
"""网络层失败:连不上、DNS 失败、超时。
|
|
41
|
+
|
|
42
|
+
与 HTTP 层错误分开:这个通常意味着「本机网络/代理/域名」有问题,
|
|
43
|
+
而不是「请求内容」有问题。
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AuthenticationError(MingpanError):
|
|
48
|
+
"""401 / 403:令牌缺失、无效、被吊销,或无权调用判断服务。
|
|
49
|
+
|
|
50
|
+
⚠️ 重试没有意义。检查 Key 是否带 `sk-` 前缀、是否已被轮换。
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class InvalidRequestError(MingpanError):
|
|
55
|
+
"""400 / 422:请求不符合契约。
|
|
56
|
+
|
|
57
|
+
好消息是这个错误的 message **通常直接指出了正确写法**,例如
|
|
58
|
+
"Choice criteria must contain between 1 and 255 options"
|
|
59
|
+
照着改即可,不必翻文档。
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RateLimitError(MingpanError):
|
|
64
|
+
"""429:触发限流或额度用尽。退避重试,或改用批量接口。"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ServiceUnavailableError(MingpanError):
|
|
68
|
+
"""5xx:服务端或上游暂时不可用。可退避重试,且这类失败不计费。"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TypeMismatchError(MingpanError):
|
|
72
|
+
"""取值器与原语类型不符。
|
|
73
|
+
|
|
74
|
+
这是 SDK 在**客户端**主动抛的 —— 例如对一道 `noul` 题调用 `result.choice("q1")`。
|
|
75
|
+
本地立刻报错,比拿回一个 `None` 然后在几十行之外 KeyError 好得多。
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_STATUS_MAP = {
|
|
80
|
+
400: InvalidRequestError,
|
|
81
|
+
401: AuthenticationError,
|
|
82
|
+
403: AuthenticationError,
|
|
83
|
+
422: InvalidRequestError,
|
|
84
|
+
429: RateLimitError,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# 上游把「路由没注册 / 未配置凭据」也归在 503,但它不是「稍后重试就好」,
|
|
88
|
+
# 重试永远不会成功。单独给一句可操作的话。
|
|
89
|
+
_ROUTE_HINTS = ("路由未注册", "未配置", "未注册")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def error_for(status: int, message: str, body: Optional[Any] = None) -> MingpanError:
|
|
93
|
+
"""按状态码选异常类型。"""
|
|
94
|
+
if status >= 500:
|
|
95
|
+
return ServiceUnavailableError(message, status, body)
|
|
96
|
+
cls = _STATUS_MAP.get(status)
|
|
97
|
+
if cls is not None:
|
|
98
|
+
return cls(message, status, body)
|
|
99
|
+
return MingpanError(message, status, body)
|
mingpan/py.typed
ADDED
|
File without changes
|
mingpan/questions.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""三原语的请求侧构造。
|
|
2
|
+
|
|
3
|
+
原语参数名容易记错,而且**记错时上游报的 400 恰好会告诉你正确写法**:
|
|
4
|
+
|
|
5
|
+
{"type": "choice", "options": [...]} → 400 "Choice criteria must contain between 1 and 255 options"
|
|
6
|
+
{"type": "score", "levels": 5} → 400 "Score criteria must contain between 2 and 10 levels"
|
|
7
|
+
{"type": "noul", "hint": "..."} → 400 "Noul question must have criteria or instructions"
|
|
8
|
+
|
|
9
|
+
也就是说三个原语都用 `criteria`(choice/score),noul 用 `instructions`。
|
|
10
|
+
用下面这三个函数就不必记这些细节。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Dict, Mapping, Sequence, Union
|
|
16
|
+
|
|
17
|
+
__all__ = ["choice", "score", "noul"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def choice(criteria: Union[Mapping[str, str], Sequence[str]]) -> Dict[str, Any]:
|
|
21
|
+
"""choice 原语:从候选中选出唯一答案。
|
|
22
|
+
|
|
23
|
+
`criteria` 有两种写法:
|
|
24
|
+
|
|
25
|
+
· **映射(推荐)** —— 键是选项名,值是判定说明,说明会进模型:
|
|
26
|
+
choice({"售后": "退换货、维修", "物流": "发货与配送", "计费": "账单与退款"})
|
|
27
|
+
|
|
28
|
+
· **序列(简写)** —— 每个值同时当标签与说明:
|
|
29
|
+
choice(["退款", "发票", "物流"])
|
|
30
|
+
|
|
31
|
+
项数上限 255。序列写法上传的是 `{标签: 标签}`,不会产生空说明 ——
|
|
32
|
+
空字符串说明在部分上游版本会被判为非法。
|
|
33
|
+
"""
|
|
34
|
+
if isinstance(criteria, Mapping):
|
|
35
|
+
if not criteria:
|
|
36
|
+
raise ValueError("choice 至少需要 1 个候选项")
|
|
37
|
+
return {"type": "choice", "criteria": {str(k): str(v) for k, v in criteria.items()}}
|
|
38
|
+
items = [str(x) for x in criteria]
|
|
39
|
+
if not items:
|
|
40
|
+
raise ValueError("choice 至少需要 1 个候选项")
|
|
41
|
+
return {"type": "choice", "criteria": {x: x for x in items}}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def score(criteria: Sequence[str]) -> Dict[str, Any]:
|
|
45
|
+
"""score 原语:把材料放到一个有序量级上打分。
|
|
46
|
+
|
|
47
|
+
`criteria` 是**从低到高**排列的级别名数组,2–10 级:
|
|
48
|
+
|
|
49
|
+
score(["平静", "略不满", "明显不满", "情绪激烈", "准备投诉"])
|
|
50
|
+
|
|
51
|
+
顺序即语义 —— 返回值 `score` 是级别序号的概率加权(0-based)。
|
|
52
|
+
写成从高到低会让所有阈值判断反过来,且不会报错。
|
|
53
|
+
"""
|
|
54
|
+
items = [str(x) for x in criteria]
|
|
55
|
+
if len(items) < 2:
|
|
56
|
+
raise ValueError("score 至少需要 2 个级别")
|
|
57
|
+
if len(items) > 10:
|
|
58
|
+
raise ValueError("score 最多 10 个级别")
|
|
59
|
+
return {"type": "score", "criteria": items}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def noul(instructions: str = "") -> Dict[str, Any]:
|
|
63
|
+
"""noul 原语:判断某个命题是否成立。
|
|
64
|
+
|
|
65
|
+
返回 0–1 的连续值,**不返回 confidence**:
|
|
66
|
+
|
|
67
|
+
noul("该用户是 VIP 客户")
|
|
68
|
+
noul("用户已经准备好按方案直接落地,而非仅了解概念")
|
|
69
|
+
|
|
70
|
+
写成「可验证的完整命题」比写成关键词效果好得多 ——
|
|
71
|
+
"VIP" 这种两字提示会让模型无从判断依据是什么。
|
|
72
|
+
"""
|
|
73
|
+
return {"type": "noul", "instructions": str(instructions)}
|
mingpan/types.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""响应类型。
|
|
2
|
+
|
|
3
|
+
三原语的响应形状**不同**,这是接口设计的核心事实:
|
|
4
|
+
|
|
5
|
+
noul → {"type": "noul", "noul": 0.58}
|
|
6
|
+
↑ 只有连续值,**没有 confidence**
|
|
7
|
+
|
|
8
|
+
choice → {"type": "choice", "choice": "计费", "confidence": 0.91,
|
|
9
|
+
"probabilities": {"售后": 0.09, "计费": 0.91}}
|
|
10
|
+
|
|
11
|
+
score → {"type": "score", "score": 1.5, "confidence": 0.32,
|
|
12
|
+
"probabilities": {"0": 0.2, "1": 0.26, ...},
|
|
13
|
+
"legend": {"0": "平静", "1": "略不满", ...}}
|
|
14
|
+
|
|
15
|
+
SDK 把它们派发成三个不同的类,并且**不给 `NoulAnswer` 提供 `confidence` 属性** ——
|
|
16
|
+
如果这里为了「统一」塞一个 `confidence=0.0` 进去,路由代码就会在 noul 上
|
|
17
|
+
静默走错分支,而这种 bug 只会在线上以「误判率异常」的形式暴露。
|
|
18
|
+
宁可让 `answer.confidence` 在 noul 上抛 AttributeError。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any, Dict, Mapping, Optional, Union
|
|
25
|
+
|
|
26
|
+
from .errors import TypeMismatchError
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ChoiceAnswer",
|
|
30
|
+
"ScoreAnswer",
|
|
31
|
+
"NoulAnswer",
|
|
32
|
+
"Answer",
|
|
33
|
+
"Usage",
|
|
34
|
+
"JudgeResult",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ChoiceAnswer:
|
|
40
|
+
"""choice 结果:唯一命中项 + 完整概率分布 + 置信度。"""
|
|
41
|
+
|
|
42
|
+
choice: str
|
|
43
|
+
confidence: float
|
|
44
|
+
probabilities: Mapping[str, float]
|
|
45
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
|
|
46
|
+
|
|
47
|
+
type = "choice"
|
|
48
|
+
|
|
49
|
+
def top(self, n: int = 2) -> list:
|
|
50
|
+
"""按概率降序取前 n 项,形如 `[("计费", 0.91), ("售后", 0.09)]`。
|
|
51
|
+
|
|
52
|
+
「第二名」是判断质量的重要信号:当 top1 与 top2 贴得很近时,
|
|
53
|
+
即使 top1 概率看起来不低,也值得转人工。
|
|
54
|
+
"""
|
|
55
|
+
items = sorted(self.probabilities.items(), key=lambda kv: kv[1], reverse=True)
|
|
56
|
+
return items[:n]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class ScoreAnswer:
|
|
61
|
+
"""score 结果:概率加权的浮点得分(0-based)。"""
|
|
62
|
+
|
|
63
|
+
score: float
|
|
64
|
+
confidence: float
|
|
65
|
+
probabilities: Mapping[str, float]
|
|
66
|
+
legend: Mapping[str, str] = field(default_factory=dict)
|
|
67
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
|
|
68
|
+
|
|
69
|
+
type = "score"
|
|
70
|
+
|
|
71
|
+
def label(self) -> str:
|
|
72
|
+
"""得分四舍五入到最近的级别名。
|
|
73
|
+
|
|
74
|
+
⚠️ 注意这是**为了可读性**的换算,不是接口契约的一部分:
|
|
75
|
+
`score` 本身是连续值,业务判断应直接用 `score` 比阈值,
|
|
76
|
+
不要用 label 做分支(会把边界附近的细微差别抹掉)。
|
|
77
|
+
"""
|
|
78
|
+
if not self.legend:
|
|
79
|
+
return ""
|
|
80
|
+
idx = int(round(self.score))
|
|
81
|
+
return self.legend.get(str(idx), "")
|
|
82
|
+
|
|
83
|
+
def top(self, n: int = 2) -> list:
|
|
84
|
+
items = sorted(self.probabilities.items(), key=lambda kv: kv[1], reverse=True)
|
|
85
|
+
return items[:n]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class NoulAnswer:
|
|
90
|
+
"""noul 结果:命题成立程度,0–1 连续值。
|
|
91
|
+
|
|
92
|
+
**本类型刻意没有 `confidence` 属性** —— 上游对这个原语不返回它。
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
noul: float
|
|
96
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
|
|
97
|
+
|
|
98
|
+
type = "noul"
|
|
99
|
+
|
|
100
|
+
def is_true(self, threshold: float = 0.5) -> bool:
|
|
101
|
+
"""是否判定为成立。阈值由业务定 —— SDK 给 0.5 只是为了有个可用的默认值。"""
|
|
102
|
+
return self.noul >= threshold
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
Answer = Union[ChoiceAnswer, ScoreAnswer, NoulAnswer]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Usage:
|
|
110
|
+
"""用量。**只有 input_tokens 计费**,output_tokens 会返回但不在计费式里。"""
|
|
111
|
+
|
|
112
|
+
input_tokens: int = 0
|
|
113
|
+
output_tokens: int = 0
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def billable_tokens(self) -> int:
|
|
117
|
+
return self.input_tokens
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class JudgeResult:
|
|
122
|
+
"""一次判断的结果。
|
|
123
|
+
|
|
124
|
+
`answers` 与请求的 `questions` **同构**:键一一对应,形状由该题的原语类型决定。
|
|
125
|
+
|
|
126
|
+
取值优先用类型化取值器,而不是字典下标:
|
|
127
|
+
|
|
128
|
+
result.choice("intent").choice # str,类型确定
|
|
129
|
+
result.score("urgency").score # float
|
|
130
|
+
result.noul("is_vip").is_true(0.6) # bool
|
|
131
|
+
|
|
132
|
+
对类型不符的题调用错误的取值器会抛 `TypeMismatchError`,
|
|
133
|
+
而不是返回 None 让你在别处踩坑。
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
model: str
|
|
137
|
+
answers: Dict[str, Answer]
|
|
138
|
+
usage: Usage
|
|
139
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
|
|
140
|
+
|
|
141
|
+
def __getitem__(self, qid: str) -> Answer:
|
|
142
|
+
"""按问题 ID 取答案。"""
|
|
143
|
+
try:
|
|
144
|
+
return self.answers[qid]
|
|
145
|
+
except KeyError:
|
|
146
|
+
known = "、".join(self.answers) or "(空)"
|
|
147
|
+
raise KeyError(f"响应里没有问题 {qid!r};本次返回的问题有:{known}") from None
|
|
148
|
+
|
|
149
|
+
def _typed(self, qid: str, cls, hint: str):
|
|
150
|
+
ans = self[qid]
|
|
151
|
+
if not isinstance(ans, cls):
|
|
152
|
+
raise TypeMismatchError(
|
|
153
|
+
f"问题 {qid!r} 的实际类型是 {getattr(ans, 'type', '?')},"
|
|
154
|
+
f"不能用 {hint} 取值"
|
|
155
|
+
)
|
|
156
|
+
return ans
|
|
157
|
+
|
|
158
|
+
def choice(self, qid: str) -> ChoiceAnswer:
|
|
159
|
+
"""取 choice 题的结果。类型不符则抛 `TypeMismatchError`。"""
|
|
160
|
+
return self._typed(qid, ChoiceAnswer, "result.choice()")
|
|
161
|
+
|
|
162
|
+
def score(self, qid: str) -> ScoreAnswer:
|
|
163
|
+
"""取 score 题的结果。类型不符则抛 `TypeMismatchError`。"""
|
|
164
|
+
return self._typed(qid, ScoreAnswer, "result.score()")
|
|
165
|
+
|
|
166
|
+
def noul(self, qid: str) -> NoulAnswer:
|
|
167
|
+
"""取 noul 题的结果。类型不符则抛 `TypeMismatchError`。"""
|
|
168
|
+
return self._typed(qid, NoulAnswer, "result.noul()")
|
|
169
|
+
|
|
170
|
+
def __repr__(self) -> str: # pragma: no cover - 仅影响打印
|
|
171
|
+
parts = []
|
|
172
|
+
for qid, a in self.answers.items():
|
|
173
|
+
if isinstance(a, ChoiceAnswer):
|
|
174
|
+
parts.append(f"{qid}=choice({a.choice!r}, conf={a.confidence})")
|
|
175
|
+
elif isinstance(a, ScoreAnswer):
|
|
176
|
+
parts.append(f"{qid}=score({a.score}, conf={a.confidence})")
|
|
177
|
+
else:
|
|
178
|
+
parts.append(f"{qid}=noul({a.noul})")
|
|
179
|
+
return f"<JudgeResult model={self.model} {'; '.join(parts)}>"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def parse_answers(raw_answers: Optional[Mapping[str, Any]]) -> Dict[str, Answer]:
|
|
183
|
+
"""把上游响应里的 `answers` 派发成类型化对象。
|
|
184
|
+
|
|
185
|
+
未知的 `type` 一律退化成 dict 包一层的通用对象 —— 上游加了新原语时,
|
|
186
|
+
旧版 SDK 不应该直接崩(向前兼容优先于完备性)。
|
|
187
|
+
"""
|
|
188
|
+
out: Dict[str, Answer] = {}
|
|
189
|
+
for qid, a in (raw_answers or {}).items():
|
|
190
|
+
if not isinstance(a, Mapping):
|
|
191
|
+
continue
|
|
192
|
+
kind = a.get("type")
|
|
193
|
+
if kind == "choice":
|
|
194
|
+
out[qid] = ChoiceAnswer(
|
|
195
|
+
choice=str(a.get("choice", "")),
|
|
196
|
+
confidence=float(a.get("confidence", 0.0) or 0.0),
|
|
197
|
+
probabilities=dict(a.get("probabilities") or {}),
|
|
198
|
+
raw=a,
|
|
199
|
+
)
|
|
200
|
+
elif kind == "score":
|
|
201
|
+
out[qid] = ScoreAnswer(
|
|
202
|
+
score=float(a.get("score", 0.0) or 0.0),
|
|
203
|
+
confidence=float(a.get("confidence", 0.0) or 0.0),
|
|
204
|
+
probabilities=dict(a.get("probabilities") or {}),
|
|
205
|
+
legend={str(k): str(v) for k, v in (a.get("legend") or {}).items()},
|
|
206
|
+
raw=a,
|
|
207
|
+
)
|
|
208
|
+
elif kind == "noul":
|
|
209
|
+
out[qid] = NoulAnswer(noul=float(a.get("noul", 0.0) or 0.0), raw=a)
|
|
210
|
+
else:
|
|
211
|
+
# 未知原语:原样保留,不猜语义
|
|
212
|
+
out[qid] = _UnknownAnswer(kind=str(kind or "?"), raw=a) # type: ignore[assignment]
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True)
|
|
217
|
+
class _UnknownAnswer:
|
|
218
|
+
"""上游新增了本版 SDK 不认识的原语时的兜底容器。"""
|
|
219
|
+
|
|
220
|
+
kind: str
|
|
221
|
+
raw: Mapping[str, Any] = field(default_factory=dict)
|
|
222
|
+
type = "unknown"
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mingpan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 明判(MingPan)Python SDK —— 三原语判断接口的类型安全客户端
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://mingpan.oneapis.cn
|
|
7
|
+
Project-URL: Documentation, https://mingpan.oneapis.cn/docs
|
|
8
|
+
Keywords: mingpan,jev,judge,llm,type-safe,routing
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# 明判 Python SDK
|
|
23
|
+
|
|
24
|
+
明判(MingPan)三原语判断接口的 Python 客户端。**运行时零依赖**(只用标准库 `urllib`),
|
|
25
|
+
`pip install` 后即可调用,不引入任何版本冲突。
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install mingpan
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**零运行时依赖** —— 装完不会引入任何第三方包(只用标准库 `urllib`)。
|
|
32
|
+
需要跟仓库里最新未发版的改动时,从源码目录装:`pip install ./sdk/python`。
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 快速开始
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from mingpan import Client, choice, score, noul
|
|
40
|
+
|
|
41
|
+
client = Client(api_key="sk-xxx") # 控制台「API Key」页创建
|
|
42
|
+
|
|
43
|
+
result = client.judge(
|
|
44
|
+
state="工单:客户反映发票抬头开错,要求尽快重开,语气着急。",
|
|
45
|
+
questions={
|
|
46
|
+
"intent": choice({"售后": "退换货、维修", "物流": "发货与配送", "计费": "账单与退款"}),
|
|
47
|
+
"urgency": score(["平静", "略不满", "明显不满", "情绪激烈", "准备投诉"]),
|
|
48
|
+
"is_vip": noul("该用户是 VIP 客户"),
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
result.choice("intent").choice # '计费' —— str,不是 dict 里捞出来的
|
|
53
|
+
result.score("urgency").score # 1.5 —— float,0-based 加权
|
|
54
|
+
result.noul("is_vip").is_true(0.6) # False
|
|
55
|
+
result.usage.input_tokens # 391 —— 计费依据
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 三个原语
|
|
59
|
+
|
|
60
|
+
| 原语 | 构造 | 返回 | 说明 |
|
|
61
|
+
|---|---|---|---|
|
|
62
|
+
| `choice` | `choice({"标签": "说明"})` | `choice`(str) / `confidence` / `probabilities` | 从候选中选唯一答案。**`criteria` 是对象**,1–255 项 |
|
|
63
|
+
| `score` | `score(["低", "中", "高"])` | `score`(float) / `confidence` / `probabilities` / `legend` | 放到有序量级上打分。**`criteria` 是数组**,2–10 级,从低到高 |
|
|
64
|
+
| `noul` | `noul("命题正文")` | `noul`(float 0–1) | 判断命题是否成立。**没有 `confidence`** |
|
|
65
|
+
|
|
66
|
+
几个必须知道的事实:
|
|
67
|
+
|
|
68
|
+
- **`score` 是 0-based 浮点**。`score=1.5` 表示重心落在第 2 级(序号 1)附近 ——
|
|
69
|
+
不是「1.5 分 / 满分 5 分」。别拿它当整数用。
|
|
70
|
+
- **`noul` 没有 `confidence`**。SDK 如实不提供该属性:读它会 `AttributeError`,
|
|
71
|
+
而不是悄悄给个 `0.0` 让你的路由代码走错分支。
|
|
72
|
+
- **`criteria` 的说明会进模型**。`{"计费": "账单与退款"}` 的判定质量明显高于
|
|
73
|
+
`["计费"]`。只给标签时模型只能靠字面猜;说明才是准确率的来源。
|
|
74
|
+
- **只有 `input_tokens` 计费**。`output_tokens` 会返回(实测 58–67,不是 0),
|
|
75
|
+
但不在计费式里。
|
|
76
|
+
|
|
77
|
+
## 类型安全
|
|
78
|
+
|
|
79
|
+
`answers` 与 `questions` 同构,但 SDK 会按原语类型**派发成不同的类**,
|
|
80
|
+
取值器对类型不符直接报错:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
result.noul("is_vip") # NoulAnswer
|
|
84
|
+
result.choice("is_vip") # ✗ TypeMismatchError: 实际类型是 noul,不能用 result.choice() 取值
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
这比「字典下标 + `.get()`」安全得多:类型错了在**调用点**就炸,
|
|
88
|
+
而不是在几十行之外的 `NoneType has no attribute` 处炸。
|
|
89
|
+
|
|
90
|
+
## 错误处理
|
|
91
|
+
|
|
92
|
+
每类失败对应一个可分别捕获的类型:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from mingpan import (
|
|
96
|
+
AuthenticationError, InvalidRequestError,
|
|
97
|
+
RateLimitError, ServiceUnavailableError, TransportError,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
result = client.judge(state=state, questions=questions)
|
|
102
|
+
except AuthenticationError: # 401/403:Key 无效、过期、无权。重试无意义
|
|
103
|
+
rotate_key()
|
|
104
|
+
except InvalidRequestError as e: # 400/422:参数不符。message 通常直接给出正确写法
|
|
105
|
+
print(e.message) # → "Choice criteria must contain between 1 and 255 options"
|
|
106
|
+
except RateLimitError: # 429:退避重试,或改用 judge_batch 降低并发
|
|
107
|
+
backoff()
|
|
108
|
+
except ServiceUnavailableError: # 5xx:不计费,可退避重试
|
|
109
|
+
backoff()
|
|
110
|
+
except TransportError: # 网络层:连不上/DNS/超时,检查网络或代理
|
|
111
|
+
pass
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`InvalidRequestError` 的 message 是最有价值的错误信息 ——
|
|
115
|
+
上游的校验错误会直接说出**正确字段名与取值范围**,照着改即可,不必翻文档。
|
|
116
|
+
|
|
117
|
+
## 并发与批量
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
# 单次异步(不阻塞事件循环)
|
|
121
|
+
result = await client.judge_async(state=state, questions=questions)
|
|
122
|
+
|
|
123
|
+
# 多个 state 共享同一套 questions,并发发送,结果按下标对应
|
|
124
|
+
results = client.judge_batch(
|
|
125
|
+
states=[t1, t2, t3],
|
|
126
|
+
questions=questions,
|
|
127
|
+
max_workers=8, # 别调太高:先撞上游限流,换来一批 429
|
|
128
|
+
return_exceptions=True, # 单条失败作为异常对象返回,不中断整批
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## 把 confidence 接进你的代码
|
|
133
|
+
|
|
134
|
+
三原语的输出可以拼成一条可测试、可审计的路由链路:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
r = client.judge(state=ticket, questions={
|
|
138
|
+
"intent": choice({"退款": "要退款", "咨询": "问问题", "投诉": "表达不满"}),
|
|
139
|
+
"urgency": score(["平静", "略不满", "明显不满", "情绪激烈", "准备投诉"]),
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
# 置信度足够 + 情绪不高 → 自动执行;否则转人工
|
|
143
|
+
if r.choice("intent").confidence >= 0.85 and r.score("urgency").score < 2.0:
|
|
144
|
+
auto_execute(r.choice("intent").choice)
|
|
145
|
+
elif r.choice("intent").confidence >= 0.5:
|
|
146
|
+
queue_for_human(r)
|
|
147
|
+
else:
|
|
148
|
+
escalate(r) # 低置信:转大模型或人工
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
阈值按**业务误判成本**定,不是按模型表现定 —— 退款错判的代价和分类错标的代价不一样。
|
|
152
|
+
路由决策本身是普通代码,所以它能被单测、能被 review、能进审计日志。
|
|
153
|
+
|
|
154
|
+
## 自建网关
|
|
155
|
+
|
|
156
|
+
默认走明判的正式入口(`https://mingpan.oneapis.cn/v1/judge`)。
|
|
157
|
+
如果判断能力挂在自己的网关上,直接指向那里即可:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
client = Client(
|
|
161
|
+
api_key="sk-xxx",
|
|
162
|
+
base_url="https://your-gateway.example.com",
|
|
163
|
+
path="/typesafe/v1/systemone", # 插件自有路由,路径与 BFF 不同
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
⚠️ 不同网关实例的插件 key 与路由路径可能不同(`/typesafe/v1/systemone` vs
|
|
168
|
+
`/jev/v1/systemone`),照抄另一台的路径会得到 404 或「SPA 兜底返回 HTML」。
|
|
169
|
+
客户端遇到 HTML 响应会明确提示「请检查地址是否正确」,而不是报一句
|
|
170
|
+
含糊的解析失败。
|
|
171
|
+
|
|
172
|
+
## 代理说明
|
|
173
|
+
|
|
174
|
+
SDK 默认沿用环境变量里的 `HTTP_PROXY` / `HTTPS_PROXY`(企业网络下通常必须)。
|
|
175
|
+
想绕过本机代理:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
client = Client(api_key="sk-xxx", use_env_proxy=False)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
代理配错时的典型症状是「同一个 Key 在 `curl` 里能通、在脚本里连不上」——
|
|
182
|
+
遇到这种情况先怀疑代理,再怀疑 Key。
|
|
183
|
+
|
|
184
|
+
## 开发
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
cd sdk/python
|
|
188
|
+
python tests/test_sdk.py # 离线:59 项,不联网
|
|
189
|
+
MINGPAN_TEST_KEY=sk-xxx python tests/test_sdk.py --live # 真实调用(会产生费用)
|
|
190
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
mingpan/__init__.py,sha256=-GEk5qidGKMvGu21BmDP5-JlibMCjtl4cm3sM1HHiOM,1943
|
|
2
|
+
mingpan/client.py,sha256=HSlNTcHBFrT2VDg_OaPqF620eWmcMBOwtPE7UC5ahvM,12198
|
|
3
|
+
mingpan/errors.py,sha256=dEoDH1bfqGMYO-v-bp0GqH2YHN6bc4zeQaWLLQHWMNU,3335
|
|
4
|
+
mingpan/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
mingpan/questions.py,sha256=EK1qTMu9lPCSV8R7pSdYsuzpR_EZYIO5UEQXMIs_sr0,3051
|
|
6
|
+
mingpan/types.py,sha256=6_1xurdFiqhnnAojEvD6QMhEZxR2T-oET8LqXAaAnrE,7852
|
|
7
|
+
mingpan-0.1.0.dist-info/METADATA,sha256=oHk07PDdQRLqF_N9kwJUxR4GUPuVeMnOe8PjwPUhPDY,7750
|
|
8
|
+
mingpan-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
mingpan-0.1.0.dist-info/top_level.txt,sha256=0aYBEjP5vLhVGyIZTfagBxYgFji82EjFigjkH1equQ8,8
|
|
10
|
+
mingpan-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mingpan
|