refactor(exam): SKILL 中文化,增加用户确认阶段,默认子代理分批处理
This commit is contained in:
+244
-86
@@ -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`,与仓库版本一致
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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\": []}'"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user