refactor(exam): SKILL 中文化,增加用户确认阶段,默认子代理分批处理

This commit is contained in:
2026-09-02 21:43:53 +08:00
parent 9e262abb23
commit 8759ba4659
8 changed files with 916 additions and 113 deletions
+244 -86
View File
@@ -1,20 +1,21 @@
---
name: exam
description: "Generate, validate, and manage CS exam questions. Trigger: '生成题目', 'generate questions', 'validate', '添加题目', '/exam'"
---
# CS 知识应试强化 — 题目生成 Skill
为 `topics/` 目录生成符合 JSON Schema 的题目文件。
从 `examination` 仓库生成符合 JSON Schema 的题目文件,自动校验、合并、更新元数据,推送到远程。
## 目录结构
## 前置条件
- Python 3.8+(用于校验和合并脚本)
- SSH 访问 `git@47.121.181.112:222`(用于 clone/push)
- `topics/index.json` 包含子主题索引
## 仓库结构
```
examination/
├── schema/
│ ├── question.schema.json # 题目 JSON Schema (Draft-07)
│ ├── prompt-template.md # 提示词模板参考
│ └── templates/ # 各题型示例
│ ├── prompt-template.md # 题目生成提示词模板
│ └── templates/ # 各题型示例 JSON
├── topics/
│ ├── index.json # 主题索引
│ └── {group}/{subtopic}/
@@ -23,111 +24,268 @@ examination/
│ └── single_choice.json # 单选题
└── exam/ # 本 skill 目录
├── SKILL.md
├── validate.py # Python 校验脚本
└── validate.mjs # Node.js 校验脚本
├── schema/
│ └── question.schema.json
└── scripts/
├── validate.py
├── validate.mjs
├── merge_questions.py
├── update_meta.py
├── read_context.json
└── validate_and_merge.json
```
## 工作流
## 执行流程
### 1. 确定目标
> **重要:默认使用子代理(SubAgent)分批次处理。** 生成题目是 token 密集型任务,为避免主会话上下文爆炸,应将每批题目的生成工作委派给子代理执行。主会话负责规划、确认和汇总。
用户会说类似「生成填空题」「给 gc-jvm 加10道选择题」。你需要:
### 阶段 0:与用户确认方案
1. 确认 **子主题**:读 `topics/index.json` 找到对应 slug 和 path
2. 确认 **题型**:fill_blank / single_choice / multiple_choice / true_false / short_answer / code_reading / scenario
3. 确认 **数量**:默认 10
**在执行任何操作之前,必须先与用户确认以下信息。** 不要假设默认值。
如果用户没指定,主动询问。
1. **子主题(subtopic)**:读取仓库中的 `topics/index.json`,列出可用的子主题供用户选择。如果用户已经指定了子主题,验证其是否存在。
2. **题型与数量**:询问用户需要生成哪些题型、每种多少道。可以一次性给出建议(如「建议先生成 10 道单选题 + 5 道判断题」),但最终必须等用户确认。
3. **展示确认摘要**:在开始生成前,向用户展示如下摘要并等待确认:
### 2. 读取上下文
```
📋 生成方案确认
━━━━━━━━━━━━━━
子主题:gc-jvm(JVM 垃圾回收)
分组: qunar-ai-fullstack
题型: 单选 × 10、判断 × 5
━━━━━━━━━━━━━━
确认后开始生成 ✅
```
用户确认后才进入下一阶段。
### 阶段 1:克隆仓库
```bash
# 查看子主题已有题目,避免重复
cat topics/{group}/{subtopic}/meta.json
cat topics/{group}/{subtopic}/{type}.json # 如果存在
REPO_DIR=$(mktemp -d)
git clone --depth 1 ssh://git@47.121.181.112:222/wonder/examination.git "$REPO_DIR"
```
### 3. 生成题目
### 阶段 2:读取上下文(子代理)
你(Claude)直接生成 JSON。遵循以下规则:
派发一个子代理,执行以下任务:
- 读取 `topics/index.json` 确认子主题存在
- 读取该子主题的 `meta.json`(获取 tags、已有题目信息)
- 读取同类型已有的题目文件(获取已有 ID,避免重复)
- 将汇总信息返回给主会话
- 输出格式严格匹配 `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 作为参考
使用本 skill 自带的批量脚本 `scripts/read_context.json` 可以一次完成上述读取:
```
run_tool_batch(
file_path="<skill_dir>/scripts/read_context.json",
args={
"topics_dir": "<REPO_DIR>/topics",
"group": "<用户确认的分组>",
"subtopic": "<用户确认的子主题>",
"question_type": "<用户确认的题型>"
}
)
```
### 阶段 3:生成题目(子代理)
> **这是 token 消耗最大的阶段,务必使用子代理处理。**
根据阶段 2 返回的上下文,**为每种题型分别派发子代理**来生成题目。每个子代理的任务是:
1. 根据子主题内容和已有题目,生成指定数量的新题目
2. 严格遵循 Schema 格式(见下方「题目格式规范」)
3. 将生成的 JSON 写入临时文件(如 `/tmp/exam_gen_{type}_{batch}.json`)
**子代理提示词模板:**
```
你是题目生成专家。请为子主题 "{subtopic_name}" 生成 {count} 道 {type_name} 题。
【上下文】
{从阶段 2 获取的 meta.json 和已有题目摘要}
【要求】
- 严格遵循 JSON Schema(见下方规范)
- ID 从 {next_id} 开始递增(如 fb-001, fb-002...)
- 难度分布在 1-5 之间,合理分布
- tags 参考子主题已有的 tags
- explanation 必须详细,解释为什么对/错
- source 填 null,related 填空数组
生成后,将 JSON 写入临时文件进行校验:
```bash
# 写入临时文件
cat > /tmp/exam_gen.json << 'ENDJSON'
{ ...生成的JSON... }
ENDJSON
```
### 4. 校验
根据可用运行时选择其一:
```bash
# Python (优先)
python3 exam/validate.py /tmp/exam_gen.json
# Node.js (备选)
node exam/validate.mjs /tmp/exam_gen.json
```
- 校验通过 → 继续写入
- 校验失败 → 修正 JSON 后重新校验,直到通过
### 5. 写入文件
校验通过后:
1. 如果目标题型文件已存在,读取现有 questions 数组,追加新题目(不覆盖旧题)
2. 如果不存在,创建新文件
3. 更新 `meta.json` 的 `question_files` 和 `stats`
4. 更新 `topics/index.json` 中对应子主题的 `stats`
文件格式:
```json
【输出】
将完整 JSON 写入文件 /tmp/exam_gen_{type}_{batch}.json,格式:
{
"topic": "{subtopic-slug}",
"topic": "{subtopic_slug}",
"type": "{question_type}",
"schema_version": "1.0.0",
"generated": "{ISO-8601}",
"generated": "{ISO 8601 时间戳}",
"questions": [ ... ]
}
```
### 6. 确认
**如果题目数量较多(如 > 15 道),建议拆分成多个批次**,每批 5-10 道,分别派发子代理并行处理。
报告生成结果:题型、数量、文件路径。
### 阶段 4:校验、合并、更新元数据(子代理)
提示用户在线查看:
所有批次的题目生成完成后,派发子代理执行校验与合并:
> ✅ 已完成!前往 http://47.121.181.112:30000/ 查看新题目。
```
run_tool_batch(
file_path="<skill_dir>/scripts/validate_and_merge.json",
args={
"skill_dir": "<skill_dir>",
"topics_dir": "<REPO_DIR>/topics",
"group": "<group>",
"subtopic": "<subtopic>",
"question_type": "<question_type>",
"generated_file": "/tmp/exam_gen_{type}_{batch}.json"
}
)
```
## 校验脚本
如果是多批次生成,需要对每个批次依次执行合并,或者先合并各批次为一个临时文件,再统一合并到已有文件。
两个脚本功能相同,检测可用环境后选用:
### 阶段 5:提交推送
- `exam/validate.py` — 优先用 `jsonschema`,不可用时回退到基础校验
- `exam/validate.mjs` — 纯 Node.js 内置模块,无外部依赖
```bash
cd "$REPO_DIR"
git add -A
git commit -m "feat: add {count} {type} questions for {subtopic}"
git push origin main
```
退出码:0=通过,1=有错误,2=用法错误。
### 阶段 6:汇报结果
## 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[] |
```
✅ 题目生成完成
━━━━━━━━━━━━━━━━━━━━━━
子主题:gc-jvm
新增: 单选 × 10、判断 × 5
累计: 35 题(单选 20、判断 10、填空 5)
提交: feat: add 15 questions for gc-jvm
━━━━━━━━━━━━━━━━━━━━━━
```
---
## 批量脚本参数
### read_context.json
| 参数 | 说明 | 示例 |
|------|------|------|
| `topics_dir` | 仓库内 `topics/` 目录的绝对路径 | `/tmp/xxxxx/topics` |
| `group` | 主题分组名 | `qunar-ai-fullstack` |
| `subtopic` | 子主题 slug | `gc-jvm` |
| `question_type` | 题型 | `single_choice` |
### validate_and_merge.json
| 参数 | 说明 | 示例 |
|------|------|------|
| `skill_dir` | 本 skill 目录的绝对路径 | `/home/.../skills/exam` |
| `topics_dir` | 同上 | 同上 |
| `group` | 同上 | 同上 |
| `subtopic` | 同上 | 同上 |
| `question_type` | 同上 | 同上 |
| `generated_file` | 生成的题目 JSON 文件绝对路径 | `/tmp/exam_gen_sc_1.json` |
## 批量执行失败处理
如果批量脚本执行失败:
1. 首先检查所有参数是否正确传入,尤其是 `args` 不能为空
2. 根据错误信息修正参数后重试
3. 如果脚本方式持续失败,回退到手动逐步执行(见下方「手动执行参考」)
4. 完成任务后告知用户:「批量执行遇到了问题,已手动完成。是否需要调整 skill 的批量脚本以便下次正常使用?」
---
## 手动执行参考
### 读取上下文
```bash
cat "$REPO_DIR/topics/index.json"
cat "$REPO_DIR/topics/{group}/{subtopic}/meta.json"
cat "$REPO_DIR/topics/{group}/{subtopic}/{type}.json" # 如果存在
```
### 校验
```bash
python3 <skill_dir>/scripts/validate.py /tmp/exam_generated.json
```
### 合并
```bash
python3 <skill_dir>/scripts/merge_questions.py \
"$REPO_DIR/topics/{group}/{subtopic}/{type}.json" \
/tmp/exam_generated.json \
/tmp/exam_merged.json
cp /tmp/exam_merged.json "$REPO_DIR/topics/{group}/{subtopic}/{type}.json"
```
### 更新元数据
```bash
python3 <skill_dir>/scripts/update_meta.py \
"$REPO_DIR/topics" {group} {subtopic} {type}
```
---
## 题目格式规范
ID 格式:`{type_short}-{seq}`,序号从已有最大值 +1 开始,三位数补零。
| 题型 | type | type_short | 关键字段 |
|------|------|------------|----------|
| 填空 | `fill_blank` | `fb` | `answer: string[]`, `answer_rule: "any"/"all"/"ordered"` |
| 单选 | `single_choice` | `sc` | `options: {A-D}`, `answer: string` |
| 多选 | `multiple_choice` | `mc` | `options: {A-D}`, `answer: string[]` |
| 判断 | `true_false` | `tf` | `answer: boolean` |
| 简答 | `short_answer` | `sa` | `answer: string`, `keywords: string[]`, `scoring_rubric: string` |
| 代码阅读 | `code_reading` | `cr` | `code`, `language`, `sub_questions[]` |
| 场景分析 | `scenario` | `sn` | `context`, `sub_questions[]` |
### 通用字段
每道题必须包含:
- `id` — 唯一标识,格式 `type_short-NNN`
- `type` — 题型枚举值
- `difficulty` — 1 到 5 的整数
- `tags` — 标签数组,参考子主题 meta.json
- `question` — 题目文本
- `explanation` — 详细解析
- `source` — 填 `null`
- `related` — 填空数组 `[]`
### sub_questions 规范
`code_reading` 和 `scenario` 题型使用 `sub_questions` 数组,每个子问题需要:
- `index` — 从 1 开始的序号
- `type` — 子问题的题型(通常是 `single_choice` 或 `short_answer`)
- `question` — 子问题文本
- `answer` — 答案
- `explanation` — 解析
- 选择题类子问题还需 `options` 字段
---
## 备注
- 校验脚本优先使用 `jsonschema` 库(Python),不可用时回退到基础校验
- Node.js 校验脚本(`validate.mjs`)为纯内置模块,无外部依赖,可作为备选
- 合并脚本自动去重(基于 question ID),不会覆盖已有题目
- 元数据更新脚本自动计算 `question_files` 和 `stats.by_type` 统计信息
- 所有脚本退出码:0=成功,1=有错误,2=用法错误
- Schema 文件已内置于 `<skill_dir>/schema/question.schema.json`,与仓库版本一致
+334
View File
@@ -0,0 +1,334 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Examination Question Set",
"description": "CS 知识应试强化系统 - 题目集 JSON Schema",
"type": "object",
"required": ["topic", "type", "schema_version", "questions"],
"properties": {
"topic": {
"type": "string",
"description": "主题 slug"
},
"type": {
"type": "string",
"enum": ["single_choice", "multiple_choice", "true_false", "fill_blank", "short_answer", "code_reading", "scenario"],
"description": "题目类型"
},
"schema_version": {
"type": "string",
"const": "1.0.0"
},
"generated": {
"type": "string",
"format": "date-time"
},
"questions": {
"type": "array",
"items": {
"$ref": "#/definitions/question"
}
}
},
"definitions": {
"question": {
"type": "object",
"required": ["id", "type", "difficulty", "tags", "question", "explanation"],
"properties": {
"id": {
"type": "string",
"pattern": "^(sc|mc|tf|fb|sa|cr|sn)-[0-9]{3}$",
"description": "题目唯一标识,格式: typecode-number(如 fb-001, sc-003)"
},
"type": {
"type": "string",
"enum": ["single_choice", "multiple_choice", "true_false", "fill_blank", "short_answer", "code_reading", "scenario"]
},
"difficulty": {
"type": "integer",
"minimum": 1,
"maximum": 5
},
"tags": {
"type": "array",
"items": { "type": "string" }
},
"question": {
"type": "string"
},
"explanation": {
"type": "string"
},
"source": {
"type": ["string", "null"],
"default": null
},
"related": {
"type": "array",
"items": { "type": "string" },
"default": []
},
"options": {
"description": "选项(选择题类型)或 null"
},
"answer": {
"description": "答案(根据题型不同而不同)"
},
"answer_rule": {
"type": "string",
"enum": ["any", "all", "ordered"],
"default": "any",
"description": "填空题答案匹配规则"
},
"keywords": {
"type": "array",
"items": { "type": "string" },
"description": "简答题评分关键词"
},
"scoring_rubric": {
"type": "string",
"description": "简答题评分标准"
},
"code": {
"type": ["string", "null"],
"description": "代码片段(代码阅读题和场景题)"
},
"language": {
"type": "string",
"description": "编程语言(代码阅读题必填)"
},
"context": {
"type": "string",
"description": "场景描述(场景分析题必填)"
},
"sub_questions": {
"type": "array",
"items": {
"$ref": "#/definitions/sub_question"
},
"description": "子问题列表(代码阅读题和场景分析题)"
}
},
"allOf": [
{
"if": { "properties": { "type": { "const": "single_choice" } } },
"then": {
"required": ["options", "answer"],
"properties": {
"options": {
"type": "object",
"patternProperties": {
"^[A-Z]$": { "type": "string" }
},
"additionalProperties": false,
"minProperties": 2
},
"answer": {
"type": "string",
"pattern": "^[A-Z]$"
}
}
}
},
{
"if": { "properties": { "type": { "const": "multiple_choice" } } },
"then": {
"required": ["options", "answer"],
"properties": {
"options": {
"type": "object",
"patternProperties": {
"^[A-Z]$": { "type": "string" }
},
"additionalProperties": false,
"minProperties": 2
},
"answer": {
"type": "array",
"items": { "type": "string", "pattern": "^[A-Z]$" },
"minItems": 1,
"uniqueItems": true
}
}
}
},
{
"if": { "properties": { "type": { "const": "true_false" } } },
"then": {
"required": ["answer"],
"properties": {
"answer": { "type": "boolean" }
}
}
},
{
"if": { "properties": { "type": { "const": "fill_blank" } } },
"then": {
"required": ["answer"],
"properties": {
"answer": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
},
"answer_rule": {
"type": "string",
"enum": ["any", "all", "ordered"],
"default": "any"
}
}
}
},
{
"if": { "properties": { "type": { "const": "short_answer" } } },
"then": {
"required": ["answer"],
"properties": {
"answer": { "type": "string" },
"keywords": {
"type": "array",
"items": { "type": "string" }
},
"scoring_rubric": { "type": "string" }
}
}
},
{
"if": { "properties": { "type": { "const": "code_reading" } } },
"then": {
"required": ["code", "language", "sub_questions"],
"properties": {
"code": { "type": "string" },
"language": { "type": "string" },
"sub_questions": {
"type": "array",
"items": { "$ref": "#/definitions/sub_question" },
"minItems": 1
}
}
}
},
{
"if": { "properties": { "type": { "const": "scenario" } } },
"then": {
"required": ["context", "sub_questions"],
"properties": {
"context": { "type": "string" },
"code": { "type": ["string", "null"] },
"sub_questions": {
"type": "array",
"items": { "$ref": "#/definitions/sub_question" },
"minItems": 1
}
}
}
}
]
},
"sub_question": {
"type": "object",
"required": ["index", "type", "question", "answer", "explanation"],
"properties": {
"index": {
"type": "integer",
"minimum": 1
},
"type": {
"type": "string",
"enum": ["single_choice", "multiple_choice", "true_false", "fill_blank", "short_answer", "code_reading", "scenario"]
},
"question": { "type": "string" },
"answer": {},
"explanation": { "type": "string" },
"options": {
"type": "object",
"patternProperties": {
"^[A-Z]$": { "type": "string" }
},
"additionalProperties": false
},
"keywords": {
"type": "array",
"items": { "type": "string" }
},
"scoring_rubric": { "type": "string" }
},
"allOf": [
{
"if": { "properties": { "type": { "const": "single_choice" } } },
"then": {
"required": ["options"],
"properties": {
"options": {
"type": "object",
"patternProperties": {
"^[A-Z]$": { "type": "string" }
},
"additionalProperties": false,
"minProperties": 2
},
"answer": {
"type": "string",
"pattern": "^[A-Z]$"
}
}
}
},
{
"if": { "properties": { "type": { "const": "multiple_choice" } } },
"then": {
"required": ["options"],
"properties": {
"options": {
"type": "object",
"patternProperties": {
"^[A-Z]$": { "type": "string" }
},
"additionalProperties": false,
"minProperties": 2
},
"answer": {
"type": "array",
"items": { "type": "string", "pattern": "^[A-Z]$" },
"minItems": 1,
"uniqueItems": true
}
}
}
},
{
"if": { "properties": { "type": { "const": "true_false" } } },
"then": {
"properties": {
"answer": { "type": "boolean" }
}
}
},
{
"if": { "properties": { "type": { "const": "fill_blank" } } },
"then": {
"properties": {
"answer": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
}
}
}
},
{
"if": { "properties": { "type": { "const": "short_answer" } } },
"then": {
"properties": {
"answer": { "type": "string" },
"keywords": {
"type": "array",
"items": { "type": "string" }
},
"scoring_rubric": { "type": "string" }
}
}
}
]
}
}
}
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Merge new questions into an existing question file, deduplicating by ID.
Usage:
python3 merge_questions.py <existing_file> <new_file> <output_file>
If existing_file does not exist or is empty, treats it as having no questions.
Outputs a JSON summary to stdout with counts.
Exit codes: 0 = success, 1 = error, 2 = usage error.
"""
import json
import sys
from pathlib import Path
def load_json(path: str) -> dict | None:
"""Load JSON file, return None if missing or empty."""
p = Path(path)
if not p.exists() or p.stat().st_size == 0:
return None
with open(p, encoding="utf-8") as f:
return json.load(f)
def merge(existing: dict | None, new_data: dict) -> dict:
"""Merge new questions into existing, deduplicating by ID."""
existing_questions = (existing or {}).get("questions", [])
new_questions = new_data.get("questions", [])
existing_ids = {q["id"] for q in existing_questions}
added = [q for q in new_questions if q["id"] not in existing_ids]
skipped = len(new_questions) - len(added)
merged = existing_questions + added
# Use new_data as base, replace questions with merged
result = dict(new_data)
result["questions"] = merged
return result, {
"existing_count": len(existing_questions),
"new_count": len(new_questions),
"added_count": len(added),
"skipped_count": skipped,
"total_count": len(merged),
}
def main():
if len(sys.argv) != 4:
print(f"Usage: {sys.argv[0]} <existing_file> <new_file> <output_file>",
file=sys.stderr)
sys.exit(2)
existing_path = sys.argv[1]
new_path = sys.argv[2]
output_path = sys.argv[3]
existing = load_json(existing_path)
try:
with open(new_path, encoding="utf-8") as f:
new_data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"❌ Cannot read new questions file: {e}", file=sys.stderr)
sys.exit(1)
merged, stats = merge(existing, new_data)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(merged, f, ensure_ascii=False, indent=2)
print(json.dumps(stats, ensure_ascii=False))
sys.exit(0)
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
[
{
"tool_name": "read_file",
"arguments": {
"file_path": "${args.topics_dir}/index.json"
}
},
{
"tool_name": "execute_shell_command",
"arguments": {
"command": "cat '${args.topics_dir}/${args.group}/${args.subtopic}/meta.json' 2>/dev/null || echo '{\"tags\": []}'"
}
},
{
"tool_name": "execute_shell_command",
"arguments": {
"command": "cat '${args.topics_dir}/${args.group}/${args.subtopic}/${args.question_type}.json' 2>/dev/null || echo '{\"questions\": []}'"
}
}
]
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Update meta.json and topics/index.json after adding questions.
Usage:
python3 update_meta.py <topics_dir> <group> <subtopic> <question_type>
- Reads the question file at topics/{group}/{subtopic}/{type}.json
- Updates/creates topics/{group}/{subtopic}/meta.json
- Updates/creates topics/index.json with subtopic stats
meta.json format:
{
"slug": "gc-jvm",
"name": "...",
"description": "...",
"tags": [...],
"difficulty_range": [1, 5],
"schema_version": "1.0.0",
"question_files": ["fill_blank", "single_choice"],
"stats": { "total": 20, "by_type": { "fill_blank": 10, "single_choice": 10 } }
}
index.json format:
{
"version": "1.0.0",
"updated": "...",
"topics": [
{
"slug": "qunar-ai-fullstack",
"name": "...",
"subtopics": [
{ "slug": "gc-jvm", "path": "topics/qunar-ai-fullstack/gc-jvm", "stats": {...} }
]
}
]
}
Exit codes: 0 = success, 1 = error, 2 = usage error.
"""
import json
import sys
from pathlib import Path
from datetime import date
def load_json(path: Path) -> dict:
if path.exists() and path.stat().st_size > 0:
with open(path, encoding="utf-8") as f:
return json.load(f)
return {}
def save_json(path: Path, data: dict):
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
def main():
if len(sys.argv) != 5:
print(f"Usage: {sys.argv[0]} <topics_dir> <group> <subtopic> <question_type>",
file=sys.stderr)
sys.exit(2)
topics_dir = Path(sys.argv[1])
group = sys.argv[2]
subtopic = sys.argv[3]
q_type = sys.argv[4]
subtopic_dir = topics_dir / group / subtopic
question_file = subtopic_dir / f"{q_type}.json"
meta_file = subtopic_dir / "meta.json"
index_file = topics_dir / "index.json"
if not question_file.exists():
print(f"❌ Question file not found: {question_file}", file=sys.stderr)
sys.exit(1)
# Load question file
q_data = load_json(question_file)
questions = q_data.get("questions", [])
count = len(questions)
# Compute difficulty stats
difficulties = [q.get("difficulty", 3) for q in questions]
diff_range = [min(difficulties), max(difficulties)] if difficulties else [1, 5]
# Compute tag union
all_tags = sorted(set(
tag for q in questions for tag in q.get("tags", [])
))
# --- Update meta.json ---
meta = load_json(meta_file)
meta["slug"] = meta.get("slug", subtopic)
meta["name"] = meta.get("name", subtopic.replace("-", " ").title())
meta.setdefault("description", "")
meta["tags"] = all_tags
meta["difficulty_range"] = diff_range
meta["schema_version"] = "1.0.0"
meta["updated"] = date.today().isoformat()
# question_files is an array of type names
if "question_files" not in meta:
meta["question_files"] = []
if q_type not in meta["question_files"]:
meta["question_files"].append(q_type)
# stats has total and by_type
if "stats" not in meta:
meta["stats"] = {"total": 0, "by_type": {}}
meta["stats"]["by_type"][q_type] = count
meta["stats"]["total"] = sum(meta["stats"]["by_type"].values())
save_json(meta_file, meta)
# --- Update index.json ---
index = load_json(index_file)
index.setdefault("version", "1.0.0")
index["updated"] = date.today().isoformat()
index.setdefault("topics", [])
# Find or create the group topic entry
group_entry = None
for t in index["topics"]:
if t.get("slug") == group:
group_entry = t
break
if group_entry is None:
group_entry = {
"slug": group,
"name": meta.get("name", group.replace("-", " ").title()),
"description": meta.get("description", ""),
"subtopics": [],
}
index["topics"].append(group_entry)
group_entry.setdefault("subtopics", [])
# Find or create the subtopic entry
sub_entry = None
for s in group_entry["subtopics"]:
if s.get("slug") == subtopic:
sub_entry = s
break
if sub_entry is None:
sub_entry = {
"slug": subtopic,
"name": meta.get("name", subtopic.replace("-", " ").title()),
"description": meta.get("description", ""),
"path": f"topics/{group}/{subtopic}",
"stats": {"total": 0, "by_type": {}},
}
group_entry["subtopics"].append(sub_entry)
sub_entry["stats"]["by_type"][q_type] = count
sub_entry["stats"]["total"] = sum(sub_entry["stats"]["by_type"].values())
save_json(index_file, index)
# Output summary
result = {
"subtopic": subtopic,
"question_type": q_type,
"count": count,
"total": meta["stats"]["total"],
"meta_file": str(meta_file),
"index_file": str(index_file),
}
print(json.dumps(result, ensure_ascii=False))
sys.exit(0)
if __name__ == "__main__":
main()
+14 -10
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Validate a question JSON file against schema/question.schema.json.
* Validate a question JSON file against the bundled schema.
*
* Usage:
* node validate.mjs <json_file>
@@ -9,19 +9,12 @@
* Exit codes: 0 = valid, 1 = errors found, 2 = usage error.
*/
import { readFileSync } from "node:fs";
import { readFileSync, existsSync } 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 SCHEMA_PATH = resolve(__dirname, "../schema/question.schema.json");
const VALID_TYPES = [
"single_choice", "multiple_choice", "true_false",
@@ -30,6 +23,11 @@ const VALID_TYPES = [
const ID_RE = /^(sc|mc|tf|fb|sa|cr|sn)-\d{3}$/;
function loadJson(path) {
const raw = path === "-" ? readFileSync(0, "utf-8") : readFileSync(path, "utf-8");
return JSON.parse(raw);
}
function validate(data) {
const errors = [];
const requiredTop = ["topic", "type", "schema_version", "questions"];
@@ -59,6 +57,12 @@ function main() {
console.error(`Usage: node ${process.argv[1]} <json_file | ->`);
process.exit(2);
}
if (!existsSync(SCHEMA_PATH)) {
console.error(`❌ Schema not found: ${SCHEMA_PATH}`);
process.exit(2);
}
const data = loadJson(process.argv[2]);
const errors = validate(data);
+21 -16
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Validate a question JSON file against schema/question.schema.json.
"""Validate a question JSON file against the bundled schema.
Usage:
python3 validate.py <json_file>
@@ -8,13 +8,18 @@ Usage:
Exit codes: 0 = valid, 1 = errors found, 2 = usage error.
"""
import json
import re
import sys
from pathlib import Path
# Resolve schema path relative to repo root (1 level up from this script)
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent
SCHEMA_PATH = REPO_ROOT / "schema" / "question.schema.json"
SCHEMA_PATH = SCRIPT_DIR.parent / "schema" / "question.schema.json"
VALID_TYPES = [
"single_choice", "multiple_choice", "true_false",
"fill_blank", "short_answer", "code_reading", "scenario",
]
ID_RE = re.compile(r"^(sc|mc|tf|fb|sa|cr|sn)-\d{3}$")
def load_json(path: str) -> dict:
@@ -27,27 +32,25 @@ def load_json(path: str) -> dict:
def validate(data: dict, schema: dict) -> list[str]:
"""Validate data against schema. Returns list of error strings."""
errors = []
# Try jsonschema if available
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}")
path_str = ".".join(str(p) for p in err.absolute_path) or "(root)"
errors.append(f"{path_str}: {err.message}")
return errors
except ImportError:
pass
# Fallback: basic manual validation
# Fallback: 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:
if data.get("type") not in VALID_TYPES:
errors.append(f"Invalid type: {data.get('type')}")
if data.get("schema_version") != "1.0.0":
@@ -58,13 +61,11 @@ def validate(data: dict, schema: dict) -> list[str]:
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:
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"]):
if "id" in q and not ID_RE.match(q["id"]):
errors.append(f"{prefix}: id '{q['id']}' doesn't match pattern {{type}}-{{000}}")
return errors
@@ -75,6 +76,10 @@ def main():
print(f"Usage: {sys.argv[0]} <json_file | ->", file=sys.stderr)
sys.exit(2)
if not SCHEMA_PATH.exists():
print(f"❌ Schema not found: {SCHEMA_PATH}", file=sys.stderr)
sys.exit(2)
schema = load_json(str(SCHEMA_PATH))
data = load_json(sys.argv[1])
errors = validate(data, schema)
+26
View File
@@ -0,0 +1,26 @@
[
{
"tool_name": "execute_shell_command",
"arguments": {
"command": "python3 ${args.skill_dir}/scripts/validate.py ${args.generated_file}"
}
},
{
"tool_name": "execute_shell_command",
"arguments": {
"command": "python3 ${args.skill_dir}/scripts/merge_questions.py '${args.topics_dir}/${args.group}/${args.subtopic}/${args.question_type}.json' ${args.generated_file} /tmp/exam_merged.json"
}
},
{
"tool_name": "execute_shell_command",
"arguments": {
"command": "cp /tmp/exam_merged.json '${args.topics_dir}/${args.group}/${args.subtopic}/${args.question_type}.json'"
}
},
{
"tool_name": "execute_shell_command",
"arguments": {
"command": "python3 ${args.skill_dir}/scripts/update_meta.py ${args.topics_dir} ${args.group} ${args.subtopic} ${args.question_type}"
}
}
]