From 8759ba4659dd7cab4d79b846cd0b9481282bbf98 Mon Sep 17 00:00:00 2001 From: wonder Date: Wed, 2 Sep 2026 21:43:53 +0800 Subject: [PATCH] =?UTF-8?q?refactor(exam):=20SKILL=20=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E5=8C=96=EF=BC=8C=E5=A2=9E=E5=8A=A0=E7=94=A8=E6=88=B7=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E9=98=B6=E6=AE=B5=EF=BC=8C=E9=BB=98=E8=AE=A4=E5=AD=90?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E5=88=86=E6=89=B9=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- exam/SKILL.md | 330 +++++++++++++++++++------- exam/schema/question.schema.json | 334 +++++++++++++++++++++++++++ exam/scripts/merge_questions.py | 78 +++++++ exam/scripts/read_context.json | 20 ++ exam/scripts/update_meta.py | 178 ++++++++++++++ exam/{ => scripts}/validate.mjs | 24 +- exam/{ => scripts}/validate.py | 39 ++-- exam/scripts/validate_and_merge.json | 26 +++ 8 files changed, 916 insertions(+), 113 deletions(-) create mode 100644 exam/schema/question.schema.json create mode 100644 exam/scripts/merge_questions.py create mode 100644 exam/scripts/read_context.json create mode 100644 exam/scripts/update_meta.py rename exam/{ => scripts}/validate.mjs (87%) rename exam/{ => scripts}/validate.py (71%) create mode 100644 exam/scripts/validate_and_merge.json diff --git a/exam/SKILL.md b/exam/SKILL.md index 1d8a600..500b9a9 100644 --- a/exam/SKILL.md +++ b/exam/SKILL.md @@ -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="/scripts/read_context.json", + args={ + "topics_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="/scripts/validate_and_merge.json", + args={ + "skill_dir": "", + "topics_dir": "/topics", + "group": "", + "subtopic": "", + "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 /scripts/validate.py /tmp/exam_generated.json +``` + +### 合并 + +```bash +python3 /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 /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 文件已内置于 `/schema/question.schema.json`,与仓库版本一致 diff --git a/exam/schema/question.schema.json b/exam/schema/question.schema.json new file mode 100644 index 0000000..9192e45 --- /dev/null +++ b/exam/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" } + } + } + } + ] + } + } +} diff --git a/exam/scripts/merge_questions.py b/exam/scripts/merge_questions.py new file mode 100644 index 0000000..2fe3e58 --- /dev/null +++ b/exam/scripts/merge_questions.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Merge new questions into an existing question file, deduplicating by ID. + +Usage: + python3 merge_questions.py + +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]} ", + 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() diff --git a/exam/scripts/read_context.json b/exam/scripts/read_context.json new file mode 100644 index 0000000..b57e74d --- /dev/null +++ b/exam/scripts/read_context.json @@ -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\": []}'" + } + } +] diff --git a/exam/scripts/update_meta.py b/exam/scripts/update_meta.py new file mode 100644 index 0000000..5f6119b --- /dev/null +++ b/exam/scripts/update_meta.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Update meta.json and topics/index.json after adding questions. + +Usage: + python3 update_meta.py + +- 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]} ", + 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() diff --git a/exam/validate.mjs b/exam/scripts/validate.mjs similarity index 87% rename from exam/validate.mjs rename to exam/scripts/validate.mjs index 928c088..ce6615e 100644 --- a/exam/validate.mjs +++ b/exam/scripts/validate.mjs @@ -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 @@ -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]} `); 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); diff --git a/exam/validate.py b/exam/scripts/validate.py similarity index 71% rename from exam/validate.py rename to exam/scripts/validate.py index 4679c26..39047cf 100644 --- a/exam/validate.py +++ b/exam/scripts/validate.py @@ -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 @@ -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,14 +61,12 @@ 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"]): - errors.append(f"{prefix}: id '{q['id']}' doesn't match pattern {{type}}-{{000}}") + 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]} ", 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) diff --git a/exam/scripts/validate_and_merge.json b/exam/scripts/validate_and_merge.json new file mode 100644 index 0000000..7fed235 --- /dev/null +++ b/exam/scripts/validate_and_merge.json @@ -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}" + } + } +]