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.
@@ -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"
@@ -0,0 +1,6 @@
1
+ """python -m phocinae_cli 入口。"""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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)