From dab71b815c51a058776a51adda11d67f184090dd Mon Sep 17 00:00:00 2001 From: wonder Date: Wed, 2 Sep 2026 20:42:54 +0800 Subject: [PATCH] feat: add exam skill for CS question generation - SKILL.md: agent instructions for generating exam questions - validate.py: Python schema validation (jsonschema with fallback) - validate.mjs: Node.js schema validation (built-in modules only) Supports7 question types: fill_blank, single_choice, multiple_choice, true_false, short_answer, code_reading, scenario. --- exam/.claude/skills/exam/SKILL.md | 133 ++++++++++++++++++++++++++ exam/.claude/skills/exam/validate.mjs | 75 +++++++++++++++ exam/.claude/skills/exam/validate.py | 93 ++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 exam/.claude/skills/exam/SKILL.md create mode 100644 exam/.claude/skills/exam/validate.mjs create mode 100644 exam/.claude/skills/exam/validate.py diff --git a/exam/.claude/skills/exam/SKILL.md b/exam/.claude/skills/exam/SKILL.md new file mode 100644 index 0000000..862c75c --- /dev/null +++ b/exam/.claude/skills/exam/SKILL.md @@ -0,0 +1,133 @@ +--- +name: exam +description: "Generate, validate, and manage CS exam questions. Trigger: '生成题目', 'generate questions', 'validate', '添加题目', '/exam'" +--- + +# CS 知识应试强化 — 题目生成 Skill + +为 `topics/` 目录生成符合 JSON Schema 的题目文件。 + +## 目录结构 + +``` +examination/ +├── schema/ +│ ├── question.schema.json # 题目 JSON Schema (Draft-07) +│ ├── prompt-template.md # 提示词模板参考 +│ └── templates/ # 各题型示例 +├── topics/ +│ ├── index.json # 主题索引 +│ └── {group}/{subtopic}/ +│ ├── meta.json # 子主题元信息 +│ ├── fill_blank.json # 填空题 +│ └── single_choice.json # 单选题 +└── .claude/skills/exam/ + ├── SKILL.md # 本文件 + ├── validate.py # Python 校验脚本 + └── validate.mjs # Node.js 校验脚本 +``` + +## 工作流 + +### 1. 确定目标 + +用户会说类似「生成填空题」「给 gc-jvm 加10道选择题」。你需要: + +1. 确认 **子主题**:读 `topics/index.json` 找到对应 slug 和 path +2. 确认 **题型**:fill_blank / single_choice / multiple_choice / true_false / short_answer / code_reading / scenario +3. 确认 **数量**:默认 10 + +如果用户没指定,主动询问。 + +### 2. 读取上下文 + +```bash +# 查看子主题已有题目,避免重复 +cat topics/{group}/{subtopic}/meta.json +cat topics/{group}/{subtopic}/{type}.json # 如果存在 +``` + +### 3. 生成题目 + +你(Claude)直接生成 JSON。遵循以下规则: + +- 输出格式严格匹配 `schema/question.schema.json` +- ID 格式:`{type_short}-{seq}`,如 `fb-001`,序号从已有最大值 +1 开始 +- type_short 映射:sc=single_choice, mc=multiple_choice, tf=true_false, fb=fill_blank, sa=short_answer, cr=code_reading, sn=scenario +- difficulty 1-5,根据主题上下文合理分布 +- tags 使用子主题 meta.json 中的 tags 作为参考 +- explanation 必须详细,解释为什么对/错 + +生成后,将 JSON 写入临时文件进行校验: + +```bash +# 写入临时文件 +cat > /tmp/exam_gen.json << 'ENDJSON' +{ ...生成的JSON... } +ENDJSON +``` + +### 4. 校验 + +根据可用运行时选择其一: + +```bash +# Python (优先) +python3 .claude/skills/exam/validate.py /tmp/exam_gen.json + +# Node.js (备选) +node .claude/skills/exam/validate.mjs /tmp/exam_gen.json +``` + +- 校验通过 → 继续写入 +- 校验失败 → 修正 JSON 后重新校验,直到通过 + +### 5. 写入文件 + +校验通过后: + +1. 如果目标题型文件已存在,读取现有 questions 数组,追加新题目(不覆盖旧题) +2. 如果不存在,创建新文件 +3. 更新 `meta.json` 的 `question_files` 和 `stats` +4. 更新 `topics/index.json` 中对应子主题的 `stats` + +文件格式: + +```json +{ + "topic": "{subtopic-slug}", + "type": "{question_type}", + "schema_version": "1.0.0", + "generated": "{ISO-8601}", + "questions": [ ... ] +} +``` + +### 6. 确认 + +报告生成结果:题型、数量、文件路径。 + +提示用户在线查看: + +> ✅ 已完成!前往 http://47.121.181.112:30000/ 查看新题目。 + +## 校验脚本 + +两个脚本功能相同,检测可用环境后选用: + +- `.claude/skills/exam/validate.py` — 优先用 `jsonschema`,不可用时回退到基础校验 +- `.claude/skills/exam/validate.mjs` — 纯 Node.js 内置模块,无外部依赖 + +退出码:0=通过,1=有错误,2=用法错误。 + +## 7 种题型速查 + +| 题型 | type | 关键字段 | +|------|------|----------| +| 填空 | fill_blank | answer: string[], answer_rule: "any"/"all"/"ordered" | +| 单选 | single_choice | options: {A-D}, answer: string | +| 多选 | multiple_choice | options: {A-D}, answer: string[] | +| 判断 | true_false | answer: boolean | +| 简答 | short_answer | answer: string, keywords: string[], scoring_rubric: string | +| 代码阅读 | code_reading | code, language, sub_questions[] | +| 场景分析 | scenario | context, sub_questions[] | diff --git a/exam/.claude/skills/exam/validate.mjs b/exam/.claude/skills/exam/validate.mjs new file mode 100644 index 0000000..6cea182 --- /dev/null +++ b/exam/.claude/skills/exam/validate.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/** + * Validate a question JSON file against schema/question.schema.json. + * + * Usage: + * node validate.mjs + * echo '' | node validate.mjs - + * + * Exit codes: 0 = valid, 1 = errors found, 2 = usage error. + */ + +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, "../../.."); +const SCHEMA_PATH = resolve(REPO_ROOT, "schema/question.schema.json"); + +function loadJson(path) { + const raw = path === "-" ? readFileSync(0, "utf-8") : readFileSync(path, "utf-8"); + return JSON.parse(raw); +} + +const VALID_TYPES = [ + "single_choice", "multiple_choice", "true_false", + "fill_blank", "short_answer", "code_reading", "scenario", +]; + +const ID_RE = /^(sc|mc|tf|fb|sa|cr|sn)-\d{3}$/; + +function validate(data) { + const errors = []; + const requiredTop = ["topic", "type", "schema_version", "questions"]; + for (const field of requiredTop) { + if (!(field in data)) errors.push(`Missing required field: ${field}`); + } + if (!VALID_TYPES.includes(data.type)) errors.push(`Invalid type: ${data.type}`); + if (data.schema_version !== "1.0.0") + errors.push(`schema_version must be '1.0.0', got '${data.schema_version}'`); + + for (const [i, q] of (data.questions || []).entries()) { + const p = `questions[${i}]`; + for (const field of ["id", "type", "difficulty", "tags", "question", "explanation"]) { + if (!(field in q)) errors.push(`${p}: missing '${field}'`); + } + if (!VALID_TYPES.includes(q.type)) errors.push(`${p}: invalid type '${q.type}'`); + if (q.difficulty !== undefined && !(q.difficulty >= 1 && q.difficulty <= 5)) + errors.push(`${p}: difficulty must be 1-5`); + if (q.id !== undefined && !ID_RE.test(q.id)) + errors.push(`${p}: id '${q.id}' doesn't match pattern {type}-{000}`); + } + return errors; +} + +function main() { + if (process.argv.length !== 3) { + console.error(`Usage: node ${process.argv[1]} `); + process.exit(2); + } + const data = loadJson(process.argv[2]); + const errors = validate(data); + + if (errors.length) { + console.log(`❌ ${errors.length} error(s):`); + for (const e of errors) console.log(` • ${e}`); + process.exit(1); + } else { + console.log(`✅ Valid — ${(data.questions || []).length} question(s)`); + process.exit(0); + } +} + +main(); diff --git a/exam/.claude/skills/exam/validate.py b/exam/.claude/skills/exam/validate.py new file mode 100644 index 0000000..9592879 --- /dev/null +++ b/exam/.claude/skills/exam/validate.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Validate a question JSON file against schema/question.schema.json. + +Usage: + python3 validate.py + echo '' | python3 validate.py - + +Exit codes: 0 = valid, 1 = errors found, 2 = usage error. +""" +import json +import sys +from pathlib import Path + +# Resolve schema path relative to repo root (3 levels up from this script) +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parent.parent.parent +SCHEMA_PATH = REPO_ROOT / "schema" / "question.schema.json" + + +def load_json(path: str) -> dict: + if path == "-": + return json.load(sys.stdin) + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def validate(data: dict, schema: dict) -> list[str]: + """Validate data against schema. Returns list of error strings.""" + errors = [] + try: + from jsonschema import Draft7Validator + validator = Draft7Validator(schema) + for err in sorted(validator.iter_errors(data), key=lambda e: list(e.path)): + path = ".".join(str(p) for p in err.absolute_path) or "(root)" + errors.append(f"{path}: {err.message}") + return errors + except ImportError: + pass + + # Fallback: basic manual validation + required_top = ["topic", "type", "schema_version", "questions"] + for field in required_top: + if field not in data: + errors.append(f"Missing required field: {field}") + + valid_types = [ + "single_choice", "multiple_choice", "true_false", + "fill_blank", "short_answer", "code_reading", "scenario", + ] + if data.get("type") not in valid_types: + errors.append(f"Invalid type: {data.get('type')}") + + if data.get("schema_version") != "1.0.0": + errors.append(f"schema_version must be '1.0.0', got '{data.get('schema_version')}'") + + for i, q in enumerate(data.get("questions", [])): + prefix = f"questions[{i}]" + for field in ["id", "type", "difficulty", "tags", "question", "explanation"]: + if field not in q: + errors.append(f"{prefix}: missing '{field}'") + if q.get("type") not in valid_types: + errors.append(f"{prefix}: invalid type '{q.get('type')}'") + if "difficulty" in q and not (1 <= q["difficulty"] <= 5): + errors.append(f"{prefix}: difficulty must be 1-5") + if "id" in q: + import re + if not re.match(r"^(sc|mc|tf|fb|sa|cr|sn)-\d{3}$", q["id"]): + errors.append(f"{prefix}: id '{q['id']}' doesn't match pattern {{type}}-{{000}}") + + return errors + + +def main(): + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(2) + + schema = load_json(str(SCHEMA_PATH)) + data = load_json(sys.argv[1]) + errors = validate(data, schema) + + if errors: + print(f"❌ {len(errors)} error(s):") + for e in errors: + print(f" • {e}") + sys.exit(1) + else: + print(f"✅ Valid — {len(data.get('questions', []))} question(s)") + sys.exit(0) + + +if __name__ == "__main__": + main()