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/gate.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""gate.py —— 命令审批门编排:L0 离线判定 →(--l1 时)L1 模型两问 → fail-closed。
|
|
2
|
+
|
|
3
|
+
判定流程(与 phocinae-guard 同口径):
|
|
4
|
+
L0 纯离线确定性表(白名单 + 前缀 + 黑名单正则)→ allow / deny;
|
|
5
|
+
L1 L0 不能定夺且 --l1 显式启用 → POST /v1/systemone 两问
|
|
6
|
+
(noul 放行概率 + score 风险 2–10)→ 阈值映射 deny / ask / allow;
|
|
7
|
+
fail-closed 服务器不可用 → 除白名单外一律 --fail-closed(deny/ask)。
|
|
8
|
+
|
|
9
|
+
退出码约定(本 CLI 全命令统一):
|
|
10
|
+
0 = allow(成功) 2 = deny 3 = ask(需人工确认) 1 = 错误
|
|
11
|
+
注:与 phocinae-guard(deny=1/ask=2)不同——本任务口径规定 deny=2。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .client import (ResponseShapeError, noul_to_probability, unwrap_noul,
|
|
18
|
+
unwrap_score)
|
|
19
|
+
from .l0 import l0_judge
|
|
20
|
+
|
|
21
|
+
EXIT_ALLOW = 0
|
|
22
|
+
EXIT_DENY = 2
|
|
23
|
+
EXIT_ASK = 3
|
|
24
|
+
EXIT_ERROR = 1
|
|
25
|
+
|
|
26
|
+
# 与 phocinae-guard / phocinae-mcp 一致的通道阈值
|
|
27
|
+
NOUL_THRESHOLD = 0.65
|
|
28
|
+
DENY_AT = 7.0
|
|
29
|
+
ASK_AT = 4.0
|
|
30
|
+
GATE_TIMEOUT = 2.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def map_l1(noul_p, score, noul_threshold=NOUL_THRESHOLD,
|
|
34
|
+
deny_at=DENY_AT, ask_at=ASK_AT):
|
|
35
|
+
"""阈值映射(通道合成取最严格):score>=deny_at 或 noul=false → deny;
|
|
36
|
+
score>=ask_at 或 noul 摇摆 → ask;其余 → allow。"""
|
|
37
|
+
if noul_p <= 1.0 - noul_threshold:
|
|
38
|
+
noul_state = "false"
|
|
39
|
+
elif noul_p >= noul_threshold:
|
|
40
|
+
noul_state = "true"
|
|
41
|
+
else:
|
|
42
|
+
noul_state = "wavering"
|
|
43
|
+
channels = []
|
|
44
|
+
if score >= deny_at:
|
|
45
|
+
channels.append("deny")
|
|
46
|
+
elif score >= ask_at:
|
|
47
|
+
channels.append("ask")
|
|
48
|
+
else:
|
|
49
|
+
channels.append("allow")
|
|
50
|
+
if noul_state == "false":
|
|
51
|
+
channels.append("deny")
|
|
52
|
+
elif noul_state == "wavering":
|
|
53
|
+
channels.append("ask")
|
|
54
|
+
else:
|
|
55
|
+
channels.append("allow")
|
|
56
|
+
decision = "deny" if "deny" in channels else \
|
|
57
|
+
("ask" if "ask" in channels else "allow")
|
|
58
|
+
return decision, noul_state
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def l1_ask(client, command, cwd):
|
|
62
|
+
"""/v1/systemone 两问(与 guard 同题面)→ (noul_p, score)。"""
|
|
63
|
+
state = json.dumps({"command": command, "cwd": cwd,
|
|
64
|
+
"tool": "phocinae-cli/" + __version__},
|
|
65
|
+
ensure_ascii=False)
|
|
66
|
+
questions = [
|
|
67
|
+
{"id": "guard_noul", "type": "noul",
|
|
68
|
+
"instructions": "This command requires human review.",
|
|
69
|
+
"criteria": {
|
|
70
|
+
"false": "The command is safe to run without human attention.",
|
|
71
|
+
"true": "A human should inspect this command before it runs."},
|
|
72
|
+
"threshold": NOUL_THRESHOLD},
|
|
73
|
+
{"id": "guard_score", "type": "score",
|
|
74
|
+
"instructions": "Assess the risk of the command described in the state."},
|
|
75
|
+
]
|
|
76
|
+
body = client.systemone(state, questions, perm=False)
|
|
77
|
+
answers = body.get("answers", {})
|
|
78
|
+
noul_raw = unwrap_noul(answers.get("guard_noul"))
|
|
79
|
+
score_raw = unwrap_score(answers.get("guard_score"))
|
|
80
|
+
if noul_raw is None or score_raw is None:
|
|
81
|
+
raise ResponseShapeError("服务应答缺少 guard_noul / guard_score")
|
|
82
|
+
return noul_to_probability(noul_raw), float(score_raw)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def decide(command, cwd, cfg):
|
|
86
|
+
"""gate 主判定:返回结果 dict(decision/layer/reason/noul/score/...)。"""
|
|
87
|
+
result = {
|
|
88
|
+
"command": command,
|
|
89
|
+
"cwd": cwd,
|
|
90
|
+
"decision": None,
|
|
91
|
+
"layer": "L0",
|
|
92
|
+
"reason": "",
|
|
93
|
+
"noul": None,
|
|
94
|
+
"score": None,
|
|
95
|
+
"noul_state": None,
|
|
96
|
+
"thresholds": {
|
|
97
|
+
"noul_threshold": cfg["noul_threshold"],
|
|
98
|
+
"deny_at": cfg["deny_at"],
|
|
99
|
+
"ask_at": cfg["ask_at"],
|
|
100
|
+
},
|
|
101
|
+
"confirm": False,
|
|
102
|
+
}
|
|
103
|
+
decision, reason = l0_judge(command, cfg["table"])
|
|
104
|
+
if decision is not None:
|
|
105
|
+
result["decision"] = decision
|
|
106
|
+
result["reason"] = reason
|
|
107
|
+
return result
|
|
108
|
+
# L0 不能定夺 → 灰区
|
|
109
|
+
if not cfg["l1_enabled"]:
|
|
110
|
+
result["decision"] = cfg["fail_closed"]
|
|
111
|
+
result["layer"] = "fail_closed"
|
|
112
|
+
result["reason"] = ("L1 未启用(--l1 才把灰区交给模型;"
|
|
113
|
+
"150M 权重未做过命令审批域校准)→ fail-closed=%s"
|
|
114
|
+
% cfg["fail_closed"])
|
|
115
|
+
return result
|
|
116
|
+
try:
|
|
117
|
+
noul_p, score = l1_ask(cfg["client"], command, cwd)
|
|
118
|
+
except Exception as exc: # 网络/协议/结构异常 → fail-closed(绝不误放行)
|
|
119
|
+
result["decision"] = cfg["fail_closed"]
|
|
120
|
+
result["layer"] = "fail_closed"
|
|
121
|
+
result["reason"] = ("L1 服务不可用或响应异常(%s: %s)→ fail-closed=%s"
|
|
122
|
+
% (type(exc).__name__, exc, cfg["fail_closed"]))
|
|
123
|
+
return result
|
|
124
|
+
decision, noul_state = map_l1(noul_p, score, cfg["noul_threshold"],
|
|
125
|
+
cfg["deny_at"], cfg["ask_at"])
|
|
126
|
+
result.update({
|
|
127
|
+
"decision": decision,
|
|
128
|
+
"layer": "L1",
|
|
129
|
+
"reason": ("L1 noul=%.4f(%s,threshold=%.2f) score=%.4f"
|
|
130
|
+
"(deny>=%g,ask>=%g)→ %s"
|
|
131
|
+
% (noul_p, noul_state, cfg["noul_threshold"], score,
|
|
132
|
+
cfg["deny_at"], cfg["ask_at"], decision)),
|
|
133
|
+
"noul": noul_p,
|
|
134
|
+
"score": score,
|
|
135
|
+
"noul_state": noul_state,
|
|
136
|
+
})
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def apply_confirm(result, confirm):
|
|
141
|
+
"""--confirm 人工确认:仅把 ask 升级为 allow;deny 不可覆盖。"""
|
|
142
|
+
if not confirm:
|
|
143
|
+
return result
|
|
144
|
+
result["confirm"] = True
|
|
145
|
+
if result["decision"] == "ask":
|
|
146
|
+
result["decision"] = "allow"
|
|
147
|
+
result["layer"] = "human_confirm"
|
|
148
|
+
result["reason"] += ";--confirm 人工确认放行"
|
|
149
|
+
elif result["decision"] == "deny":
|
|
150
|
+
result["reason"] += "(--confirm 不能覆盖 deny)"
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
EXIT_CODES = {"allow": EXIT_ALLOW, "deny": EXIT_DENY, "ask": EXIT_ASK}
|
phocinae_cli/l0.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""l0.py —— gate 命令的 L0 离线判定层(纯确定性,无网络)。
|
|
2
|
+
|
|
3
|
+
表结构与判定口径移植自 phocinae-guard 0.1.0 的内置默认表
|
|
4
|
+
(同生态件,MIT;保持 whitelist 精确 / whitelist_prefix 前缀 / blacklist 正则
|
|
5
|
+
三层判定,黑名单取最严)。可经 --table 用自定义 JSON 覆盖各节。
|
|
6
|
+
|
|
7
|
+
注意(P0 口径,与 guard 一致):150M 权重未做过命令审批域校准,
|
|
8
|
+
L1 模型信号 ≈ 噪声,故 gate 默认只走 L0 + fail-closed;--l1 显式启用才
|
|
9
|
+
把灰区交给 /v1/systemone。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
DEFAULT_L0 = {
|
|
16
|
+
"whitelist": [
|
|
17
|
+
"ls", "ls -a", "ls -la", "ls -l", "pwd", "whoami", "date", "uname -a",
|
|
18
|
+
"env", "hostname", "id", "echo $?", "which python", "which python3",
|
|
19
|
+
"which node", "which git", "git status", "git status --short",
|
|
20
|
+
"git diff", "git diff --stat", "git log", "git log --oneline",
|
|
21
|
+
"git log --oneline -10", "git show", "git branch", "git branch -a",
|
|
22
|
+
"git remote -v", "git fetch", "git fetch --all", "git stash list",
|
|
23
|
+
"git tag", "git tag -l", "git --version", "python --version",
|
|
24
|
+
"python3 --version", "pip --version", "node --version", "npm --version",
|
|
25
|
+
"gcc --version", "g++ --version",
|
|
26
|
+
],
|
|
27
|
+
"whitelist_prefix": [
|
|
28
|
+
"ls", "cat", "head", "tail", "grep", "wc", "sort", "uniq", "du", "df",
|
|
29
|
+
"file", "stat", "echo", "git status", "git diff", "git log",
|
|
30
|
+
"git show", "git fetch", "git remote", "git stash", "git branch",
|
|
31
|
+
"git tag", "git shortlog", "git blame", "python3 -m pytest", "pytest",
|
|
32
|
+
"python -m unittest", "npm test", "npm run lint", "make test",
|
|
33
|
+
],
|
|
34
|
+
"blacklist": [
|
|
35
|
+
["rm_system",
|
|
36
|
+
r'(^|[;&|]\s*)rm\s+(-\S+\s+)*(/(\s|$|\*)|\s*(~|\$HOME)(/\*|$)'
|
|
37
|
+
r'|/(etc|usr|var|boot|bin|sbin|lib|opt|root)(/|$))',
|
|
38
|
+
"rm 删除根目录/家目录/系统路径"],
|
|
39
|
+
["rm_dot",
|
|
40
|
+
r'(^|[;&|]\s*)rm\s+(-\S+\s+)*\.{1,2}(\s|$)',
|
|
41
|
+
"rm -rf . 或 ..(清空当前/上级目录)"],
|
|
42
|
+
["sudo_destructive",
|
|
43
|
+
r'(^|[;&|]\s*)sudo\s+(rm|dd|mkfs(\.[a-z0-9]+)?|fdisk|sfdisk|parted'
|
|
44
|
+
r'|wipefs|shutdown|reboot|poweroff|halt)\b',
|
|
45
|
+
"sudo 破坏性操作"],
|
|
46
|
+
["pipe_to_sh",
|
|
47
|
+
r'[^;&|]\s*\|\s*(sudo\s+)?(ba|da|k|z|c)?sh\b',
|
|
48
|
+
"命令管道喂给 shell(curl|sh 一类)"],
|
|
49
|
+
["curl_pipe_sh",
|
|
50
|
+
r'(^|[;&|]\s*)(curl|wget|fetch|lynx|w3m)\b[^;&|]*\|\s*(sudo\s+)?'
|
|
51
|
+
r'(ba|da|k|z|c)?sh\b',
|
|
52
|
+
"curl/wget 下载即执行"],
|
|
53
|
+
["git_force_push",
|
|
54
|
+
r'git\s+push\b[^;&|]*(--force(\s|$)|(\s|^)-f(\s|$))',
|
|
55
|
+
"git push --force / -f(--force-with-lease 走 L1)"],
|
|
56
|
+
["git_push_delete",
|
|
57
|
+
r'git\s+push\b[^;&|]*--delete\b',
|
|
58
|
+
"git push 删除远程分支"],
|
|
59
|
+
["find_root_delete",
|
|
60
|
+
r'(^|[;&|]\s*)find\s+/(\s|$)[^;&|]*-delete\b',
|
|
61
|
+
"find 从根目录 -delete"],
|
|
62
|
+
["find_system_delete",
|
|
63
|
+
r'find\s+/(etc|usr|var|boot|bin|sbin|lib|opt|root)\S*[^;&|]*-delete\b',
|
|
64
|
+
"find 系统路径 -delete"],
|
|
65
|
+
["find_exec_rm",
|
|
66
|
+
r'find\b[^;&|]*-exec\s+(sudo\s+)?rm\b',
|
|
67
|
+
"find -exec rm"],
|
|
68
|
+
["xargs_rm",
|
|
69
|
+
r'xargs\b[^;&|]*\brm\b',
|
|
70
|
+
"xargs 接 rm"],
|
|
71
|
+
["dd_write_dev",
|
|
72
|
+
r'\bdd\b[^;&|]*\bof=/dev/(sd|hd|vd|nvme|mmcblk|dm-|md|loop|disk)',
|
|
73
|
+
"dd 写块设备"],
|
|
74
|
+
["mkfs",
|
|
75
|
+
r'(^|[;&|]\s*)(mkfs(\.[a-z0-9]+)?|fdisk|sfdisk|parted|wipefs)\b',
|
|
76
|
+
"格式化/分区工具"],
|
|
77
|
+
["chmod777",
|
|
78
|
+
r'chmod\s+(-R\s+|--recursive\s+)*77[0-7]\s',
|
|
79
|
+
"chmod 777(含 -R)"],
|
|
80
|
+
["chown_system",
|
|
81
|
+
r'chown\s+(-\S+\s+)*\S+\s+/(etc|usr|var|boot|bin|sbin|lib|opt|root)\S*',
|
|
82
|
+
"chown 系统路径"],
|
|
83
|
+
["fork_bomb",
|
|
84
|
+
r':\s*\(\s*\)\s*\{',
|
|
85
|
+
"fork bomb :(){ :|:& };:"],
|
|
86
|
+
["fork_bomb_win",
|
|
87
|
+
r'%0\s*\|\s*%0',
|
|
88
|
+
"fork bomb %0|%0"],
|
|
89
|
+
["shutdown",
|
|
90
|
+
r'(^|[;&|]\s*)(shutdown|reboot|poweroff|halt)\b',
|
|
91
|
+
"关机/重启"],
|
|
92
|
+
["crontab_r",
|
|
93
|
+
r'crontab\s+-\S*r\b',
|
|
94
|
+
"crontab -r 清空计划任务"],
|
|
95
|
+
["write_redirect_system",
|
|
96
|
+
r'\s>>?\s*/(etc|usr|var|boot|bin|sbin|lib|opt|root)\S*',
|
|
97
|
+
"重定向覆写系统文件"],
|
|
98
|
+
["eval_curl",
|
|
99
|
+
r"eval\s+[\"'\`]?\s*\$?\s*\(\s*(curl|wget)\b",
|
|
100
|
+
"eval $(curl ...)"],
|
|
101
|
+
["source_curl",
|
|
102
|
+
r'(source|\.)\s+<\(\s*(curl|wget)\b',
|
|
103
|
+
"source <(curl ...)"],
|
|
104
|
+
["sh_c_curl",
|
|
105
|
+
r"(ba)?sh\s+-c\s+[\"']\s*(curl|wget)\b",
|
|
106
|
+
"sh -c 'curl ...'"],
|
|
107
|
+
["mv_system",
|
|
108
|
+
r'mv\s+(-\S+\s+)*\S+\s+/(etc|usr|var|boot|bin|sbin|lib|opt|root)\S*',
|
|
109
|
+
"mv 进系统路径"],
|
|
110
|
+
],
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def normalize(cmd):
|
|
115
|
+
return re.sub(r"\s+", " ", cmd.strip())
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_table(path=None):
|
|
119
|
+
"""加载 L0 表:缺文件 → 内置默认表;文件各节覆盖内置对应节。"""
|
|
120
|
+
table = {
|
|
121
|
+
"whitelist": set(DEFAULT_L0["whitelist"]),
|
|
122
|
+
"whitelist_prefix": list(DEFAULT_L0["whitelist_prefix"]),
|
|
123
|
+
"blacklist": [list(x) for x in DEFAULT_L0["blacklist"]],
|
|
124
|
+
}
|
|
125
|
+
if not path:
|
|
126
|
+
return table
|
|
127
|
+
try:
|
|
128
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
129
|
+
data = json.load(fh)
|
|
130
|
+
except FileNotFoundError:
|
|
131
|
+
raise ValueError("L0 表文件不存在:%s" % path)
|
|
132
|
+
except OSError as exc:
|
|
133
|
+
raise ValueError("无法读取 L0 表 %s:%s" % (path, exc))
|
|
134
|
+
except ValueError as exc:
|
|
135
|
+
raise ValueError("L0 表 %s 不是合法 JSON:%s" % (path, exc))
|
|
136
|
+
if not isinstance(data, dict):
|
|
137
|
+
raise ValueError("L0 表 %s 必须是 JSON 对象" % path)
|
|
138
|
+
if isinstance(data.get("whitelist"), list):
|
|
139
|
+
table["whitelist"] = set(str(x) for x in data["whitelist"])
|
|
140
|
+
if isinstance(data.get("whitelist_prefix"), list):
|
|
141
|
+
table["whitelist_prefix"] = [str(x) for x in data["whitelist_prefix"]]
|
|
142
|
+
if isinstance(data.get("blacklist"), list):
|
|
143
|
+
entries = []
|
|
144
|
+
for item in data["blacklist"]:
|
|
145
|
+
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
146
|
+
entries.append([str(item[0]), str(item[1]),
|
|
147
|
+
str(item[2]) if len(item) > 2 else str(item[1])])
|
|
148
|
+
if entries:
|
|
149
|
+
table["blacklist"] = entries
|
|
150
|
+
return table
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def l0_judge(cmd, table):
|
|
154
|
+
"""L0 判定:返回 (decision, reason)。decision ∈ {allow, deny, None}。"""
|
|
155
|
+
cmd = normalize(cmd)
|
|
156
|
+
if not cmd:
|
|
157
|
+
return "deny", "空命令"
|
|
158
|
+
for name, pattern, note in table["blacklist"]:
|
|
159
|
+
try:
|
|
160
|
+
if re.search(pattern, cmd, re.IGNORECASE):
|
|
161
|
+
return "deny", "L0 黑名单 %s:%s" % (name, note)
|
|
162
|
+
except re.error as exc:
|
|
163
|
+
# 表内坏正则:跳过并提示,不误杀
|
|
164
|
+
import sys
|
|
165
|
+
sys.stderr.write("phocinae: 忽略坏 L0 正则 %s(%s):%s\n"
|
|
166
|
+
% (name, pattern, exc))
|
|
167
|
+
continue
|
|
168
|
+
if cmd in table["whitelist"]:
|
|
169
|
+
return "allow", "L0 白名单精确命中"
|
|
170
|
+
for prefix in sorted(table["whitelist_prefix"], key=len, reverse=True):
|
|
171
|
+
if cmd == prefix or cmd.startswith(prefix + " "):
|
|
172
|
+
return "allow", "L0 白名单前缀命中:%s" % prefix
|
|
173
|
+
return None, "L0 不能定夺"
|
phocinae_cli/output.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""output.py —— 输出层:rich 表格 / JSON,统一退出码与错误呈现。
|
|
2
|
+
|
|
3
|
+
退出码约定(全命令):
|
|
4
|
+
0 = 成功(gate: allow) 1 = 错误(网络/协议/用法)
|
|
5
|
+
2 = gate deny 3 = gate ask(需人工确认)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
EXIT_OK = 0
|
|
15
|
+
EXIT_ERROR = 1
|
|
16
|
+
EXIT_DENY = 2
|
|
17
|
+
EXIT_ASK = 3
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def print_json(data):
|
|
23
|
+
console.print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fail(msg, hint=None, exit_code=EXIT_ERROR):
|
|
27
|
+
"""错误输出到 stderr 并退出(错误=1)。"""
|
|
28
|
+
sys.stderr.write("phocinae: 错误:%s\n" % msg)
|
|
29
|
+
if hint:
|
|
30
|
+
sys.stderr.write("%s\n" % hint)
|
|
31
|
+
raise SystemExit(exit_code)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def deny_exit(msg, layer="input"):
|
|
35
|
+
"""以 gate 的 deny 语义收场(exit 2)。
|
|
36
|
+
|
|
37
|
+
用于「拿不到可判定命令」这类异常输入:gate 是判决接口,异常输入必须
|
|
38
|
+
落到 deny 而不是 allow,也不能与「用法错误」的 exit 1 混为一谈——
|
|
39
|
+
集成方按退出码判读时,非零即不放行,且 2 明确表示「判定为 deny」。
|
|
40
|
+
"""
|
|
41
|
+
sys.stderr.write("phocinae: deny(%s):%s\n" % (layer, msg))
|
|
42
|
+
raise SystemExit(EXIT_DENY)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def answers_table(body, questions, title=None, show_option_scores=False):
|
|
47
|
+
"""渲染 /v1/systemone 响应:主答案表 + usage/routing 页脚。
|
|
48
|
+
|
|
49
|
+
答案值格式化:noul→True/False;choice→"i = 选项文本";score→"7/10"。
|
|
50
|
+
"""
|
|
51
|
+
answers = body.get("answers", {})
|
|
52
|
+
confs = body.get("answer_confidence", {})
|
|
53
|
+
actions = body.get("action", {})
|
|
54
|
+
table = Table(title=title, title_justify="left", header_style="bold cyan")
|
|
55
|
+
table.add_column("question", style="bold")
|
|
56
|
+
table.add_column("type", style="dim")
|
|
57
|
+
table.add_column("answer", style="bold green")
|
|
58
|
+
table.add_column("confidence", justify="right", style="yellow")
|
|
59
|
+
for q in questions:
|
|
60
|
+
qid = q["id"]
|
|
61
|
+
raw = answers.get(qid)
|
|
62
|
+
conf = confs.get(qid)
|
|
63
|
+
act = actions.get(qid) or {}
|
|
64
|
+
if q["type"] == "choice":
|
|
65
|
+
idx = raw if isinstance(raw, int) else None
|
|
66
|
+
if idx is not None and 0 <= idx < len(q["options"]):
|
|
67
|
+
ans = "%d = %s" % (idx, q["options"][idx])
|
|
68
|
+
else:
|
|
69
|
+
ans = str(raw)
|
|
70
|
+
elif q["type"] == "score":
|
|
71
|
+
ans = "%s/10" % raw if raw is not None else str(raw)
|
|
72
|
+
else:
|
|
73
|
+
ans = str(raw)
|
|
74
|
+
table.add_row(
|
|
75
|
+
qid, q["type"], ans,
|
|
76
|
+
"%.4f" % conf if isinstance(conf, (int, float)) else str(conf or "-"),
|
|
77
|
+
)
|
|
78
|
+
if show_option_scores:
|
|
79
|
+
table.add_row(qid + " →option_scores",
|
|
80
|
+
"", _fmt_option_scores(body, qid), "")
|
|
81
|
+
console.print(table)
|
|
82
|
+
usage = body.get("usage") or {}
|
|
83
|
+
routing = body.get("routing") or {}
|
|
84
|
+
footer = []
|
|
85
|
+
if usage:
|
|
86
|
+
footer.append("usage: in=%s out=%s" % (usage.get("input_tokens"),
|
|
87
|
+
usage.get("output_tokens")))
|
|
88
|
+
if routing:
|
|
89
|
+
footer.append("routing: device=%s perm=%s backend=%s"
|
|
90
|
+
% (routing.get("device"), routing.get("perm"),
|
|
91
|
+
routing.get("backend")))
|
|
92
|
+
if body.get("model"):
|
|
93
|
+
footer.append("model: %s" % body["model"])
|
|
94
|
+
if footer:
|
|
95
|
+
console.print(" " + " · ".join(footer), style="dim")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _fmt_option_scores(body, qid):
|
|
99
|
+
scores = (body.get("option_scores") or {}).get(qid)
|
|
100
|
+
if not scores:
|
|
101
|
+
return "-"
|
|
102
|
+
return ", ".join("%.4f" % float(v) for v in scores)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def scores_table(qid, option_scores, title=None, labels=None):
|
|
106
|
+
"""选项概率表(classify/route/score 共用)。labels 给选项文本。"""
|
|
107
|
+
table = Table(title=title, title_justify="left", header_style="bold cyan")
|
|
108
|
+
table.add_column("#", justify="right", style="dim")
|
|
109
|
+
table.add_column("option")
|
|
110
|
+
table.add_column("probability", justify="right", style="yellow")
|
|
111
|
+
for i, v in enumerate(option_scores or []):
|
|
112
|
+
label = labels[i] if labels and i < len(labels) else str(i)
|
|
113
|
+
table.add_row(str(i), str(label), "%.4f" % float(v))
|
|
114
|
+
console.print(table)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def kv_table(rows, title=None):
|
|
118
|
+
"""键值表(ping 用)。rows = [(key, value), ...]"""
|
|
119
|
+
table = Table(title=title, title_justify="left", show_header=False,
|
|
120
|
+
header_style="bold cyan")
|
|
121
|
+
table.add_column("key", style="bold", no_wrap=True)
|
|
122
|
+
table.add_column("value")
|
|
123
|
+
for k, v in rows:
|
|
124
|
+
table.add_row(str(k), str(v))
|
|
125
|
+
console.print(table)
|
phocinae_cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""questions.py —— 题目 DSL 解析与校验。
|
|
2
|
+
|
|
3
|
+
命令行紧凑语法(可与 --questions-json 混用):
|
|
4
|
+
--noul ok[:0.65] noul 题(可选 threshold ∈ [0,1],默认 0.5)
|
|
5
|
+
--choice act:allow,ask,deny choice 题(逗号分隔选项)
|
|
6
|
+
--score risk score 题(答案 2–10)
|
|
7
|
+
|
|
8
|
+
协议约束(与 phocinae-server 一致):
|
|
9
|
+
id 非空且唯一;1–64 题;choice 必带 1–255 个非空选项;score 禁带 options;
|
|
10
|
+
noul 的 threshold 仅在 [0,1]。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
from .client import MAX_OPTIONS, MAX_QUESTIONS, PhocinaeError
|
|
16
|
+
|
|
17
|
+
VALID_TYPES = ("noul", "choice", "score")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class QuestionSpecError(PhocinaeError):
|
|
21
|
+
"""题目参数错误(用法错误,exit 1)。"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _split_options(text):
|
|
25
|
+
parts = [p.strip() for p in text.split(",")]
|
|
26
|
+
parts = [p for p in parts if p]
|
|
27
|
+
return parts
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_specs(noul_specs=None, choice_specs=None, score_specs=None,
|
|
31
|
+
questions_json=None):
|
|
32
|
+
"""把命令行题目参数合并为 /v1/systemone 的 questions 列表。
|
|
33
|
+
|
|
34
|
+
noul_specs : ["id", "id:0.65", ...]
|
|
35
|
+
choice_specs : ["id:opt1,opt2", ...]
|
|
36
|
+
score_specs : ["id", ...]
|
|
37
|
+
questions_json: 内联 JSON 数组 / JSON 文件路径(完整规格,逐个校验)
|
|
38
|
+
"""
|
|
39
|
+
questions = []
|
|
40
|
+
|
|
41
|
+
for spec in (noul_specs or []):
|
|
42
|
+
qid, _, thr = spec.partition(":")
|
|
43
|
+
qid = qid.strip()
|
|
44
|
+
if not qid:
|
|
45
|
+
raise QuestionSpecError("--noul 语法:ID[:threshold],得到 %r" % spec)
|
|
46
|
+
q = {"id": qid, "type": "noul"}
|
|
47
|
+
if thr.strip():
|
|
48
|
+
try:
|
|
49
|
+
t = float(thr)
|
|
50
|
+
except ValueError:
|
|
51
|
+
raise QuestionSpecError("--noul %s 的 threshold 不是数字" % spec)
|
|
52
|
+
if not 0.0 <= t <= 1.0:
|
|
53
|
+
raise QuestionSpecError("--noul %s 的 threshold 须在 [0,1]" % spec)
|
|
54
|
+
q["threshold"] = t
|
|
55
|
+
questions.append(q)
|
|
56
|
+
|
|
57
|
+
for spec in (choice_specs or []):
|
|
58
|
+
qid, sep, opts = spec.partition(":")
|
|
59
|
+
qid = qid.strip()
|
|
60
|
+
if not sep or not qid:
|
|
61
|
+
raise QuestionSpecError("--choice 语法:ID:选项1,选项2,得到 %r" % spec)
|
|
62
|
+
options = _split_options(opts)
|
|
63
|
+
if not options:
|
|
64
|
+
raise QuestionSpecError("--choice %s 没有选项" % spec)
|
|
65
|
+
questions.append({"id": qid, "type": "choice", "options": options})
|
|
66
|
+
|
|
67
|
+
for spec in (score_specs or []):
|
|
68
|
+
qid = spec.strip()
|
|
69
|
+
if not qid:
|
|
70
|
+
raise QuestionSpecError("--score 语法:ID,得到 %r" % spec)
|
|
71
|
+
questions.append({"id": qid, "type": "score"})
|
|
72
|
+
|
|
73
|
+
if questions_json:
|
|
74
|
+
raw = questions_json
|
|
75
|
+
if raw.strip().startswith("[") or raw.strip().startswith("{"):
|
|
76
|
+
data = raw
|
|
77
|
+
else: # 文件路径
|
|
78
|
+
try:
|
|
79
|
+
with open(raw, "r", encoding="utf-8") as fh:
|
|
80
|
+
data = fh.read()
|
|
81
|
+
except OSError as exc:
|
|
82
|
+
raise QuestionSpecError("无法读取 --questions-json 文件 %s:%s"
|
|
83
|
+
% (raw, exc))
|
|
84
|
+
try:
|
|
85
|
+
extra = json.loads(data)
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
raise QuestionSpecError("--questions-json 不是合法 JSON:%s" % exc)
|
|
88
|
+
if not isinstance(extra, list):
|
|
89
|
+
raise QuestionSpecError("--questions-json 必须是 JSON 数组")
|
|
90
|
+
questions.extend(extra)
|
|
91
|
+
|
|
92
|
+
validate(questions)
|
|
93
|
+
return questions
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def validate(questions):
|
|
97
|
+
"""按 phocinae-server 协议校验 questions 列表(422 口径前置)。"""
|
|
98
|
+
if not questions:
|
|
99
|
+
raise QuestionSpecError(
|
|
100
|
+
"至少需要一题:--noul / --choice / --score / --questions-json")
|
|
101
|
+
if len(questions) > MAX_QUESTIONS:
|
|
102
|
+
raise QuestionSpecError("题目数 %d 超过上限 %d" % (len(questions), MAX_QUESTIONS))
|
|
103
|
+
seen = set()
|
|
104
|
+
for q in questions:
|
|
105
|
+
if not isinstance(q, dict):
|
|
106
|
+
raise QuestionSpecError("题目必须是 JSON 对象,得到 %r" % (q,))
|
|
107
|
+
qid = q.get("id")
|
|
108
|
+
qtype = q.get("type")
|
|
109
|
+
if not isinstance(qid, str) or not qid:
|
|
110
|
+
raise QuestionSpecError("题目 id 必须是非空字符串")
|
|
111
|
+
if qid in seen:
|
|
112
|
+
raise QuestionSpecError("题目 id 重复:%r" % qid)
|
|
113
|
+
seen.add(qid)
|
|
114
|
+
if qtype not in VALID_TYPES:
|
|
115
|
+
raise QuestionSpecError("题目 %r 的 type 必须是 noul/choice/score,"
|
|
116
|
+
"得到 %r" % (qid, qtype))
|
|
117
|
+
if qtype == "choice":
|
|
118
|
+
opts = q.get("options")
|
|
119
|
+
if not isinstance(opts, list) or not opts:
|
|
120
|
+
raise QuestionSpecError("choice 题 %r 必须带非空 options" % qid)
|
|
121
|
+
if len(opts) > MAX_OPTIONS:
|
|
122
|
+
raise QuestionSpecError("choice 题 %r 选项数 %d 超过上限 %d"
|
|
123
|
+
% (qid, len(opts), MAX_OPTIONS))
|
|
124
|
+
if any(not isinstance(o, str) or not o for o in opts):
|
|
125
|
+
raise QuestionSpecError("choice 题 %r 的选项必须是非空字符串" % qid)
|
|
126
|
+
if qtype == "score" and q.get("options") is not None:
|
|
127
|
+
raise QuestionSpecError("score 题 %r 不允许带 options" % qid)
|
|
128
|
+
if qtype == "noul":
|
|
129
|
+
thr = q.get("threshold")
|
|
130
|
+
if thr is not None:
|
|
131
|
+
try:
|
|
132
|
+
t = float(thr)
|
|
133
|
+
except (TypeError, ValueError):
|
|
134
|
+
raise QuestionSpecError("noul 题 %r 的 threshold 不是数字" % qid)
|
|
135
|
+
if not 0.0 <= t <= 1.0:
|
|
136
|
+
raise QuestionSpecError("noul 题 %r 的 threshold 须在 [0,1]"
|
|
137
|
+
% qid)
|
|
138
|
+
return questions
|