phocinae-cli 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.
- phocinae_cli/__init__.py +10 -0
- phocinae_cli/__main__.py +6 -0
- phocinae_cli/classify_route.py +129 -0
- phocinae_cli/cli.py +121 -0
- phocinae_cli/client.py +243 -0
- phocinae_cli/decide.py +105 -0
- phocinae_cli/gate.py +154 -0
- phocinae_cli/l0.py +173 -0
- phocinae_cli/output.py +125 -0
- phocinae_cli/py.typed +0 -0
- phocinae_cli/questions.py +138 -0
- phocinae_cli/router_const.py +9 -0
- phocinae_cli/score_gate_cli.py +134 -0
- phocinae_cli-0.1.0.dist-info/METADATA +276 -0
- phocinae_cli-0.1.0.dist-info/RECORD +19 -0
- phocinae_cli-0.1.0.dist-info/WHEEL +5 -0
- phocinae_cli-0.1.0.dist-info/entry_points.txt +2 -0
- phocinae_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- phocinae_cli-0.1.0.dist-info/top_level.txt +1 -0
phocinae_cli/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""phocinae CLI —— 斑海豹 /v1/systemone 命令行客户端。
|
|
2
|
+
|
|
3
|
+
一行命令本地决策:decide / classify / route / score / gate / ping。
|
|
4
|
+
协议:TypeSafe System One API(POST /v1/systemone)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
DEFAULT_SERVER = "http://127.0.0.1:8155"
|
|
10
|
+
DEFAULT_MODEL = "Phocinae-Largha-150M-v1"
|
phocinae_cli/__main__.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""classify_route.py —— classify 与 route 子命令。
|
|
2
|
+
|
|
3
|
+
classify:state + labels → choice 题,输出选中标签 + 置信度 + 各标签概率。
|
|
4
|
+
route :state + 工具列表(≤10,name 或 name:desc)→ choice 题 + 4 排列平均
|
|
5
|
+
(canonical 口径,与 phocinae-router 一致),输出工具 + 概率 + escalate。
|
|
6
|
+
tau=0.6 为 E1 升级门冻结口径:置信度 < tau 建议升级外部模型。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from . import output
|
|
14
|
+
from .client import MAX_OPTIONS, PhocinaeError, unwrap_choice
|
|
15
|
+
from .questions import QuestionSpecError
|
|
16
|
+
from .router_const import MAX_TOOLS, TAU_DEFAULT
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _read_state_stdin(state):
|
|
20
|
+
if state == "-" or (not state and not sys.stdin.isatty()):
|
|
21
|
+
data = sys.stdin.read().strip()
|
|
22
|
+
if not data:
|
|
23
|
+
raise QuestionSpecError("stdin 为空,无法取得 state")
|
|
24
|
+
return data
|
|
25
|
+
if state:
|
|
26
|
+
return state
|
|
27
|
+
raise QuestionSpecError(
|
|
28
|
+
"需要 state:--state '文本',或 echo '文本' | phocinae ...")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _print_choice_result(ctx, body, qid, options, what):
|
|
32
|
+
answers = body.get("answers", {})
|
|
33
|
+
confs = body.get("answer_confidence", {})
|
|
34
|
+
raw = unwrap_choice(answers.get(qid))
|
|
35
|
+
if not isinstance(raw, int) or not 0 <= raw < len(options):
|
|
36
|
+
raise PhocinaeError("choice 答案异常:%r(选项数 %d)"
|
|
37
|
+
% (raw, len(options)))
|
|
38
|
+
conf = confs.get(qid)
|
|
39
|
+
conf_f = float(conf) if isinstance(conf, (int, float)) else None
|
|
40
|
+
if ctx.obj.json:
|
|
41
|
+
output.print_json(body)
|
|
42
|
+
return
|
|
43
|
+
output.console.print(
|
|
44
|
+
"[bold green]%s: %s[/] (index=%d)"
|
|
45
|
+
% (what, options[raw], raw))
|
|
46
|
+
if conf_f is not None:
|
|
47
|
+
escalate = conf_f < TAU_DEFAULT
|
|
48
|
+
output.console.print(
|
|
49
|
+
"confidence: %.4f tau=%.2f escalate: [bold %s]%s[/]"
|
|
50
|
+
% (conf_f, TAU_DEFAULT,
|
|
51
|
+
"red" if escalate else "green", escalate))
|
|
52
|
+
scores = (body.get("option_scores") or {}).get(qid)
|
|
53
|
+
if scores:
|
|
54
|
+
output.scores_table(qid, scores, title="选项概率(标定)",
|
|
55
|
+
labels=options)
|
|
56
|
+
output.console.print(
|
|
57
|
+
" model: %s" % body.get("model", "?"), style="dim")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_tools(tools):
|
|
61
|
+
"""工具列表解析:'name' 或 'name:desc'(逗号分隔,可重复)。"""
|
|
62
|
+
names, descs = [], []
|
|
63
|
+
for spec in tools:
|
|
64
|
+
for part in spec.split(","):
|
|
65
|
+
part = part.strip()
|
|
66
|
+
if not part:
|
|
67
|
+
continue
|
|
68
|
+
name, sep, desc = part.partition(":")
|
|
69
|
+
name = name.strip()
|
|
70
|
+
if not name:
|
|
71
|
+
raise QuestionSpecError("工具名不能为空:%r" % part)
|
|
72
|
+
if name in names:
|
|
73
|
+
raise QuestionSpecError("工具名重复:%r" % name)
|
|
74
|
+
names.append(name)
|
|
75
|
+
descs.append(desc.strip() if sep and desc.strip() else None)
|
|
76
|
+
if not names:
|
|
77
|
+
raise QuestionSpecError("--tools 至少需要一个工具(name 或 name:desc)")
|
|
78
|
+
if len(names) > MAX_TOOLS:
|
|
79
|
+
raise QuestionSpecError(
|
|
80
|
+
"工具数 %d 超过 %d 上限(JevBench tool_selection 域界)"
|
|
81
|
+
% (len(names), MAX_TOOLS))
|
|
82
|
+
options = [("%s: %s" % (n, d[:160])) if d else n
|
|
83
|
+
for n, d in zip(names, descs)]
|
|
84
|
+
return names, options
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def classify(
|
|
88
|
+
ctx: typer.Context,
|
|
89
|
+
state: str = typer.Option(None, "--state", "-s", help="决策上下文"),
|
|
90
|
+
labels: str = typer.Option(..., "--labels", "-l",
|
|
91
|
+
help="候选标签,逗号分隔(1–255 个)"),
|
|
92
|
+
perm: bool = typer.Option(False, "--perm", help="4 排列平均"),
|
|
93
|
+
):
|
|
94
|
+
"""state + labels → 单选标签。"""
|
|
95
|
+
opts = [x.strip() for x in labels.split(",") if x.strip()]
|
|
96
|
+
if not opts:
|
|
97
|
+
output.fail("--labels 至少需要一个标签")
|
|
98
|
+
if len(opts) > MAX_OPTIONS:
|
|
99
|
+
output.fail("标签数 %d 超过上限 %d" % (len(opts), MAX_OPTIONS))
|
|
100
|
+
try:
|
|
101
|
+
state_text = _read_state_stdin(state)
|
|
102
|
+
body = ctx.obj.client().systemone(
|
|
103
|
+
state_text, [{"id": "classify", "type": "choice", "options": opts}],
|
|
104
|
+
perm=perm)
|
|
105
|
+
except (PhocinaeError, QuestionSpecError) as exc:
|
|
106
|
+
output.fail(str(exc))
|
|
107
|
+
_print_choice_result(ctx, body, "classify", opts, "label")
|
|
108
|
+
raise SystemExit(output.EXIT_OK)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def route(
|
|
112
|
+
ctx: typer.Context,
|
|
113
|
+
state: str = typer.Option(None, "--state", "-s", help="决策上下文"),
|
|
114
|
+
tools: list[str] = typer.Option(
|
|
115
|
+
..., "--tools", "-t", help="工具列表 'name' 或 'name:desc',逗号分隔,可重复"),
|
|
116
|
+
no_perm: bool = typer.Option(
|
|
117
|
+
False, "--no-perm", help="关闭 4 排列平均(默认开启,canonical 口径)"),
|
|
118
|
+
):
|
|
119
|
+
"""state + 工具列表 → 工具选择(4 排列平均 + escalate)。"""
|
|
120
|
+
try:
|
|
121
|
+
names, options = _parse_tools(tools)
|
|
122
|
+
state_text = _read_state_stdin(state)
|
|
123
|
+
body = ctx.obj.client().systemone(
|
|
124
|
+
state_text, [{"id": "tool", "type": "choice", "options": options}],
|
|
125
|
+
perm=not no_perm)
|
|
126
|
+
except (PhocinaeError, QuestionSpecError) as exc:
|
|
127
|
+
output.fail(str(exc))
|
|
128
|
+
_print_choice_result(ctx, body, "tool", names, "tool")
|
|
129
|
+
raise SystemExit(output.EXIT_OK)
|
phocinae_cli/cli.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""cli.py —— phocinae CLI 主程序(typer)。
|
|
2
|
+
|
|
3
|
+
子命令:decide / classify / route / score / gate / ping。
|
|
4
|
+
全局选项:--server(env PHOC_SERVER)、--timeout、--retries、--model、
|
|
5
|
+
--json、--verbose。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from . import DEFAULT_MODEL, DEFAULT_SERVER, __version__
|
|
15
|
+
from .client import CONNECT_TIMEOUT, DEFAULT_RETRIES, DEFAULT_TIMEOUT, Client
|
|
16
|
+
from .output import EXIT_ERROR, EXIT_OK, console, kv_table, print_json
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="phocinae",
|
|
20
|
+
help="斑海豹 phocinae CLI —— 一行命令本地决策(/v1/systemone 客户端)。",
|
|
21
|
+
add_completion=False,
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Ctx:
|
|
28
|
+
"""全局配置(typer callback 注入 ctx.obj)。"""
|
|
29
|
+
server: str = DEFAULT_SERVER
|
|
30
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
31
|
+
retries: int = DEFAULT_RETRIES
|
|
32
|
+
model: str = DEFAULT_MODEL
|
|
33
|
+
json: bool = False
|
|
34
|
+
verbose: bool = False
|
|
35
|
+
|
|
36
|
+
def client(self, timeout=None):
|
|
37
|
+
return Client(server=self.server, timeout=timeout or self.timeout,
|
|
38
|
+
retries=self.retries, model=self.model,
|
|
39
|
+
verbosity=1 if self.verbose else 0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.callback()
|
|
43
|
+
def _main(
|
|
44
|
+
ctx: typer.Context,
|
|
45
|
+
server: str = typer.Option(
|
|
46
|
+
os.environ.get("PHOC_SERVER", DEFAULT_SERVER),
|
|
47
|
+
"--server", help="phocinae-server 基地址(env PHOC_SERVER)"),
|
|
48
|
+
timeout: float = typer.Option(
|
|
49
|
+
DEFAULT_TIMEOUT, "--timeout", help="总请求超时秒数(默认 30)"),
|
|
50
|
+
retries: int = typer.Option(
|
|
51
|
+
DEFAULT_RETRIES, "--retries", help="连接错误/5xx 重试次数(默认 3)"),
|
|
52
|
+
model: str = typer.Option(
|
|
53
|
+
DEFAULT_MODEL, "--model", help="模型名(默认 Phocinae-Largha-150M-v1)"),
|
|
54
|
+
json_: bool = typer.Option(
|
|
55
|
+
False, "--json", help="输出原始 JSON(默认富表格)"),
|
|
56
|
+
verbose: bool = typer.Option(
|
|
57
|
+
False, "--verbose", help="显示重试与完整选项概率"),
|
|
58
|
+
version_flag: bool = typer.Option(
|
|
59
|
+
False, "--version", help="打印版本并退出"),
|
|
60
|
+
):
|
|
61
|
+
if version_flag:
|
|
62
|
+
console.print("phocinae CLI %s" % __version__)
|
|
63
|
+
raise SystemExit(EXIT_OK)
|
|
64
|
+
ctx.obj = Ctx(server=server, timeout=timeout, retries=retries,
|
|
65
|
+
model=model, json=json_, verbose=verbose)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command()
|
|
69
|
+
def ping(ctx: typer.Context):
|
|
70
|
+
"""健康检查:GET /health + GET /v1/models。成功 exit 0,失败 exit 1。"""
|
|
71
|
+
c = ctx.obj.client()
|
|
72
|
+
try:
|
|
73
|
+
health = c.health()
|
|
74
|
+
models = c.models()
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
sys.stderr.write("phocinae: 错误:%s\n" % exc)
|
|
77
|
+
raise SystemExit(EXIT_ERROR)
|
|
78
|
+
if ctx.obj.json:
|
|
79
|
+
print_json({"health": health, "models": models})
|
|
80
|
+
raise SystemExit(EXIT_OK)
|
|
81
|
+
data = (models.get("data") or [{}])[0]
|
|
82
|
+
kv_table([
|
|
83
|
+
("endpoint", c.server),
|
|
84
|
+
("status", health.get("status")),
|
|
85
|
+
("model", health.get("model")),
|
|
86
|
+
("model_loaded", health.get("model_loaded")),
|
|
87
|
+
("device", health.get("device")),
|
|
88
|
+
("uptime_s", health.get("uptime_s")),
|
|
89
|
+
("context_length", data.get("context_length")),
|
|
90
|
+
("perm_avg", data.get("perm_avg")),
|
|
91
|
+
("extensions", ", ".join(data.get("extensions") or [])),
|
|
92
|
+
], title="phocinae-server 健康检查")
|
|
93
|
+
raise SystemExit(EXIT_OK)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# 子命令注册(函数定义于各模块,直接挂到主 app:phocinae decide ...)
|
|
97
|
+
from . import classify_route, decide, score_gate_cli # noqa: E402
|
|
98
|
+
|
|
99
|
+
app.command("decide",
|
|
100
|
+
help="state + questions → answers(表格式/JSON;管道与批量模式)")(
|
|
101
|
+
decide.decide)
|
|
102
|
+
app.command("classify",
|
|
103
|
+
help="state + labels → 单选标签(choice 题)")(
|
|
104
|
+
classify_route.classify)
|
|
105
|
+
app.command("route",
|
|
106
|
+
help="state + 工具列表 → 工具选择(4 排列平均 + escalate)")(
|
|
107
|
+
classify_route.route)
|
|
108
|
+
app.command("score",
|
|
109
|
+
help="state + criteria → 2–10 风险分(含各档概率)")(
|
|
110
|
+
score_gate_cli.score)
|
|
111
|
+
app.command("gate",
|
|
112
|
+
help="命令审批门:allow / deny / ask(L0 离线 + 可选 L1 + fail-closed)")(
|
|
113
|
+
score_gate_cli.gate)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main():
|
|
117
|
+
# typer 的 no_args_is_help 在无子命令时会跳过 callback → 预先处理 --version
|
|
118
|
+
if "--version" in sys.argv[1:]:
|
|
119
|
+
console.print("phocinae CLI %s" % __version__)
|
|
120
|
+
raise SystemExit(EXIT_OK)
|
|
121
|
+
app()
|
phocinae_cli/client.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""client.py —— phocinae-server HTTP 客户端。
|
|
2
|
+
|
|
3
|
+
httpx 重试(连接错误/5xx,指数退避,4xx 不重试)、超时分层
|
|
4
|
+
(connect 5s / 总 timeout 默认 30s)、错误分类(服务未启动给可行动提示)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from . import DEFAULT_MODEL, DEFAULT_SERVER, __version__
|
|
14
|
+
|
|
15
|
+
DEFAULT_TIMEOUT = 30.0
|
|
16
|
+
CONNECT_TIMEOUT = 5.0
|
|
17
|
+
DEFAULT_RETRIES = 3
|
|
18
|
+
MAX_QUESTIONS = 64
|
|
19
|
+
MAX_OPTIONS = 255
|
|
20
|
+
MAX_BATCH = 64
|
|
21
|
+
|
|
22
|
+
STARTUP_HINT = (
|
|
23
|
+
"phocinae-server 似乎未在运行({server})。启动方式:\n"
|
|
24
|
+
" · 官方包:pip install phocinae-server,然后运行其 run_server.sh\n"
|
|
25
|
+
" (export PHOC_MODEL_DIR=<hf_repo 路径>;默认端口 8155)\n"
|
|
26
|
+
" · 源码:python -m phocinae.main --host 127.0.0.1 --port 8155\n"
|
|
27
|
+
"或先运行 `phocinae ping` 确认服务状态;"
|
|
28
|
+
"本 CLI 可用 --server 指定地址,或用环境变量 PHOC_SERVER。"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PhocinaeError(Exception):
|
|
33
|
+
"""phocinae CLI 错误基类(exit 1)。"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ServerUnavailableError(PhocinaeError):
|
|
37
|
+
"""连接不上 / DNS / 连接拒绝。"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ServerTimeoutError(PhocinaeError):
|
|
41
|
+
"""请求超时。"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ServerHTTPError(PhocinaeError):
|
|
45
|
+
"""服务返回非 2xx(含 422 校验错、401 鉴权、413 超大)。"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, status, detail):
|
|
48
|
+
super().__init__("phocinae-server 返回 HTTP %d:%s" % (status, detail))
|
|
49
|
+
self.status = status
|
|
50
|
+
self.detail = detail
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ResponseShapeError(PhocinaeError):
|
|
54
|
+
"""200 但响应结构不符合 /v1/systemone 协议。"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------- 应答解包(P0 兼容)
|
|
58
|
+
def _unwrap(value, keys):
|
|
59
|
+
"""兼容 P0 扁平值与旧 mock 嵌套对象({type, noul|score|choice, ...})。
|
|
60
|
+
|
|
61
|
+
与 phocinae-mcp backend._unwrap 同口径。
|
|
62
|
+
"""
|
|
63
|
+
seen = 0
|
|
64
|
+
while isinstance(value, dict) and seen < 4:
|
|
65
|
+
seen += 1
|
|
66
|
+
hit = None
|
|
67
|
+
for key in keys:
|
|
68
|
+
if key in value:
|
|
69
|
+
hit = value[key]
|
|
70
|
+
break
|
|
71
|
+
if hit is None:
|
|
72
|
+
for key in ("answer", "value"):
|
|
73
|
+
if key in value:
|
|
74
|
+
hit = value[key]
|
|
75
|
+
break
|
|
76
|
+
if hit is None:
|
|
77
|
+
return None
|
|
78
|
+
value = hit
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def unwrap_noul(value):
|
|
83
|
+
return _unwrap(value, ("noul",))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def unwrap_choice(value):
|
|
87
|
+
return _unwrap(value, ("choice", "index"))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def unwrap_score(value):
|
|
91
|
+
return _unwrap(value, ("score",))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def noul_to_probability(noul):
|
|
95
|
+
"""扁平 bool → 放行概率;数值原样传递(与 guard/mcp 同口径)。"""
|
|
96
|
+
if noul is True:
|
|
97
|
+
return 1.0
|
|
98
|
+
if noul is False:
|
|
99
|
+
return 0.0
|
|
100
|
+
return float(noul)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Client:
|
|
104
|
+
"""/v1/systemone 客户端:重试 + 超时 + 错误分类。"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, server=DEFAULT_SERVER, timeout=DEFAULT_TIMEOUT,
|
|
107
|
+
retries=DEFAULT_RETRIES, model=DEFAULT_MODEL,
|
|
108
|
+
token=None, verbosity=0):
|
|
109
|
+
self.server = (server or DEFAULT_SERVER).rstrip("/")
|
|
110
|
+
self.timeout = max(0.1, float(timeout))
|
|
111
|
+
self.retries = max(0, int(retries))
|
|
112
|
+
self.model = model or DEFAULT_MODEL
|
|
113
|
+
self.token = token or os.environ.get("PHOC_BEARER_TOKEN") or None
|
|
114
|
+
self.verbosity = verbosity
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------ 内部工具
|
|
117
|
+
def _headers(self):
|
|
118
|
+
h = {"Content-Type": "application/json",
|
|
119
|
+
"User-Agent": "phocinae-cli/" + __version__}
|
|
120
|
+
if self.token:
|
|
121
|
+
h["Authorization"] = "Bearer " + self.token
|
|
122
|
+
return h
|
|
123
|
+
|
|
124
|
+
def _request(self, method, path, body=None):
|
|
125
|
+
"""带重试的 HTTP 请求。连接错误/超时/5xx 重试,4xx 立即抛出。"""
|
|
126
|
+
url = self.server + path
|
|
127
|
+
payload = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
|
|
128
|
+
last_exc = None
|
|
129
|
+
for attempt in range(self.retries + 1):
|
|
130
|
+
if self.verbosity >= 1 and attempt > 0:
|
|
131
|
+
print(" [retry %d/%d] %s %s" % (attempt, self.retries, method, path),
|
|
132
|
+
file=os.sys.stderr)
|
|
133
|
+
try:
|
|
134
|
+
with httpx.Client(timeout=httpx.Timeout(
|
|
135
|
+
self.timeout, connect=CONNECT_TIMEOUT),
|
|
136
|
+
trust_env=False) as http:
|
|
137
|
+
resp = http.request(method, url, content=payload,
|
|
138
|
+
headers=self._headers())
|
|
139
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
|
|
140
|
+
last_exc = exc
|
|
141
|
+
if attempt < self.retries:
|
|
142
|
+
time.sleep(0.4 * (2 ** attempt))
|
|
143
|
+
continue
|
|
144
|
+
raise ServerUnavailableError(
|
|
145
|
+
"无法连接 phocinae-server(%s):%s\n%s"
|
|
146
|
+
% (url, exc, STARTUP_HINT.format(server=self.server))) from exc
|
|
147
|
+
except httpx.ReadTimeout as exc:
|
|
148
|
+
last_exc = exc
|
|
149
|
+
if attempt < self.retries:
|
|
150
|
+
time.sleep(0.4 * (2 ** attempt))
|
|
151
|
+
continue
|
|
152
|
+
raise ServerTimeoutError(
|
|
153
|
+
"请求超时(>%.1fs):%s\n可加大 --timeout 或检查服务负载("
|
|
154
|
+
"permute 端点 4× 前向、批量端点 ≤64 题,都更慢)。"
|
|
155
|
+
% (self.timeout, url)) from exc
|
|
156
|
+
if 500 <= resp.status_code <= 599 and attempt < self.retries:
|
|
157
|
+
last_exc = ServerHTTPError(resp.status_code,
|
|
158
|
+
resp.text[:300].replace("\n", " "))
|
|
159
|
+
time.sleep(0.4 * (2 ** attempt))
|
|
160
|
+
continue
|
|
161
|
+
if resp.status_code >= 400:
|
|
162
|
+
if resp.status_code == 422:
|
|
163
|
+
detail = self._extract_detail(resp)
|
|
164
|
+
raise ServerHTTPError(422, detail)
|
|
165
|
+
raise ServerHTTPError(resp.status_code,
|
|
166
|
+
resp.text[:300].replace("\n", " "))
|
|
167
|
+
return resp
|
|
168
|
+
raise last_exc # pragma: no cover
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _extract_detail(resp):
|
|
172
|
+
try:
|
|
173
|
+
data = resp.json()
|
|
174
|
+
except ValueError:
|
|
175
|
+
return resp.text[:300].replace("\n", " ")
|
|
176
|
+
if isinstance(data, dict):
|
|
177
|
+
if "detail" in data:
|
|
178
|
+
d = data["detail"]
|
|
179
|
+
return json.dumps(d, ensure_ascii=False)[:300]
|
|
180
|
+
err = data.get("error")
|
|
181
|
+
if isinstance(err, dict):
|
|
182
|
+
return "%s: %s" % (err.get("code"), err.get("message"))
|
|
183
|
+
return resp.text[:300].replace("\n", " ")
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _parse(resp):
|
|
187
|
+
try:
|
|
188
|
+
return resp.json()
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
raise ResponseShapeError(
|
|
191
|
+
"服务响应不是合法 JSON(%s;URL=%s)" % (exc, resp.url)) from exc
|
|
192
|
+
|
|
193
|
+
def _check_answers(self, body, questions):
|
|
194
|
+
answers = body.get("answers")
|
|
195
|
+
if not isinstance(answers, dict):
|
|
196
|
+
raise ResponseShapeError(
|
|
197
|
+
"响应缺少 answers 字段(可能是别的服务,"
|
|
198
|
+
"请确认指向 phocinae-server 的 /v1/systemone)")
|
|
199
|
+
missing = [q["id"] for q in questions if q["id"] not in answers]
|
|
200
|
+
if missing:
|
|
201
|
+
raise ResponseShapeError("响应缺少问题答案:%s" % missing)
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------ 对外接口
|
|
204
|
+
def systemone(self, state, questions, perm=False):
|
|
205
|
+
"""POST /v1/systemone(perm=True 时走 /permute)。"""
|
|
206
|
+
body = {"model": self.model, "state": state, "questions": questions}
|
|
207
|
+
path = "/v1/systemone/permute" if perm else "/v1/systemone"
|
|
208
|
+
resp = self._request("POST", path, body)
|
|
209
|
+
data = self._parse(resp)
|
|
210
|
+
if not isinstance(data, dict):
|
|
211
|
+
raise ResponseShapeError("响应顶层不是 JSON 对象")
|
|
212
|
+
self._check_answers(data, questions)
|
|
213
|
+
return data
|
|
214
|
+
|
|
215
|
+
def batch(self, items, perm=False):
|
|
216
|
+
"""POST /v1/systemone/batch;items = [(state, questions), ...]。
|
|
217
|
+
自动按 MAX_BATCH=64 分片,保持顺序。"""
|
|
218
|
+
out = []
|
|
219
|
+
reqs = [{"model": self.model, "state": s, "questions": q}
|
|
220
|
+
for s, q in items]
|
|
221
|
+
for i in range(0, len(reqs), MAX_BATCH):
|
|
222
|
+
chunk = reqs[i:i + MAX_BATCH]
|
|
223
|
+
resp = self._request("POST", "/v1/systemone/batch",
|
|
224
|
+
{"requests": chunk})
|
|
225
|
+
data = self._parse(resp)
|
|
226
|
+
if not isinstance(data, list):
|
|
227
|
+
raise ResponseShapeError("batch 响应顶层不是 JSON 数组")
|
|
228
|
+
out.extend(data)
|
|
229
|
+
for body, (_, questions) in zip(out, items):
|
|
230
|
+
self._check_answers(body, questions)
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
def health(self):
|
|
234
|
+
resp = self._request("GET", "/health")
|
|
235
|
+
data = self._parse(resp)
|
|
236
|
+
if not isinstance(data, dict):
|
|
237
|
+
raise ResponseShapeError("/health 响应不是 JSON 对象")
|
|
238
|
+
return data
|
|
239
|
+
|
|
240
|
+
def models(self):
|
|
241
|
+
resp = self._request("GET", "/v1/models")
|
|
242
|
+
data = self._parse(resp)
|
|
243
|
+
return data
|
phocinae_cli/decide.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""decide.py —— decide 子命令:state + questions → answers。
|
|
2
|
+
|
|
3
|
+
模式:
|
|
4
|
+
常规 --state TEXT(或管道/--state - 从 stdin 读一条 state)
|
|
5
|
+
批量 --batch:stdin 每行一条 state(JSON 对象行取 "state" 键),
|
|
6
|
+
经 /v1/systemone/batch 提交(自动按 64 分片)。
|
|
7
|
+
题目:--noul ID[:thr] / --choice ID:o1,o2 / --score ID / --questions-json。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from . import output
|
|
16
|
+
from .client import PhocinaeError
|
|
17
|
+
from .output import console
|
|
18
|
+
from .questions import QuestionSpecError, parse_specs
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_state(stdin_text=None):
|
|
22
|
+
"""解析 --state 参数:None 且 stdin 非 tty → 读 stdin;'-' → 读 stdin。"""
|
|
23
|
+
if stdin_text == "-":
|
|
24
|
+
return sys.stdin.read().strip()
|
|
25
|
+
if stdin_text:
|
|
26
|
+
return stdin_text
|
|
27
|
+
if not sys.stdin.isatty():
|
|
28
|
+
data = sys.stdin.read().strip()
|
|
29
|
+
if not data:
|
|
30
|
+
raise QuestionSpecError("stdin 为空,无法取得 state")
|
|
31
|
+
return data
|
|
32
|
+
raise QuestionSpecError(
|
|
33
|
+
"需要 state:--state '文本',或 echo '文本' | phocinae decide ...")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read_batch_states():
|
|
37
|
+
"""批量模式:每行一条 state;JSON 对象行取 state 键。"""
|
|
38
|
+
states = []
|
|
39
|
+
for lineno, line in enumerate(sys.stdin, 1):
|
|
40
|
+
line = line.strip()
|
|
41
|
+
if not line:
|
|
42
|
+
continue
|
|
43
|
+
if line.startswith("{"):
|
|
44
|
+
try:
|
|
45
|
+
obj = json.loads(line)
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
raise QuestionSpecError(
|
|
48
|
+
"批量输入第 %d 行不是合法 JSON:%s" % (lineno, exc))
|
|
49
|
+
if not isinstance(obj, dict) or not isinstance(obj.get("state"), str):
|
|
50
|
+
raise QuestionSpecError(
|
|
51
|
+
"批量输入第 %d 行 JSON 对象必须含字符串 state 字段" % lineno)
|
|
52
|
+
states.append(obj["state"])
|
|
53
|
+
else:
|
|
54
|
+
states.append(line)
|
|
55
|
+
if not states:
|
|
56
|
+
raise QuestionSpecError("批量模式需要 stdin 提供至少一行 state")
|
|
57
|
+
return states
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def decide(
|
|
61
|
+
ctx: typer.Context,
|
|
62
|
+
state: str = typer.Option(None, "--state", "-s",
|
|
63
|
+
help="决策上下文;缺省且 stdin 被管道时读 stdin"),
|
|
64
|
+
noul: list[str] = typer.Option(
|
|
65
|
+
None, "--noul", help="noul 题 ID[:threshold],可重复"),
|
|
66
|
+
choice: list[str] = typer.Option(
|
|
67
|
+
None, "--choice", help="choice 题 ID:选项1,选项2,可重复"),
|
|
68
|
+
score: list[str] = typer.Option(
|
|
69
|
+
None, "--score", help="score 题 ID,可重复"),
|
|
70
|
+
questions_json: str = typer.Option(
|
|
71
|
+
None, "--questions-json",
|
|
72
|
+
help="完整题目规格:内联 JSON 数组或文件路径"),
|
|
73
|
+
batch: bool = typer.Option(False, "--batch", help="批量模式:stdin 每行一条 state"),
|
|
74
|
+
perm: bool = typer.Option(
|
|
75
|
+
False, "--perm", help="choice 题 4 排列平均(/v1/systemone/permute)"),
|
|
76
|
+
):
|
|
77
|
+
"""state + questions → answers。"""
|
|
78
|
+
client = ctx.obj.client()
|
|
79
|
+
try:
|
|
80
|
+
questions = parse_specs(noul, choice, score, questions_json)
|
|
81
|
+
if batch:
|
|
82
|
+
if state:
|
|
83
|
+
raise QuestionSpecError("--batch 与 --state 互斥")
|
|
84
|
+
states = _read_batch_states()
|
|
85
|
+
bodies = client.batch([(s, questions) for s in states], perm=perm)
|
|
86
|
+
if ctx.obj.json:
|
|
87
|
+
output.print_json(bodies)
|
|
88
|
+
else:
|
|
89
|
+
for s, body in zip(states, bodies):
|
|
90
|
+
output.answers_table(
|
|
91
|
+
body, questions,
|
|
92
|
+
title="state: %s" % (s if len(s) <= 64 else s[:61] + "…"),
|
|
93
|
+
show_option_scores=ctx.obj.verbose)
|
|
94
|
+
raise SystemExit(output.EXIT_OK)
|
|
95
|
+
state_text = _read_state(state)
|
|
96
|
+
body = client.systemone(state_text, questions, perm=perm)
|
|
97
|
+
except (PhocinaeError, QuestionSpecError) as exc:
|
|
98
|
+
output.fail(str(exc))
|
|
99
|
+
if ctx.obj.json:
|
|
100
|
+
output.print_json(body)
|
|
101
|
+
else:
|
|
102
|
+
console.print("state: %s" % state_text, style="dim")
|
|
103
|
+
output.answers_table(body, questions,
|
|
104
|
+
show_option_scores=ctx.obj.verbose)
|
|
105
|
+
raise SystemExit(output.EXIT_OK)
|