studycn 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT.md +2 -2
- package/OPERATIONS.md +36 -0
- package/README.md +7 -7
- package/package.json +2 -2
- package/question-format.mjs +11 -9
- package/question.schema.json +13 -5
- package/study.mjs +43 -7
package/AGENT.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
使用同目录 `study.mjs`,Node.js 22+。读取 `README.md` 后操作;服务与凭证从 `STUDY_URL` / `STUDY_TOKEN` 或 `--token-file` 提供,不把凭证写入题目、日志或对话。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
先whoami、banks、capabilities,只操作授权范围。六题型支持文字、图片、视频。题干content/mediaIds、标准答案referenceAnswer/answerMediaIds、解析explanation/explanationMediaIds、选项options[].mediaIds组合使用,素材先upload,用真实返回id引用,不保存临时URL。填空与选择题仍需可机读答案;主观题rubric必填并人工评阅,编程不执行代码。
|
|
6
6
|
|
|
7
7
|
先本地 validate,再服务端 validate 或 import --dry-run;新建最多100道一批。写入采用固定 --request-id,每个独立操作新标识,同一操作重试复用。
|
|
8
8
|
|
|
@@ -10,4 +10,4 @@
|
|
|
10
10
|
|
|
11
11
|
检查退出码与 JSON;0成功,1校验/参数错误,2服务/网络错误。业务命令不依赖自然语言解析。遇到网络错误按输出的 idempotencyKey 重试。写入后 get 核验。
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
CLI 1.2的call覆盖各业务模块,参见OPERATIONS.md。账号角色、部门范围、对象所有权与凭证模块权限均需满足。只执行用户明确要求的业务动作;自动出题不包含发布考试、发布成绩、权限变更、发送催办。不要把题库写入成功表述为考试或成绩已发布。通用幂等重试仅返回completed/replayed,随后查询核对当前对象。
|
package/OPERATIONS.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# CLI 1.2 业务操作
|
|
2
|
+
|
|
3
|
+
`study capabilities`返回模块权限表,`study whoami`返回当前有效授权。
|
|
4
|
+
|
|
5
|
+
`study call --path PATH --method GET|POST|DELETE --file 参数.json --request-id 固定标识`调用与网页相同的业务操作。GET默认,可省略file和请求标识。路径不带`/api/`或前导斜线。
|
|
6
|
+
|
|
7
|
+
| 模块 | 路径 | 操作权限 |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| 项目/部门 | projects / departments;删除 /ID | organization:read/write |
|
|
10
|
+
| 人员 | people;people/move;people/delete | people:read/write |
|
|
11
|
+
| 题库 | banks;删除 banks/ID | questions:read / banks:write |
|
|
12
|
+
| 试卷 | papers;删除 /ID | papers:read/write |
|
|
13
|
+
| 考试 | exams;exams/ID/publish、copy、void、finish、remind | exams:read/write |
|
|
14
|
+
| 名单 | enrollments/考试ID | exams:read / enrollments:write |
|
|
15
|
+
| 答卷 | attempts/答卷ID;/save、submit、terminate、attachments | 本人 self:read/write,管理 grades:read / exams:write |
|
|
16
|
+
| 成绩 | grades;grades/ID/adjust、confirm、publish | grades:read/write |
|
|
17
|
+
| 复核 | reviews | reviews:read/write;本人申请 self:write |
|
|
18
|
+
| 导出 | export/results、analysis、grades、attempts、exams、enrollments | exports:read + grades:read |
|
|
19
|
+
| 题库导出 | export/questions?bankId=ID | exports:read + questions:read |
|
|
20
|
+
| 人员导出 | export/people | exports:read + people:read |
|
|
21
|
+
| 模板 | templates/questions、people、enrollments | exports:read |
|
|
22
|
+
| Excel | imports/preview;imports/commit | questions:write / people:write / enrollments:write,名单导入同时需people:write |
|
|
23
|
+
| 本人 | personal;account;notices/ID | self:read/write |
|
|
24
|
+
| 配置 | settings | settings:read/write |
|
|
25
|
+
| 任务 | jobs;jobs/ai;jobs/ID | jobs:read/write |
|
|
26
|
+
| 凭证 | agent-keys;删除 /ID | credentials:write,不得扩大当前凭证范围 |
|
|
27
|
+
|
|
28
|
+
示例:`study call --path projects`;`study call --path departments --method POST --file department.json --request-id department-001`;`study call --path "export/results?examId=实际ID" --out results.xlsx`。
|
|
29
|
+
|
|
30
|
+
固定试卷JSON:`{"name":"试卷","description":"","mode":"fixed","bankIds":["实际题库ID"],"questionIds":["实际题目ID"],"mandatoryIds":[],"quotas":[],"shuffleQuestions":false,"shuffleOptions":false,"allowCorrectSupplement":false,"wrongRatio":0}`。保存已有对象保留id和version;逐题附件通过questionSettings设置。
|
|
31
|
+
|
|
32
|
+
Excel预览参数 `{"type":"questions","targetId":"实际题库ID"}`,`study call --path imports/preview --method POST --file import.json --upload questions.xlsx --request-id preview-001`。提交参数保留相同type、targetId和预览返回token,再调用imports/commit。
|
|
33
|
+
|
|
34
|
+
通用请求返回`result`;文件下载用`--out`;媒体附件GET可返回签名URL。写入在请求级事务内执行,与幂等标识一起提交。重试返回completed与replayed,随后查询当前对象,不返还旧数据。逻辑删除保留历史答卷、成绩及日志。
|
|
35
|
+
|
|
36
|
+
技能ZIP内附首次授权与持久化脚本。首次连接验证后在本人私密目录保存凭证,不调用钥匙串,不保存网页登录密码。豆包宿主需提供Node22+、终端工具、联网与持久化私密目录。
|
package/README.md
CHANGED
|
@@ -8,15 +8,15 @@ API:`https://study.aimianshi.online/api/agent/v1`
|
|
|
8
8
|
|
|
9
9
|
题库管理 → 管理题目 → 新建/编辑。题干与解析使用可视化编辑器,支持标题、加粗、斜体、列表、引用、表格;可切换 Markdown。右侧实时预览正确答案、选项图片与解析。知识点和标签可手动填,也可由 Agent 在 JSON 中提供,系统不会自动猜测。
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
题干、标准答案、解析和每个选项均可组合文字、图片、视频及附件,单文件最大100MB。图片支持PNG/JPG/WebP/GIF,视频支持MP4/WebM/MOV,推荐浏览器通用的H.264/AAC MP4。JSON使用上传返回的编号:题干`mediaIds`、答案`answerMediaIds`、解析`explanationMediaIds`、选项`options[].mediaIds`,兼容图片`imageId`。Excel填写已上传完整文件名,对应“题目素材”“答案素材”“解析素材”“选项素材A–L”,多个用分号分隔。
|
|
12
12
|
|
|
13
13
|
“Agent JSON”可粘贴结构化题目、校验并应用到编辑器,也可导出 JSON。网页、CLI、Agent API 使用同一 JSON 校验器。草稿、Markdown 模式与页面位置在当前浏览器刷新后保留;关闭弹窗也保留草稿,保存成功清除。退出账号时清除该账号浏览器草稿。
|
|
14
14
|
|
|
15
15
|
## 凭证与权限
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
题库管理、系统管理或账号设置 → Agent 接入。有效期30/90/365天,按业务模块选择权限;题库操作须指定题库。题库查询包含标准答案与解析,只应授权维护人员。每人可授权本人操作;当前角色允许的操作才可勾选。
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
完整凭证仅展示一次,服务器仅存SHA-256摘要。可撤销,过期、停用或当前权限变化即时生效。CLI 1.2支持人员、项目部门、试卷考试、名单、答卷、评分复核、配置、后台任务和导出,各模块分别授权。账号密码与邮箱验证码在网页处理。操作仍受账号角色、部门范围、题库范围、对象所有权限制。所有Agent写入记录操作者、凭证名称与请求编号。
|
|
20
20
|
|
|
21
21
|
HTTP 身份:`Authorization: Bearer <访问凭证>`。网页 Session 不适用于 Agent API。没有自动把 AI 服务配置或其密钥交给 Agent。
|
|
22
22
|
|
|
@@ -52,7 +52,7 @@ study whoami
|
|
|
52
52
|
|
|
53
53
|
Mac/Linux 默认安装目录为 `~/.local/share/study`,命令入口为 `~/.local/bin/study`,环境文件为 `~/.local/share/study/env`。安装脚本向当前用户的 `.zshrc` 或 `.bashrc` 追加环境加载行;Bash 同时维护 `.bash_profile`。Windows 默认安装目录为 `%LOCALAPPDATA%\StudyCLI`,命令入口为该目录下的 `bin\study.cmd`,同时更新当前终端和用户 PATH。Windows ARM64/x64、Mac ARM64/x64、Linux ARM64/x64 由安装脚本识别。
|
|
54
54
|
|
|
55
|
-
Mac/Linux
|
|
55
|
+
Mac/Linux可用`STUDY_INSTALL_DIR`、`STUDY_BIN_DIR`自定义目录;`STUDY_NO_PROFILE=1`禁止修改终端配置,此时自行加载env。当前官网与Skill内置CLI版本1.2.0,不调用系统钥匙串。Windows脚本尚未实机验证。npm版本以实际发布记录为准;官网安装脚本始终下载本站完整包。
|
|
56
56
|
|
|
57
57
|
已有 Node.js 22+ 也可使用 npm:
|
|
58
58
|
|
|
@@ -108,7 +108,7 @@ study resource --id '素材编号' --out option.png
|
|
|
108
108
|
| status | `active` / `disabled`,默认 active |
|
|
109
109
|
| id / version | 更新时必须保留读取到的编号与原版本;新建不传 |
|
|
110
110
|
|
|
111
|
-
填空、简答、编程题的
|
|
111
|
+
填空、简答、编程题的options和correct为空。选择题正确选项与填空题可接受答案继续采用可机读格式;可追加答案媒体。主观参考答案可用文字或媒体,评分标准仍须文字。答题者附件由试卷questionSettings逐题开启,默认关闭;最多5个,图片视频各100MB,其他附件20MB。交卷后锁定;操作答卷及成绩须另行授权,评分遵循“改分→确认→再次发布”。
|
|
112
112
|
|
|
113
113
|
答卷采用 `{selected:[], text:"", blanks:[], attachmentIds:[]}`,仍兼容旧选择题数组。简答、编程成绩带逐题待评分状态,全部人工评分后才可确认,再发布。AI 仅提供复核意见。Excel 模板最后三列为“填空答案”“参考答案”“评分标准”,示例覆盖六种题型。
|
|
114
114
|
|
|
@@ -140,14 +140,14 @@ JSON Schema用于形状、范围和必填检查;引用存在、两位小数、
|
|
|
140
140
|
|
|
141
141
|
写入题目返回 `{items,count,requestId,replayed}`。POST/PUT 写入(含上传)必须有 `Idempotency-Key`,8–100 位字母、数字、下划线、点、冒号或短横线。相同凭证+标识+内容重试返回原结果、不重复入库,`replayed=true`;相同标识用于不同内容返回409。CLI自动生成标识,建议批次明确传 `--request-id` 以便跨进程重试。每个新操作使用新的标识。
|
|
142
142
|
|
|
143
|
-
JSON
|
|
143
|
+
JSON请求2MB,素材文件100MB、请求102MB。校验不等于写入成功,并发版本、重复题及授权变化在最终事务拦截。图片与视频放在私有TOS的Study专用目录,文字在数据库。浏览器调用`/api/resources/ID/access?attemptId=答卷ID`取得单文件5分钟签名URL,随后直接访问TOS,过期重新授权。TOS密钥不发给浏览器。答案和解析媒体在成绩正式发布且到达个人解析开放时间后开放,猜测编号不能提前访问。旧素材兼容本地存储。
|
|
144
144
|
|
|
145
145
|
401:缺少/失效凭证;403:权限不足;404:题目/素材不存在或不可访问;409:版本冲突、幂等标识冲突;400:格式或重复题;413:超大小;500:服务错误。401/403不能自动扩大权限,409须重读核对,禁止用新版本直接覆盖旧草稿。
|
|
146
146
|
|
|
147
147
|
## Agent 推荐步骤
|
|
148
148
|
|
|
149
149
|
1. `whoami` 与 `banks` 确认范围。
|
|
150
|
-
2.
|
|
150
|
+
2. 根据需求生成六种题型、答案、解析与知识点;图片视频先upload,用返回id填写对应媒体字段。
|
|
151
151
|
3. `validate` 本地校验,`validate --remote` 或 `import --dry-run` 校验服务端权限与引用。
|
|
152
152
|
4. 新建使用 create/import,固定 request-id;更新先 get,核对内容后保留 version 执行 update。
|
|
153
153
|
5. 再次 get/questions 核验。考试或成绩发布仍由网页正常人工流程完成。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "studycn",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "考试中心 Agent CLI",
|
|
5
5
|
"homepage": "https://study.aimianshi.online/",
|
|
6
6
|
"keywords": ["study", "exam", "cli", "agent", "question-bank"],
|
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
"bin": {"study": "./study.mjs"},
|
|
9
9
|
"engines": {"node": ">=22"},
|
|
10
10
|
"publishConfig": {"registry": "https://registry.npmjs.org/", "access": "public"},
|
|
11
|
-
"files": ["study.mjs", "question-format.mjs", "question.schema.json", "questions.example.json", "README.md", "AGENT.md"],
|
|
11
|
+
"files": ["study.mjs", "question-format.mjs", "question.schema.json", "questions.example.json", "README.md", "AGENT.md", "OPERATIONS.md"],
|
|
12
12
|
"license": "UNLICENSED"
|
|
13
13
|
}
|
package/question-format.mjs
CHANGED
|
@@ -23,7 +23,7 @@ const numeric = (value, path) => {
|
|
|
23
23
|
};
|
|
24
24
|
export function normalizeQuestion(input) {
|
|
25
25
|
if (!input || typeof input !== "object" || Array.isArray(input)) fail("$", "题目必须是 JSON 对象");
|
|
26
|
-
const allowed = ["id", "version", "bankId", "kind", "content", "options", "correct", "points", "explanation", "difficulty", "knowledge", "tags", "mediaIds", "status", "ownerId", "createdAt", "updatedAt", "blankAnswers", "referenceAnswer", "rubric"];
|
|
26
|
+
const allowed = ["id", "version", "bankId", "kind", "content", "options", "correct", "points", "explanation", "difficulty", "knowledge", "tags", "mediaIds", "answerMediaIds", "explanationMediaIds", "status", "ownerId", "createdAt", "updatedAt", "blankAnswers", "referenceAnswer", "rubric"];
|
|
27
27
|
for (const key of Object.keys(input)) if (!allowed.includes(key)) fail(key, `不支持字段 ${key}`);
|
|
28
28
|
const kind = input.kind;
|
|
29
29
|
if (!["single", "multiple", "boolean", "fill", "short", "coding"].includes(kind)) fail("kind", "支持 single 单选、multiple 多选、boolean 判断、fill 填空、short 简答、coding 编程");
|
|
@@ -45,7 +45,7 @@ export function normalizeQuestion(input) {
|
|
|
45
45
|
} else {
|
|
46
46
|
result.referenceAnswer = string(input.referenceAnswer ?? "", "referenceAnswer");
|
|
47
47
|
result.rubric = string(input.rubric ?? "", "rubric");
|
|
48
|
-
if (!result.referenceAnswer || !result.rubric) fail("referenceAnswer", "
|
|
48
|
+
if ((!result.referenceAnswer && !result.answerMediaIds.length) || !result.rubric) fail("referenceAnswer", "主观题须提供文字或媒体参考答案,并填写评分标准,逐题人工评阅");
|
|
49
49
|
}
|
|
50
50
|
return result;
|
|
51
51
|
}
|
|
@@ -53,18 +53,19 @@ export function normalizeQuestion(input) {
|
|
|
53
53
|
if (!Array.isArray(rawOptions) || rawOptions.length < 2 || rawOptions.length > 12) fail("options", "请填写 2–12 个选项");
|
|
54
54
|
const options = rawOptions.map((option, index) => {
|
|
55
55
|
if (!option || typeof option !== "object" || Array.isArray(option)) fail(`options[${index}]`, "选项必须是对象");
|
|
56
|
-
for (const key of Object.keys(option)) if (!["id", "text", "imageId", "score"].includes(key)) fail(`options[${index}].${key}`, "不支持此选项字段");
|
|
56
|
+
for (const key of Object.keys(option)) if (!["id", "text", "imageId", "mediaIds", "score"].includes(key)) fail(`options[${index}].${key}`, "不支持此选项字段");
|
|
57
57
|
const id = string(option.id ?? letters[index], `options[${index}].id`, 100);
|
|
58
58
|
const text = string(option.text ?? "", `options[${index}].text`, 4000);
|
|
59
59
|
const imageId = option.imageId ? string(option.imageId, `options[${index}].imageId`, 100) : undefined;
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
const mediaIds = list(option.mediaIds, `options[${index}].mediaIds`);
|
|
61
|
+
if (!id || (!text && !imageId && !mediaIds.length)) fail(`options[${index}]`, "每个选项须有编号,以及文字或已上传媒体");
|
|
62
|
+
return {id, text, ...(imageId ? {imageId} : {}), ...(mediaIds.length ? {mediaIds} : {}), ...(option.score !== undefined ? {score: numeric(option.score, `options[${index}].score`)} : {})};
|
|
62
63
|
});
|
|
63
64
|
if (new Set(options.map(o => o.id)).size !== options.length) fail("options", "选项编号不能重复");
|
|
64
65
|
const correct = list(input.correct, "correct");
|
|
65
66
|
if (correct.some(id => !options.some(o => o.id === id))) fail("correct", "正确答案必须引用选项 id");
|
|
66
67
|
if (kind === "multiple" ? correct.length < 2 : correct.length !== 1) fail("correct", kind === "multiple" ? "多选至少两个正确项" : "单选和判断须选择一个正确项");
|
|
67
|
-
if (kind === "boolean" && (options.length !== 2 || options[0].text !== "正确" || options[1].text !== "错误"
|
|
68
|
+
if (kind === "boolean" && (options.length !== 2 || options[0].text !== "正确" || options[1].text !== "错误")) fail("options", "判断题固定两个选项:正确、错误");
|
|
68
69
|
const explicitScores = options.filter(o => correct.includes(o.id) && o.score !== undefined);
|
|
69
70
|
if (explicitScores.length && explicitScores.length !== correct.length) fail("options.score", "所有正确项须一起填分值,或全部不填以自动均分");
|
|
70
71
|
const scores = equalScores(points, correct.length);
|
|
@@ -74,13 +75,14 @@ export function normalizeQuestion(input) {
|
|
|
74
75
|
return commonQuestion(input, {kind, points, options: scoredOptions, correct});
|
|
75
76
|
}
|
|
76
77
|
function commonQuestion(input, base) {
|
|
77
|
-
const content = string(input.content, "content"), explanation = string(input.explanation, "explanation");
|
|
78
|
-
|
|
78
|
+
const content = string(input.content ?? "", "content"), explanation = string(input.explanation ?? "", "explanation");
|
|
79
|
+
const mediaIds = list(input.mediaIds, "mediaIds"), answerMediaIds = list(input.answerMediaIds, "answerMediaIds"), explanationMediaIds = list(input.explanationMediaIds, "explanationMediaIds");
|
|
80
|
+
if ((!content && !mediaIds.length) || (!explanation && !explanationMediaIds.length)) fail(!content && !mediaIds.length ? "content" : "explanation", "题干和解析各需提供文字或媒体");
|
|
79
81
|
const difficulty = input.difficulty ?? "medium", status = input.status ?? "active";
|
|
80
82
|
if (!["easy", "medium", "hard"].includes(difficulty)) fail("difficulty", "难度仅支持 easy / medium / hard");
|
|
81
83
|
if (!["active", "disabled"].includes(status)) fail("status", "状态仅支持 active / disabled");
|
|
82
84
|
const result = {bankId: string(input.bankId, "bankId", 100), ...base, content, explanation, difficulty,
|
|
83
|
-
knowledge: list(input.knowledge, "knowledge"), tags: list(input.tags, "tags"), mediaIds
|
|
85
|
+
knowledge: list(input.knowledge, "knowledge"), tags: list(input.tags, "tags"), mediaIds, answerMediaIds, explanationMediaIds, status};
|
|
84
86
|
if (!result.bankId) fail("bankId", "请选择所属题库");
|
|
85
87
|
if (input.id !== undefined) {
|
|
86
88
|
result.id = string(input.id, "id", 100);
|
package/question.schema.json
CHANGED
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
"title": "考试中心题目 v1",
|
|
5
5
|
"type": "object",
|
|
6
6
|
"additionalProperties": false,
|
|
7
|
-
"required": ["bankId", "kind"
|
|
7
|
+
"required": ["bankId", "kind"],
|
|
8
|
+
"$defs": {
|
|
9
|
+
"media": {"type": "array", "maxItems": 100, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 200}}
|
|
10
|
+
},
|
|
8
11
|
"properties": {
|
|
9
12
|
"id": {"type": "string", "minLength": 1, "maxLength": 100},
|
|
10
13
|
"version": {"type": "integer", "minimum": 1},
|
|
11
14
|
"bankId": {"type": "string", "minLength": 1, "maxLength": 100},
|
|
12
15
|
"kind": {"enum": ["single", "multiple", "boolean", "fill", "short", "coding"]},
|
|
13
|
-
"content": {"type": "string", "
|
|
16
|
+
"content": {"type": "string", "maxLength": 20000, "description": "题干,支持 Markdown、GFM 表格;文字或 mediaIds 至少一项"},
|
|
14
17
|
"options": {
|
|
15
18
|
"type": "array", "maxItems": 12,
|
|
16
19
|
"items": {
|
|
@@ -19,20 +22,23 @@
|
|
|
19
22
|
"id": {"type": "string", "minLength": 1, "maxLength": 100, "description": "省略时按顺序生成 A–L"},
|
|
20
23
|
"text": {"type": "string", "maxLength": 4000},
|
|
21
24
|
"imageId": {"type": "string", "maxLength": 100, "description": "上传接口返回的 id;仅图片"},
|
|
25
|
+
"mediaIds": {"$ref": "#/$defs/media", "description": "选项图片、视频及附件,使用上传后的编号"},
|
|
22
26
|
"score": {"type": "number", "minimum": 0, "multipleOf": 0.01, "description": "正确项一起填写或一起省略自动均分;错误项须为 0"}
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
},
|
|
26
30
|
"correct": {"type": "array", "maxItems": 12, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 200}, "description": "选择题引用 options.id;填空和主观题为空数组或省略"},
|
|
27
31
|
"blankAnswers": {"type": "array", "minItems": 1, "maxItems": 20, "items": {"type": "array", "minItems": 1, "maxItems": 20, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 2000}}, "description": "填空逐空可接受答案,与题干 {{1}}、{{2}} 顺序一致"},
|
|
28
|
-
"referenceAnswer": {"type": "string", "
|
|
32
|
+
"referenceAnswer": {"type": "string", "maxLength": 20000},
|
|
29
33
|
"rubric": {"type": "string", "minLength": 1, "maxLength": 20000, "description": "简答和编程题人工评分标准"},
|
|
30
34
|
"points": {"type": "number", "exclusiveMinimum": 0, "maximum": 10000, "multipleOf": 0.01, "default": 10},
|
|
31
|
-
"explanation": {"type": "string", "
|
|
35
|
+
"explanation": {"type": "string", "maxLength": 20000},
|
|
32
36
|
"difficulty": {"enum": ["easy", "medium", "hard"], "default": "medium"},
|
|
33
37
|
"knowledge": {"type": "array", "maxItems": 100, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 200}, "default": []},
|
|
34
38
|
"tags": {"type": "array", "maxItems": 100, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 200}, "default": []},
|
|
35
39
|
"mediaIds": {"type": "array", "maxItems": 100, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 200}, "default": []},
|
|
40
|
+
"answerMediaIds": {"$ref": "#/$defs/media", "description": "标准答案媒体,仅答案开放后向本人展示"},
|
|
41
|
+
"explanationMediaIds": {"$ref": "#/$defs/media", "description": "解析媒体,仅答案开放后向本人展示"},
|
|
36
42
|
"status": {"enum": ["active", "disabled"], "default": "active"},
|
|
37
43
|
"ownerId": {"type": "string", "description": "get 返回的只读字段,输入时忽略"},
|
|
38
44
|
"createdAt": {"type": "string", "description": "只读"},
|
|
@@ -40,13 +46,15 @@
|
|
|
40
46
|
},
|
|
41
47
|
"dependentRequired": {"id": ["version"], "version": ["id"]},
|
|
42
48
|
"allOf": [
|
|
49
|
+
{"anyOf": [{"required": ["content"], "properties": {"content": {"minLength": 1}}}, {"required": ["mediaIds"], "properties": {"mediaIds": {"minItems": 1}}}]},
|
|
50
|
+
{"anyOf": [{"required": ["explanation"], "properties": {"explanation": {"minLength": 1}}}, {"required": ["explanationMediaIds"], "properties": {"explanationMediaIds": {"minItems": 1}}}]},
|
|
43
51
|
{"if": {"properties": {"kind": {"enum": ["single", "multiple"]}}}, "then": {"required": ["options"], "properties": {"options": {"minItems": 2}}}},
|
|
44
52
|
{"if": {"properties": {"kind": {"enum": ["single", "multiple", "boolean"]}}}, "then": {"required": ["correct"], "properties": {"correct": {"minItems": 1}}}},
|
|
45
53
|
{"if": {"properties": {"kind": {"const": "multiple"}}}, "then": {"properties": {"correct": {"minItems": 2}}}},
|
|
46
54
|
{"if": {"properties": {"kind": {"enum": ["single", "boolean"]}}}, "then": {"properties": {"correct": {"maxItems": 1}}}},
|
|
47
55
|
{"if": {"properties": {"kind": {"const": "boolean"}}}, "then": {"properties": {"options": {"minItems": 2, "maxItems": 2}}}},
|
|
48
56
|
{"if": {"properties": {"kind": {"const": "fill"}}}, "then": {"required": ["blankAnswers"]}},
|
|
49
|
-
{"if": {"properties": {"kind": {"enum": ["short", "coding"]}}}, "then": {"required": ["referenceAnswer", "
|
|
57
|
+
{"if": {"properties": {"kind": {"enum": ["short", "coding"]}}}, "then": {"required": ["rubric"], "anyOf": [{"required": ["referenceAnswer"], "properties": {"referenceAnswer": {"minLength": 1}}}, {"required": ["answerMediaIds"], "properties": {"answerMediaIds": {"minItems": 1}}}]}},
|
|
50
58
|
{"if": {"properties": {"kind": {"enum": ["fill", "short", "coding"]}}}, "then": {"properties": {"options": {"maxItems": 0}, "correct": {"maxItems": 0}}}}
|
|
51
59
|
]
|
|
52
60
|
}
|
package/study.mjs
CHANGED
|
@@ -29,6 +29,13 @@ const help = `考试中心 CLI(Node.js 22+)
|
|
|
29
29
|
resources 列出可访问素材
|
|
30
30
|
upload --file FILE [--request-id KEY]
|
|
31
31
|
resource --id ID --out FILE 下载素材
|
|
32
|
+
access --id ID 获取素材短时访问链接
|
|
33
|
+
delete --id ID --request-id KEY 删除题目(保留历史答卷)
|
|
34
|
+
capabilities 查看业务操作与权限说明
|
|
35
|
+
call --path PATH [--method GET|POST|DELETE] [--file JSON]
|
|
36
|
+
[--upload FILE] [--out FILE] [--request-id KEY]
|
|
37
|
+
操作项目、部门、人员、试卷、考试、名单、
|
|
38
|
+
答卷、成绩、复核、配置、凭证、任务与导出
|
|
32
39
|
schema [--out FILE] 获取本地 JSON Schema
|
|
33
40
|
|
|
34
41
|
通用参数:--url URL、--token-file FILE、--help、--json。
|
|
@@ -38,7 +45,7 @@ const help = `考试中心 CLI(Node.js 22+)
|
|
|
38
45
|
`;
|
|
39
46
|
const output = value => process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
40
47
|
const error = (message, code = "CLI_ERROR", exit = 1) => {const e = new Error(message); e.code = code; e.exit = exit; throw e;};
|
|
41
|
-
const valueOptions = new Set(["url", "token-file", "request-id", "file", "id", "out", "bank", "kind", "status", "knowledge", "q", "limit", "offset"]);
|
|
48
|
+
const valueOptions = new Set(["url", "token-file", "request-id", "file", "id", "out", "bank", "kind", "status", "knowledge", "q", "limit", "offset", "path", "method", "upload"]);
|
|
42
49
|
const flags = new Set(["dry-run", "remote", "help", "json"]);
|
|
43
50
|
function parse(args) {
|
|
44
51
|
let command; const options = {};
|
|
@@ -66,13 +73,13 @@ async function readJson(filename) {
|
|
|
66
73
|
const requireId = value => {if (!value || typeof value !== "string") error("请使用 --id 指定编号");return encodeURIComponent(value);};
|
|
67
74
|
async function run() {
|
|
68
75
|
const {command, options} = parse(process.argv.slice(2));
|
|
69
|
-
if (command === "version") {output({name: "考试中心 CLI", version: "1.
|
|
70
|
-
if (command === "help" || options.help) {if(options.json) output({name: "考试中心 CLI", version: "1.
|
|
76
|
+
if (command === "version") {output({name: "考试中心 CLI", version: "1.2.0", node: process.versions.node});return;}
|
|
77
|
+
if (command === "help" || options.help) {if(options.json) output({name: "考试中心 CLI", version: "1.2.0", help}); else process.stdout.write(help);return;}
|
|
71
78
|
if (command === "schema") {
|
|
72
79
|
const value = JSON.parse(await fs.readFile(new URL("./question.schema.json", import.meta.url), "utf8"));
|
|
73
80
|
if (options.out) {await fs.writeFile(options.out, JSON.stringify(value, null, 2) + "\n");output({ok: true, file: path.resolve(options.out)});}else output(value);return;
|
|
74
81
|
}
|
|
75
|
-
if (!["whoami", "banks", "questions", "get", "validate", "create", "import", "update", "resources", "upload", "resource"].includes(command)) error("未知命令:" + command);
|
|
82
|
+
if (!["whoami", "banks", "questions", "get", "validate", "create", "import", "update", "resources", "upload", "resource", "access", "delete", "call", "capabilities"].includes(command)) error("未知命令:" + command);
|
|
76
83
|
if (options["dry-run"] && !["create", "import", "update"].includes(command)) error("--dry-run 仅支持 create / import / update;本命令未执行");
|
|
77
84
|
if (options.remote && command !== "validate") error("--remote 仅支持 validate;本命令未执行");
|
|
78
85
|
let items;
|
|
@@ -99,13 +106,32 @@ async function run() {
|
|
|
99
106
|
const headers = {"Authorization": "Bearer " + token};
|
|
100
107
|
if (body !== undefined) {headers["Idempotency-Key"] = requestId;if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";}
|
|
101
108
|
let response;
|
|
102
|
-
try {response = await fetch(new URL("/api/agent/v1/" + endpoint, base), {method, headers, body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body), redirect: "error", signal: AbortSignal.timeout(
|
|
109
|
+
try {response = await fetch(new URL("/api/agent/v1/" + endpoint, base), {method, headers, body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body), redirect: "error", signal: AbortSignal.timeout(180000)});}
|
|
103
110
|
catch {error("网络请求未完成;写入重试请复用 --request-id " + requestId, "NETWORK_ERROR", 2);}
|
|
104
111
|
if (binary && response.ok) return new Uint8Array(await response.arrayBuffer());
|
|
105
112
|
let value;try {value = await response.json();}catch {error("服务未返回有效 JSON(HTTP " + response.status + ")", "INVALID_RESPONSE", 2);}
|
|
106
113
|
if (!response.ok) {const e = new Error(value.error?.message || value.error || "请求失败");e.code = value.error?.code || "HTTP_ERROR";e.status = response.status;e.requestId = value.error?.requestId;e.idempotencyKey = requestId;e.exit = 2;throw e;}
|
|
107
114
|
return value;
|
|
108
115
|
}
|
|
116
|
+
if (command === "capabilities") return output(await request("capabilities"));
|
|
117
|
+
if (command === "call" || command === "delete") {
|
|
118
|
+
const method = command === "delete" ? "DELETE" : (options.method || "GET").toUpperCase();
|
|
119
|
+
const endpoint = command === "delete" ? "questions/" + requireId(options.id) : options.path;
|
|
120
|
+
if (!endpoint || !["GET", "POST", "DELETE"].includes(method)) error("call 须指定 --path 和有效 --method");
|
|
121
|
+
let body = options.file ? await readJson(options.file) : undefined;
|
|
122
|
+
let payload = {method, path: endpoint, ...(body ? {body} : {})};
|
|
123
|
+
if (options.upload) {
|
|
124
|
+
const stat = await fs.stat(options.upload);if(!stat.size || stat.size > 100 * 1024 * 1024) error("上传文件须为 1 字节至 100MB");
|
|
125
|
+
const form = new FormData();form.set("operation", JSON.stringify(payload));form.set("file", new File([await fs.readFile(options.upload)], path.basename(options.upload)));payload = form;
|
|
126
|
+
}
|
|
127
|
+
const response = await request("operations", "POST", payload);
|
|
128
|
+
if (options.out && response.result?.binary) {
|
|
129
|
+
const bytes = Buffer.from(response.result.binary, "base64");await fs.writeFile(options.out, bytes);return output({ok: true, file: path.resolve(options.out), size: bytes.length});
|
|
130
|
+
}
|
|
131
|
+
if (response.result?.binary && !options.out) error("下载或导出请指定 --out;操作已返回结果", "OUTPUT_REQUIRED");
|
|
132
|
+
if (options.out) {await fs.writeFile(options.out, JSON.stringify(response.result ?? response, null, 2) + "\n");return output({ok: true, file: path.resolve(options.out)});}
|
|
133
|
+
return output(response);
|
|
134
|
+
}
|
|
109
135
|
if (["whoami", "banks", "resources"].includes(command)) return output(await request(command === "whoami" ? "me" : command));
|
|
110
136
|
if (command === "questions") {
|
|
111
137
|
const params = new URLSearchParams();
|
|
@@ -126,13 +152,23 @@ async function run() {
|
|
|
126
152
|
if (command === "update") return output(await request("questions/" + requireId(items[0].id), "PUT", items[0]));
|
|
127
153
|
if (command === "upload") {
|
|
128
154
|
if(!options.file || options.file === "-") error("上传请指定真实文件");
|
|
129
|
-
const stat = await fs.stat(options.file);if(!stat.size || stat.size >
|
|
155
|
+
const stat = await fs.stat(options.file);if(!stat.size || stat.size > 100 * 1024 * 1024) error("上传文件须为 1 字节至 100MB");
|
|
130
156
|
const form = new FormData();form.set("file", new File([await fs.readFile(options.file)], path.basename(options.file)));
|
|
131
157
|
return output({...await request("resources", "POST", form), requestId});
|
|
132
158
|
}
|
|
159
|
+
if (command === "access") return output(await request("resources/" + requireId(options.id) + "/access"));
|
|
133
160
|
if (command === "resource") {
|
|
134
161
|
if(!options.out) error("下载请使用 --out 指定保存路径");
|
|
135
|
-
const
|
|
162
|
+
const access = await request("resources/" + requireId(options.id) + "/access");
|
|
163
|
+
let bytes;
|
|
164
|
+
if (access.url.startsWith("/")) bytes = await request("resources/" + requireId(options.id), "GET", undefined, true);
|
|
165
|
+
else {
|
|
166
|
+
const url = new URL(access.url);
|
|
167
|
+
if (url.protocol !== "https:" || url.username || url.password || !url.hostname.endsWith(".volces.com")) error("媒体访问地址不符合预期", "INVALID_MEDIA_URL", 2);
|
|
168
|
+
const response = await fetch(url, {redirect: "error", signal: AbortSignal.timeout(180000)});
|
|
169
|
+
if (!response.ok) error("媒体下载失败,请重新获取授权链接", "MEDIA_ERROR", 2);
|
|
170
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
171
|
+
}
|
|
136
172
|
await fs.writeFile(options.out, bytes);output({ok: true, file: path.resolve(options.out), size: bytes.length});return;
|
|
137
173
|
}
|
|
138
174
|
}
|