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.
This commit is contained in:
@@ -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[] |
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validate a question JSON file against schema/question.schema.json.
|
||||
*
|
||||
* Usage:
|
||||
* node validate.mjs <json_file>
|
||||
* echo '<json>' | 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]} <json_file | ->`);
|
||||
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();
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a question JSON file against schema/question.schema.json.
|
||||
|
||||
Usage:
|
||||
python3 validate.py <json_file>
|
||||
echo '<json>' | 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]} <json_file | ->", 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()
|
||||
Reference in New Issue
Block a user