studycn 0.0.0-stage → 1.1.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 ADDED
@@ -0,0 +1,13 @@
1
+ # 考试中心 Agent 操作约定
2
+
3
+ 使用同目录 `study.mjs`,Node.js 22+。读取 `README.md` 后操作;服务与凭证从 `STUDY_URL` / `STUDY_TOKEN` 或 `--token-file` 提供,不把凭证写入题目、日志或对话。
4
+
5
+ 仅操作明确授权题库。先 `whoami` / `banks`,用 JSON 创建 single/multiple/boolean/fill/short/coding 题目,题干和解析必填。填空用 blankAnswers 二维数组,题干按序 {{1}}、{{2}};简答/编程须 referenceAnswer 与 rubric,options/correct 为空。主观题人工评分,编程不执行代码。作答附件仅由网页试卷创建者逐题开启,题库 Agent 不操作答卷和成绩。素材先 upload,用返回 id 引用。知识点与标签由需求明确或合理编写,不伪称系统自动生成。
6
+
7
+ 先本地 validate,再服务端 validate 或 import --dry-run;新建最多100道一批。写入采用固定 --request-id,每个独立操作新标识,同一操作重试复用。
8
+
9
+ 更新先 get,编辑返回的完整 JSON,保留 id 与原 version。409重新读取并比较,不能自动覆盖别人的修改;401/403报告授权问题,不使用网页账号密码或其他凭证绕过。
10
+
11
+ 检查退出码与 JSON;0成功,1校验/参数错误,2服务/网络错误。业务命令不依赖自然语言解析。遇到网络错误按输出的 idempotencyKey 重试。写入后 get 核验。
12
+
13
+ 这些接口不支持账号配置、考试发布、答卷改动或成绩发布。不要将题库写入成功表述为考试或成绩已经发布。
package/README.md CHANGED
@@ -1,3 +1,155 @@
1
- # Temporary Holding Version
1
+ # 考试中心:网页编辑器、Agent API 与 CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 服务:https://study.aimianshi.online/
4
+ API:`https://study.aimianshi.online/api/agent/v1`
5
+ 格式版本:1。支持单选、多选、判断、填空、简答和编程题;编程题以代码文本或附件作答,人工评分,不执行代码。
6
+
7
+ ## 网页使用
8
+
9
+ 题库管理 → 管理题目 → 新建/编辑。题干与解析使用可视化编辑器,支持标题、加粗、斜体、列表、引用、表格;可切换 Markdown。右侧实时预览正确答案、选项图片与解析。知识点和标签可手动填,也可由 Agent 在 JSON 中提供,系统不会自动猜测。
10
+
11
+ 选项“图片”按钮可上传或选择已有素材;支持纯图片或图文选项。题目素材可包含图片、音视频、PDF、TXT,单文件最大 40MB。JSON 中图片使用上传接口返回的 `id`,无需人工编写编号。图片不要写进 Markdown 外部链接;应使用选项 `imageId` 或题目 `mediaIds`。Excel 使用已上传完整文件名,继续兼容原导入方式。
12
+
13
+ “Agent JSON”可粘贴结构化题目、校验并应用到编辑器,也可导出 JSON。网页、CLI、Agent API 使用同一 JSON 校验器。草稿、Markdown 模式与页面位置在当前浏览器刷新后保留;关闭弹窗也保留草稿,保存成功清除。退出账号时清除该账号浏览器草稿。
14
+
15
+ ## 凭证与权限
16
+
17
+ 题库管理 → Agent 接入;超级管理员也可从系统管理 → Agent 接入进入。创建凭证时指定题库、有效期(30/90/365 天)、查询/题目写入/素材上传权限。查询权限包含答案与解析,只应授权题库维护人员。
18
+
19
+ 完整凭证仅在创建时展示一次,服务器仅存 SHA-256 摘要;网页刷新或关闭后不能再次查看。可随时撤销。凭证继承所属账号当前权限,账号停用、失去题库权限、题库删除、凭证过期或撤销后不能继续操作。凭证不能访问账号管理、邮件/AI配置、考试发布、答卷或成绩发布接口。每次 Agent 写入均记录凭证名称、请求编号、题目版本和操作者。
20
+
21
+ HTTP 身份:`Authorization: Bearer <访问凭证>`。网页 Session 不适用于 Agent API。没有自动把 AI 服务配置或其密钥交给 Agent。
22
+
23
+ ## CLI 快速开始
24
+
25
+ 有 Node.js 22+ 的电脑可从官方 npm 安装:
26
+
27
+ ```sh
28
+ npm install -g studycn
29
+ study version
30
+ study help
31
+ ```
32
+
33
+ npm 包名为 `studycn`,安装后的命令为 `study`,无需另下 ZIP。下面的官网安装脚本会自动准备 Node.js 运行环境。
34
+
35
+ 直接下载安装到自己的电脑,无需管理员权限。Mac / Linux:
36
+
37
+ ```sh
38
+ curl -fsSL https://study.aimianshi.online/agent/install.sh | bash && . "$HOME/.local/share/study/env"
39
+ study version
40
+ ```
41
+
42
+ Windows PowerShell:
43
+
44
+ ```powershell
45
+ irm https://study.aimianshi.online/agent/install.ps1 | iex
46
+ study version
47
+ $env:STUDY_TOKEN = '在网页创建的访问凭证'
48
+ study whoami
49
+ ```
50
+
51
+ 安装脚本将 CLI 放入用户目录,配置用户 PATH;已有 Node.js 22+ 则复用,否则从 Node.js 官方下载 22 LTS 并校验 SHA256。CLI 安装包同样校验 SHA256。重复安装会更新 CLI。Mac/Linux 的环境加载命令使当前终端立即可用,新终端无需再次加载。脚本不读写浏览器账号或访问凭证。
52
+
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
+
55
+ Mac/Linux 可用 `STUDY_INSTALL_DIR`、`STUDY_BIN_DIR` 自定义目录;`STUDY_NO_PROFILE=1` 禁止修改终端配置文件,此时自行加载指定安装目录的 `env`。所有安装与运行均不调用系统钥匙串。当前 CLI 版本为 1.1.0;Windows PowerShell 安装脚本已经静态检查,尚未完成 Windows 实机验证。
56
+
57
+ 已有 Node.js 22+ 也可使用 npm:
58
+
59
+ ```sh
60
+ npm install -g https://study.aimianshi.online/agent/study-cli.tgz
61
+ study help
62
+ ```
63
+
64
+ 全局 npm 默认目录无写权限时使用上方用户目录安装脚本。下载 ZIP 后仍可运行 `node study.mjs`,需保留 `study.mjs`、`question-format.mjs`、`question.schema.json` 在同一目录。以下命令使用安装后的 `study`:
65
+
66
+ ```sh
67
+ export STUDY_URL=https://study.aimianshi.online
68
+ export STUDY_TOKEN='在网页创建的访问凭证'
69
+ study help
70
+ study whoami
71
+ study banks
72
+ study validate --file questions.example.json --bank '题库编号'
73
+ study import --file questions.example.json --bank '题库编号' --dry-run
74
+ study import --file questions.example.json --bank '题库编号' --request-id import-20261111-001
75
+ study questions --bank '题库编号' --kind multiple --limit 50
76
+ study get --id '题目编号' --out question.json
77
+ # 编辑 question.json,保留 id 和原 version
78
+ study update --file question.json --request-id update-20261111-001
79
+ study upload --file option.png --request-id upload-20261111-001
80
+ study resources
81
+ study resource --id '素材编号' --out option.png
82
+ ```
83
+
84
+ 也可用 `--token-file /安全目录/凭证文件`,文件仅保存凭证。CLI 不写本地账号配置、不调用系统钥匙串、不输出凭证。`--file -` 从标准输入读 JSON。`--url` 指定另一个考试中心服务,须 HTTPS;本机 HTTP 仅允许回环地址。
85
+
86
+ 所有业务命令 stdout 输出 JSON;`help` 输出中文帮助,`help --json` 输出 JSON。create/import/update 均支持 `--dry-run`,只校验、不写入。退出码:0 成功,1 参数/格式/校验失败,2 HTTP/网络失败。HTTP 错误含 `status`、错误 `code`、诊断 `requestId` 及写入 `idempotencyKey`,便于 Agent 判断下一步。
87
+
88
+ ## 题目格式
89
+
90
+ 完整机器约束见 `question.schema.json`;示例见 `questions.example.json`。
91
+
92
+ | 字段 | 规则 |
93
+ |---|---|
94
+ | bankId | 必填;来自 `banks`;须在凭证授权题库内 |
95
+ | kind | `single` 单选、`multiple` 多选、`boolean` 判断、`fill` 填空、`short` 简答、`coding` 编程 |
96
+ | content / explanation | 必填 Markdown,最大各 20000 字符 |
97
+ | options | 单选/多选 2–12 项,每项至少有 text 或 imageId |
98
+ | options.id | 唯一编号;省略按 A–L 自动生成,correct 引用此值 |
99
+ | options.imageId | PNG/JPG/WebP 的已上传素材 id,不使用文件名或公网图片 URL |
100
+ | correct | 数组;单选/判断一项,多选至少两项,例如 `["A","C"]` |
101
+ | blankAnswers | 填空必填二维数组,如 `[["北京","北京市"],["1949"]]`;题干依次用 `{{1}}`、`{{2}}`,最多20空。按空均分,匹配忽略大小写、全半角、首尾空白 |
102
+ | referenceAnswer / rubric | 简答和编程必填:参考答案及逐项评分标准,支持 Markdown / 代码块 |
103
+ | points | 默认 10;大于 0、不超过 10000,最多两位小数 |
104
+ | options.score | 正确项全部不填时按分均分(10÷3 → 3.34/3.33/3.33);手动填时每个正确项都须填,合计等于 points,错误项须为 0 |
105
+ | difficulty | `easy` / `medium` / `hard`,默认 medium |
106
+ | knowledge / tags | 可选数组,由人或 Agent 提供,不自动生成 |
107
+ | mediaIds | 可选题目附件 id 数组,上传返回编号 |
108
+ | status | `active` / `disabled`,默认 active |
109
+ | id / version | 更新时必须保留读取到的编号与原版本;新建不传 |
110
+
111
+ 填空、简答、编程题的 `options` 和 `correct` 须省略或为空数组。附件不在题库层面开启:试卷创建者在网页试卷编辑中逐题配置 `questionSettings[questionId].allowAttachments`,默认关闭;固定与随机组卷均支持,发布时固定快照。每题最多5个作答附件,每个20MB,交卷后锁定。Agent 凭证仅负责题库,不拥有答卷、作答附件或成绩权限。
112
+
113
+ 答卷采用 `{selected:[], text:"", blanks:[], attachmentIds:[]}`,仍兼容旧选择题数组。简答、编程成绩带逐题待评分状态,全部人工评分后才可确认,再发布。AI 仅提供复核意见。Excel 模板最后三列为“填空答案”“参考答案”“评分标准”,示例覆盖六种题型。
114
+
115
+ 判断题可省略 options,系统补 A=正确、B=错误。正确项填 `["A"]` 或 `["B"]`。显式 options 时固定两个选项“正确”“错误”,不使用图片。多选漏选按所选正确项分值累计,选错任意一项整题 0 分。
116
+
117
+ JSON Schema用于形状、范围和必填检查;引用存在、两位小数、分值合计、判断选项顺序及业务权限,以共用校验器和 API 为准。API 拒绝未知写入字段;get 返回的 ownerId/createdAt/updatedAt 为只读字段,原样回传时忽略。
118
+
119
+ ## HTTP 接口
120
+
121
+ 除素材下载外,返回 JSON,均不缓存;无跨站 CORS。错误:
122
+
123
+ ```json
124
+ {"error":{"code":"VERSION_CONFLICT","message":"内容已被其他操作更新,请刷新后重试","requestId":"诊断编号"}}
125
+ ```
126
+
127
+ | 方法 / 路径 | 权限 | 请求与返回 |
128
+ |---|---|---|
129
+ | GET /me | questions:read | 返回账号名称、凭证名称、scopes、bankIds、版本 |
130
+ | GET /banks | questions:read | `{items:[题库]}` |
131
+ | GET /questions | questions:read | `bankId,kind,q,knowledge,status,offset,limit`;返回 `{items,total,offset,limit}`,默认50,最多100 |
132
+ | GET /questions/:id | questions:read | 返回完整题目,包含 id/version/答案/解析 |
133
+ | POST /validate | questions:read | 单题对象或 `{items:[题目]}`;返回 `{valid,items,errors:[{index,path,message}]}`,不写题目,校验权限、素材、版本 |
134
+ | POST /questions | questions:write | 单题对象,新建;不带 id/version |
135
+ | PUT /questions/:id | questions:write | 完整题目对象;id匹配路径、version匹配旧版本,更新成功 version+1 |
136
+ | POST /imports | questions:write | `{items:[新题]}`,1–100道,只新建;任一道失败整个事务回滚 |
137
+ | GET /resources | questions:read | `{items:[素材]}`,所属账号素材与授权题库引用素材 |
138
+ | POST /resources | resources:write | multipart/form-data 的 file 字段;返回 id/name/mime/size 等 |
139
+ | GET /resources/:id | questions:read | 返回文件二进制 |
140
+
141
+ 写入题目返回 `{items,count,requestId,replayed}`。POST/PUT 写入(含上传)必须有 `Idempotency-Key`,8–100 位字母、数字、下划线、点、冒号或短横线。相同凭证+标识+内容重试返回原结果、不重复入库,`replayed=true`;相同标识用于不同内容返回409。CLI自动生成标识,建议批次明确传 `--request-id` 以便跨进程重试。每个新操作使用新的标识。
142
+
143
+ JSON 请求上限2MB;素材请求上限42MB、文件上限40MB。校验成功不代表写入时一定成功:并发修改、重复题、权限变化仍会被最终事务拦截。预览校验不查重;最终写入按题型、题干、选项与正确项检查题库内完全重复题。
144
+
145
+ 401:缺少/失效凭证;403:权限不足;404:题目/素材不存在或不可访问;409:版本冲突、幂等标识冲突;400:格式或重复题;413:超大小;500:服务错误。401/403不能自动扩大权限,409须重读核对,禁止用新版本直接覆盖旧草稿。
146
+
147
+ ## Agent 推荐步骤
148
+
149
+ 1. `whoami` 与 `banks` 确认范围。
150
+ 2. 根据需求生成六种受支持题型及解析、知识点;有图片先 upload,将返回 id 填入 JSON。
151
+ 3. `validate` 本地校验,`validate --remote` 或 `import --dry-run` 校验服务端权限与引用。
152
+ 4. 新建使用 create/import,固定 request-id;更新先 get,核对内容后保留 version 执行 update。
153
+ 5. 再次 get/questions 核验。考试或成绩发布仍由网页正常人工流程完成。
154
+
155
+ 仓库内维护时可从 `public/agent` 获取全部源码。下载的工具包不包含任何正式凭证或业务数据。
package/package.json CHANGED
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "name": "studycn",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "考试中心 Agent CLI",
5
+ "homepage": "https://study.aimianshi.online/",
6
+ "keywords": ["study", "exam", "cli", "agent", "question-bank"],
7
+ "type": "module",
8
+ "bin": {"study": "./study.mjs"},
9
+ "engines": {"node": ">=22"},
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"],
12
+ "license": "UNLICENSED"
13
+ }
@@ -0,0 +1,101 @@
1
+ export const formatVersion = "1";
2
+ export const letters = "ABCDEFGHIJKL";
3
+ export function equalScores(points, count) {
4
+ if (!count) return [];
5
+ const cents = Math.round(points * 100), base = Math.floor(cents / count);
6
+ return Array.from({length: count}, (_, i) => (base + (i < cents % count ? 1 : 0)) / 100);
7
+ }
8
+ const fail = (path, message) => {const error = new Error(message); error.path = path; throw error;};
9
+ const string = (value, path, max = 20000) => {
10
+ if (typeof value !== "string" || value.length > max) fail(path, `${path} 必须是长度不超过 ${max} 的文字`);
11
+ return value.trim();
12
+ };
13
+ const list = (value, path) => {
14
+ if (value === undefined) return [];
15
+ if (!Array.isArray(value) || value.length > 100) fail(path, `${path} 必须是最多 100 项的数组`);
16
+ const values = value.map((v, i) => string(v, `${path}[${i}]`, 200));
17
+ if (values.some(v => !v) || new Set(values).size !== values.length) fail(path, `${path} 不能包含空值或重复项`);
18
+ return values;
19
+ };
20
+ const numeric = (value, path) => {
21
+ if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value * 100 - Math.round(value * 100)) > 1e-6) fail(path, `${path} 必须是最多两位小数的数字`);
22
+ return value;
23
+ };
24
+ export function normalizeQuestion(input) {
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"];
27
+ for (const key of Object.keys(input)) if (!allowed.includes(key)) fail(key, `不支持字段 ${key}`);
28
+ const kind = input.kind;
29
+ if (!["single", "multiple", "boolean", "fill", "short", "coding"].includes(kind)) fail("kind", "支持 single 单选、multiple 多选、boolean 判断、fill 填空、short 简答、coding 编程");
30
+ const points = numeric(input.points ?? 10, "points");
31
+ if (points <= 0 || points > 10000) fail("points", "题目分值须大于 0 且不超过 10000");
32
+ if (["fill", "short", "coding"].includes(kind)) {
33
+ if ((input.options !== undefined && (!Array.isArray(input.options) || input.options.length)) || (input.correct !== undefined && (!Array.isArray(input.correct) || input.correct.length))) fail("options", "填空和主观题不填写选项或正确选项");
34
+ const result = commonQuestion(input, {kind, points, options: [], correct: []});
35
+ if (kind === "fill") {
36
+ if (!Array.isArray(input.blankAnswers) || input.blankAnswers.length < 1 || input.blankAnswers.length > 20) fail("blankAnswers", "填空须为 1–20 空,每空答案为数组");
37
+ result.blankAnswers = input.blankAnswers.map((values, i) => {
38
+ if (!Array.isArray(values) || values.length < 1 || values.length > 20) fail(`blankAnswers[${i}]`, "每空须有 1–20 个可接受答案");
39
+ const items = values.map(v => string(v, `blankAnswers[${i}]`, 2000));
40
+ if (items.some(v => !v) || new Set(items).size !== items.length) fail(`blankAnswers[${i}]`, "每空答案不能为空或重复");
41
+ return items;
42
+ });
43
+ const markers = [...result.content.matchAll(/\{\{(\d+)\}\}/g)].map(m => Number(m[1]));
44
+ if (markers.length !== result.blankAnswers.length || !markers.every((v,i) => v === i + 1)) fail("content", "题干按顺序使用 {{1}}、{{2}},数量须与答案一致");
45
+ } else {
46
+ result.referenceAnswer = string(input.referenceAnswer ?? "", "referenceAnswer");
47
+ result.rubric = string(input.rubric ?? "", "rubric");
48
+ if (!result.referenceAnswer || !result.rubric) fail("referenceAnswer", "主观题须填写参考答案和评分标准,逐题人工评阅");
49
+ }
50
+ return result;
51
+ }
52
+ const rawOptions = input.options ?? (kind === "boolean" ? [{id: "A", text: "正确"}, {id: "B", text: "错误"}] : []);
53
+ if (!Array.isArray(rawOptions) || rawOptions.length < 2 || rawOptions.length > 12) fail("options", "请填写 2–12 个选项");
54
+ const options = rawOptions.map((option, index) => {
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}`, "不支持此选项字段");
57
+ const id = string(option.id ?? letters[index], `options[${index}].id`, 100);
58
+ const text = string(option.text ?? "", `options[${index}].text`, 4000);
59
+ const imageId = option.imageId ? string(option.imageId, `options[${index}].imageId`, 100) : undefined;
60
+ if (!id || (!text && !imageId)) fail(`options[${index}]`, "每个选项须有编号,以及文字或已上传图片");
61
+ return {id, text, ...(imageId ? {imageId} : {}), ...(option.score !== undefined ? {score: numeric(option.score, `options[${index}].score`)} : {})};
62
+ });
63
+ if (new Set(options.map(o => o.id)).size !== options.length) fail("options", "选项编号不能重复");
64
+ const correct = list(input.correct, "correct");
65
+ if (correct.some(id => !options.some(o => o.id === id))) fail("correct", "正确答案必须引用选项 id");
66
+ 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 !== "错误" || options.some(o => o.imageId))) fail("options", "判断题固定两个选项:正确、错误");
68
+ const explicitScores = options.filter(o => correct.includes(o.id) && o.score !== undefined);
69
+ if (explicitScores.length && explicitScores.length !== correct.length) fail("options.score", "所有正确项须一起填分值,或全部不填以自动均分");
70
+ const scores = equalScores(points, correct.length);
71
+ const scoredOptions = options.map(o => ({...o, score: correct.includes(o.id) ? o.score ?? scores[correct.indexOf(o.id)] : o.score ?? 0}));
72
+ if (scoredOptions.some(o => o.score < 0 || (!correct.includes(o.id) && o.score !== 0))) fail("options.score", "分值须为非负数,错误项分值须为 0");
73
+ if (Math.round(scoredOptions.reduce((n, o) => n + o.score, 0) * 100) !== Math.round(points * 100)) fail("options.score", "正确项分值合计须等于题目总分");
74
+ return commonQuestion(input, {kind, points, options: scoredOptions, correct});
75
+ }
76
+ function commonQuestion(input, base) {
77
+ const content = string(input.content, "content"), explanation = string(input.explanation, "explanation");
78
+ if (!content || !explanation) fail(!content ? "content" : "explanation", "题干和解析必填");
79
+ const difficulty = input.difficulty ?? "medium", status = input.status ?? "active";
80
+ if (!["easy", "medium", "hard"].includes(difficulty)) fail("difficulty", "难度仅支持 easy / medium / hard");
81
+ if (!["active", "disabled"].includes(status)) fail("status", "状态仅支持 active / disabled");
82
+ const result = {bankId: string(input.bankId, "bankId", 100), ...base, content, explanation, difficulty,
83
+ knowledge: list(input.knowledge, "knowledge"), tags: list(input.tags, "tags"), mediaIds: list(input.mediaIds, "mediaIds"), status};
84
+ if (!result.bankId) fail("bankId", "请选择所属题库");
85
+ if (input.id !== undefined) {
86
+ result.id = string(input.id, "id", 100);
87
+ if (!result.id || !Number.isInteger(input.version) || input.version < 1) fail("version", "更新题目须带 id 和原 version");
88
+ result.version = input.version;
89
+ } else if (input.version !== undefined) fail("version", "新建题目不填写 version");
90
+ return result;
91
+ }
92
+ export function validateQuestions(input) {
93
+ const items = Array.isArray(input) ? input : [input];
94
+ if (!items.length || items.length > 100) return {valid: false, items: [], errors: [{index: 0, path: "$", message: "每批须为 1–100 道题"}]};
95
+ const normalized = [], errors = [];
96
+ items.forEach((item, index) => {
97
+ try {normalized.push(normalizeQuestion(item));}
98
+ catch (error) {errors.push({index, path: error.path || "$", message: error.message});}
99
+ });
100
+ return {valid: errors.length === 0, items: normalized, errors};
101
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://study.aimianshi.online/agent/question.schema.json",
4
+ "title": "考试中心题目 v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["bankId", "kind", "content", "explanation"],
8
+ "properties": {
9
+ "id": {"type": "string", "minLength": 1, "maxLength": 100},
10
+ "version": {"type": "integer", "minimum": 1},
11
+ "bankId": {"type": "string", "minLength": 1, "maxLength": 100},
12
+ "kind": {"enum": ["single", "multiple", "boolean", "fill", "short", "coding"]},
13
+ "content": {"type": "string", "minLength": 1, "maxLength": 20000, "description": "题干,支持 Markdown、GFM 表格"},
14
+ "options": {
15
+ "type": "array", "maxItems": 12,
16
+ "items": {
17
+ "type": "object", "additionalProperties": false,
18
+ "properties": {
19
+ "id": {"type": "string", "minLength": 1, "maxLength": 100, "description": "省略时按顺序生成 A–L"},
20
+ "text": {"type": "string", "maxLength": 4000},
21
+ "imageId": {"type": "string", "maxLength": 100, "description": "上传接口返回的 id;仅图片"},
22
+ "score": {"type": "number", "minimum": 0, "multipleOf": 0.01, "description": "正确项一起填写或一起省略自动均分;错误项须为 0"}
23
+ }
24
+ }
25
+ },
26
+ "correct": {"type": "array", "maxItems": 12, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 200}, "description": "选择题引用 options.id;填空和主观题为空数组或省略"},
27
+ "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", "minLength": 1, "maxLength": 20000},
29
+ "rubric": {"type": "string", "minLength": 1, "maxLength": 20000, "description": "简答和编程题人工评分标准"},
30
+ "points": {"type": "number", "exclusiveMinimum": 0, "maximum": 10000, "multipleOf": 0.01, "default": 10},
31
+ "explanation": {"type": "string", "minLength": 1, "maxLength": 20000},
32
+ "difficulty": {"enum": ["easy", "medium", "hard"], "default": "medium"},
33
+ "knowledge": {"type": "array", "maxItems": 100, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 200}, "default": []},
34
+ "tags": {"type": "array", "maxItems": 100, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 200}, "default": []},
35
+ "mediaIds": {"type": "array", "maxItems": 100, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 200}, "default": []},
36
+ "status": {"enum": ["active", "disabled"], "default": "active"},
37
+ "ownerId": {"type": "string", "description": "get 返回的只读字段,输入时忽略"},
38
+ "createdAt": {"type": "string", "description": "只读"},
39
+ "updatedAt": {"type": "string", "description": "只读"}
40
+ },
41
+ "dependentRequired": {"id": ["version"], "version": ["id"]},
42
+ "allOf": [
43
+ {"if": {"properties": {"kind": {"enum": ["single", "multiple"]}}}, "then": {"required": ["options"], "properties": {"options": {"minItems": 2}}}},
44
+ {"if": {"properties": {"kind": {"enum": ["single", "multiple", "boolean"]}}}, "then": {"required": ["correct"], "properties": {"correct": {"minItems": 1}}}},
45
+ {"if": {"properties": {"kind": {"const": "multiple"}}}, "then": {"properties": {"correct": {"minItems": 2}}}},
46
+ {"if": {"properties": {"kind": {"enum": ["single", "boolean"]}}}, "then": {"properties": {"correct": {"maxItems": 1}}}},
47
+ {"if": {"properties": {"kind": {"const": "boolean"}}}, "then": {"properties": {"options": {"minItems": 2, "maxItems": 2}}}},
48
+ {"if": {"properties": {"kind": {"const": "fill"}}}, "then": {"required": ["blankAnswers"]}},
49
+ {"if": {"properties": {"kind": {"enum": ["short", "coding"]}}}, "then": {"required": ["referenceAnswer", "rubric"]}},
50
+ {"if": {"properties": {"kind": {"enum": ["fill", "short", "coding"]}}}, "then": {"properties": {"options": {"maxItems": 0}, "correct": {"maxItems": 0}}}}
51
+ ]
52
+ }
@@ -0,0 +1,59 @@
1
+ [
2
+ {
3
+ "bankId": "请替换为题库编号",
4
+ "kind": "single",
5
+ "content": "收到客户反馈后,第一步应做什么?",
6
+ "options": [{"id": "A", "text": "记录并确认诉求"}, {"id": "B", "text": "直接关闭工单"}],
7
+ "correct": ["A"],
8
+ "points": 10,
9
+ "explanation": "先确认问题与诉求,再安排处理。",
10
+ "knowledge": ["客户沟通"]
11
+ },
12
+ {
13
+ "bankId": "请替换为题库编号",
14
+ "kind": "multiple",
15
+ "content": "哪些操作符合信息安全要求?",
16
+ "options": [{"id": "A", "text": "按授权访问", "score": 4}, {"id": "B", "text": "共享密码", "score": 0}, {"id": "C", "text": "离开工位时锁屏", "score": 6}],
17
+ "correct": ["A", "C"],
18
+ "points": 10,
19
+ "explanation": "A、C 符合要求;B 会暴露账号权限。漏选累计得分,选错整题 0 分。",
20
+ "knowledge": ["信息安全"]
21
+ },
22
+ {
23
+ "bankId": "请替换为题库编号",
24
+ "kind": "boolean",
25
+ "content": "未经确认的规则评分可以直接发布。",
26
+ "correct": ["B"],
27
+ "points": 10,
28
+ "explanation": "成绩须经人工确认后发布。"
29
+ },
30
+ {
31
+ "bankId": "请替换为题库编号",
32
+ "kind": "fill",
33
+ "content": "中国首都是 {{1}},中华人民共和国成立于 {{2}} 年。",
34
+ "blankAnswers": [["北京", "北京市"], ["1949"]],
35
+ "points": 10,
36
+ "explanation": "逐空均分,每空 5 分。",
37
+ "knowledge": ["常识"]
38
+ },
39
+ {
40
+ "bankId": "请替换为题库编号",
41
+ "kind": "short",
42
+ "content": "发现账号异常登录后,应如何处理?",
43
+ "referenceAnswer": "修改密码,终止异常会话,报告管理员并保留记录。",
44
+ "rubric": "修改密码3分;终止会话3分;报告2分;保留记录2分。",
45
+ "points": 10,
46
+ "explanation": "先阻断访问,再报告和留痕。",
47
+ "knowledge": ["信息安全"]
48
+ },
49
+ {
50
+ "bankId": "请替换为题库编号",
51
+ "kind": "coding",
52
+ "content": "用 Python 实现两个数相加的函数。",
53
+ "referenceAnswer": "```python\ndef add(a, b):\n return a + b\n```",
54
+ "rubric": "参数2分;返回正确结果6分;格式与说明2分。人工评阅代码。",
55
+ "points": 10,
56
+ "explanation": "接收两个参数,返回相加结果。",
57
+ "knowledge": ["Python"]
58
+ }
59
+ ]
package/study.mjs ADDED
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ import {validateQuestions} from "./question-format.mjs";
6
+
7
+ const help = `考试中心 CLI(Node.js 22+)
8
+
9
+ 环境变量:
10
+ STUDY_URL 服务地址,默认 https://study.aimianshi.online
11
+ STUDY_TOKEN 在网页“题库管理 → Agent 接入”创建的访问凭证
12
+
13
+ 命令(默认输出 JSON):
14
+ version 查看 CLI 版本
15
+ help [--json] 查看帮助
16
+ whoami 查看凭证权限与授权题库
17
+ banks 列出授权题库
18
+ questions [--bank ID] [--kind single|multiple|boolean|fill|short|coding]
19
+ [--q 文字] [--knowledge 名称] [--status active|disabled]
20
+ [--offset 0] [--limit 50]
21
+ get --id ID [--out question.json] 读取题目及版本
22
+ validate --file FILE [--bank ID] [--remote]
23
+ 本地校验;--remote 另校验授权、素材和版本
24
+ create --file FILE [--bank ID] [--request-id KEY]
25
+ import --file FILE [--bank ID] [--dry-run] [--request-id KEY]
26
+ 批量新建,1–100 道,全部成功或全部回滚
27
+ update --file FILE [--id ID] [--request-id KEY]
28
+ 使用 get 返回的完整题目,须保留 id 与 version
29
+ resources 列出可访问素材
30
+ upload --file FILE [--request-id KEY]
31
+ resource --id ID --out FILE 下载素材
32
+ schema [--out FILE] 获取本地 JSON Schema
33
+
34
+ 通用参数:--url URL、--token-file FILE、--help、--json。
35
+ --file - 从 stdin 读取 JSON;上传必须指定真实文件。
36
+ 写入自动生成幂等标识,可用 --request-id 固定标识后安全重试。
37
+ 完整说明见 README.md;退出码:0 成功,1 参数/校验失败,2 HTTP/网络失败。
38
+ `;
39
+ const output = value => process.stdout.write(JSON.stringify(value, null, 2) + "\n");
40
+ 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"]);
42
+ const flags = new Set(["dry-run", "remote", "help", "json"]);
43
+ function parse(args) {
44
+ let command; const options = {};
45
+ for (let i = 0; i < args.length; i++) {
46
+ const arg = args[i];
47
+ if (arg.startsWith("--")) {
48
+ const key = arg.slice(2);
49
+ if (flags.has(key)) options[key] = true;
50
+ else if (valueOptions.has(key)) {
51
+ if (args[i + 1] === undefined || args[i + 1].startsWith("--")) error(`参数 --${key} 缺少值`);
52
+ options[key] = args[++i];
53
+ } else error(`不支持参数 ${arg}`);
54
+ } else if (!command) command = arg;
55
+ else error(`多余参数 ${arg}`);
56
+ }
57
+ return {command: command || "help", options};
58
+ }
59
+ async function readJson(filename) {
60
+ if (!filename) error("请使用 --file 指定 JSON 文件;--file - 可读 stdin");
61
+ let raw;
62
+ if (filename === "-") {const chunks = []; let size = 0;for await (const chunk of process.stdin) {size += chunk.length;if(size > 2 * 1024 * 1024) error("JSON 超过 2MB");chunks.push(chunk);}raw = Buffer.concat(chunks).toString("utf8");}
63
+ else {if ((await fs.stat(filename)).size > 2 * 1024 * 1024) error("JSON 超过 2MB");raw = await fs.readFile(filename, "utf8");}
64
+ try {return JSON.parse(raw);} catch {error("文件不是有效 JSON", "INVALID_JSON");}
65
+ }
66
+ const requireId = value => {if (!value || typeof value !== "string") error("请使用 --id 指定编号");return encodeURIComponent(value);};
67
+ async function run() {
68
+ const {command, options} = parse(process.argv.slice(2));
69
+ if (command === "version") {output({name: "考试中心 CLI", version: "1.1.0", node: process.versions.node});return;}
70
+ if (command === "help" || options.help) {if(options.json) output({name: "考试中心 CLI", version: "1.1.0", help}); else process.stdout.write(help);return;}
71
+ if (command === "schema") {
72
+ const value = JSON.parse(await fs.readFile(new URL("./question.schema.json", import.meta.url), "utf8"));
73
+ 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
+ }
75
+ if (!["whoami", "banks", "questions", "get", "validate", "create", "import", "update", "resources", "upload", "resource"].includes(command)) error("未知命令:" + command);
76
+ if (options["dry-run"] && !["create", "import", "update"].includes(command)) error("--dry-run 仅支持 create / import / update;本命令未执行");
77
+ if (options.remote && command !== "validate") error("--remote 仅支持 validate;本命令未执行");
78
+ let items;
79
+ if (["validate", "create", "import", "update"].includes(command)) {
80
+ const raw = await readJson(options.file);
81
+ items = Array.isArray(raw) ? raw : Array.isArray(raw?.items) ? raw.items : [raw];
82
+ if (options.bank) items = items.map(item => ({...item, bankId: options.bank}));
83
+ if (command === "update" && options.id && items.length === 1) {if (items[0].id && items[0].id !== options.id) error("--id 与文件 id 不一致");items[0] = {...items[0], id: options.id};}
84
+ const checked = validateQuestions(items);
85
+ if (!checked.valid) {output(checked);process.exitCode = 1;return;}
86
+ items = checked.items;
87
+ if (command === "validate" && !options.remote) {output(checked);return;}
88
+ if (["create", "update"].includes(command) && items.length !== 1) error("create / update 每次处理一道题目");
89
+ if (["create", "import"].includes(command) && items.some(item => item.id)) error("新建不填写 id / version;更新请用 update");
90
+ if (command === "update" && !items[0].id) error("更新须保留 get 返回的 id 和 version");
91
+ }
92
+ const base = new URL(options.url || process.env.STUDY_URL || "https://study.aimianshi.online");
93
+ if (base.username || base.password || base.search || base.hash || (base.pathname !== "/" && base.pathname !== "")) error("服务地址须为站点根地址,不含账号、查询参数或子路径");
94
+ if (base.protocol !== "https:" && !(base.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(base.hostname))) error("服务地址须使用 HTTPS;本机测试可用 HTTP");
95
+ const token = (options["token-file"] ? await fs.readFile(options["token-file"], "utf8") : process.env.STUDY_TOKEN || "").trim();
96
+ if (!/^study_[A-Za-z0-9_-]{43}$/.test(token)) error("请设置有效 STUDY_TOKEN 或 --token-file", "MISSING_CREDENTIAL");
97
+ const requestId = options["request-id"] || crypto.randomUUID();
98
+ async function request(endpoint, method = "GET", body, binary = false) {
99
+ const headers = {"Authorization": "Bearer " + token};
100
+ if (body !== undefined) {headers["Idempotency-Key"] = requestId;if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";}
101
+ 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(60000)});}
103
+ catch {error("网络请求未完成;写入重试请复用 --request-id " + requestId, "NETWORK_ERROR", 2);}
104
+ if (binary && response.ok) return new Uint8Array(await response.arrayBuffer());
105
+ let value;try {value = await response.json();}catch {error("服务未返回有效 JSON(HTTP " + response.status + ")", "INVALID_RESPONSE", 2);}
106
+ 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
+ return value;
108
+ }
109
+ if (["whoami", "banks", "resources"].includes(command)) return output(await request(command === "whoami" ? "me" : command));
110
+ if (command === "questions") {
111
+ const params = new URLSearchParams();
112
+ for (const key of ["kind", "q", "knowledge", "status", "offset", "limit"]) if(options[key]) params.set(key, options[key]);
113
+ if (options.bank) params.set("bankId", options.bank);
114
+ return output(await request("questions?" + params));
115
+ }
116
+ if (command === "get") {
117
+ const result = await request("questions/" + requireId(options.id));
118
+ if(options.out) {await fs.writeFile(options.out, JSON.stringify(result, null, 2) + "\n");return output({ok: true, file: path.resolve(options.out), id: result.id, version: result.version});}
119
+ return output(result);
120
+ }
121
+ if (command === "validate" || (["create", "import", "update"].includes(command) && options["dry-run"])) {
122
+ const result = await request("validate", "POST", {items});output(result);if(!result.valid) process.exitCode = 1;return;
123
+ }
124
+ if (command === "create") return output(await request("questions", "POST", items[0]));
125
+ if (command === "import") return output(await request("imports", "POST", {items}));
126
+ if (command === "update") return output(await request("questions/" + requireId(items[0].id), "PUT", items[0]));
127
+ if (command === "upload") {
128
+ if(!options.file || options.file === "-") error("上传请指定真实文件");
129
+ const stat = await fs.stat(options.file);if(!stat.size || stat.size > 40 * 1024 * 1024) error("上传文件须为 1 字节至 40MB");
130
+ const form = new FormData();form.set("file", new File([await fs.readFile(options.file)], path.basename(options.file)));
131
+ return output({...await request("resources", "POST", form), requestId});
132
+ }
133
+ if (command === "resource") {
134
+ if(!options.out) error("下载请使用 --out 指定保存路径");
135
+ const bytes = await request("resources/" + requireId(options.id), "GET", undefined, true);
136
+ await fs.writeFile(options.out, bytes);output({ok: true, file: path.resolve(options.out), size: bytes.length});return;
137
+ }
138
+ }
139
+ run().catch(e => {
140
+ output({error: {code: e.code || "CLI_ERROR", message: String(e.message).replace(/study_[A-Za-z0-9_-]{43}/g, "[凭证已隐藏]"), ...(e.status ? {status: e.status} : {}), ...(e.requestId ? {requestId: e.requestId} : {}), ...(e.idempotencyKey ? {idempotencyKey: e.idempotencyKey} : {})}});
141
+ process.exitCode = e.exit || 1;
142
+ });