@clawhub-kagurananaga-2c1b18d02c
拾遗 · 通用考试备考追踪 Skill。适用于任何考试——GRE、雅思、考研、注会、高考、期末…… 核心功能:识别错题截图 → 自由标签分类 → 词库积累复用 → 二刷提醒 → 导出 Excel。 触发关键词:做了题、错了、截图发来、导出错题、待二刷、记得、不记得、换考试。 图片消息直接触发识别。
---
name: shiyi
description: >
拾遗 · 通用考试备考追踪 Skill。适用于任何考试——GRE、雅思、考研、注会、高考、期末……
核心功能:识别错题截图 → 自由标签分类 → 词库积累复用 → 二刷提醒 → 导出 Excel。
触发关键词:做了题、错了、截图发来、导出错题、待二刷、记得、不记得、换考试。
图片消息直接触发识别。
---
# 拾遗 · 通用备考追踪 Skill
## 一、首次安装
Skill 加载时发一条消息,问你在备考什么考试,之后不再重复。
```
拾遗已安装。
你在备考什么考试?比如:
GRE、雅思、考研英语、注会、高考、期末……
不在列表里也没关系。
```
输入考试名称后,Skill 加载对应的识别背景知识,之后截图识别精度更高。
如果后来换考试,发「换考试」即可重新配置,不影响已有错题记录。
---
## 二、与朱批录的核心差异
| | 朱批录 | 拾遗 |
|---|---|---|
| 适用范围 | 考公(国考/省考) | 任意考试 |
| 科目结构 | 固定5科(言语/数量/判断/资料/申论) | 自由标签,AI 自动生成 |
| 标签复用 | 不需要(科目是固定的) | tag_library 跨题目积累 |
| 考试切换 | 不支持 | 「换考试」指令随时切换 |
---
## 三、触发场景
| 用户说的话 | 执行 |
|------------------------------------|-------------------------------|
| 发来截图 | 多模态识别 + 标签归档 |
| 发来截图附带"粗心" | 识别后直接归档,不追问 |
| "Verbal-Text Completion-词汇量不足" | 快捷格式,直接归档 |
| "记得" / "不记得" | 二刷自评,连续2次记得→已掌握 |
| "导出错题本" | 导出全部 |
| "只导出待二刷的" | 筛选导出 |
| "导出Verbal的错题" | 按 section 筛选 |
| "导出最近两周的" | 按时间筛选 |
| "换考试" | 重新配置考试类型 |
---
## 四、数据结构
```
~/.openclaw/skills/shiyi/data/
├── config.json ← 当前考试配置
├── tag_library.json ← 各考试的标签词库
├── wrong_questions.json ← 所有错题
├── review_state.json ← 二刷状态
├── review_session.json ← 当前二刷进度
├── stats_cache.json ← 打卡连续天数
├── daily/ ← 每日记录
├── backups/ ← wrong_questions 自动备份(最近10个)
└── exports/ ← 导出文件
```
错题记录字段:
```json
{
"id": "uuid",
"date": "2026-03-19",
"exam": "GRE",
"exam_name": "GRE",
"section": "Verbal",
"question_type": "Text Completion",
"knowledge_point": "逻辑关系词 — 转折",
"question_text": "完整题目文字",
"visual_description": null,
"answer": "B",
"error_reason": "知识点不会",
"keywords": ["逻辑关系词", "Text Completion"],
"status": "待二刷"
}
```
---
## 五、核心流程
### 图片识别
1. 读取 `config.json` 里的 `exam_key`,加载对应的考试背景知识
2. 从 `tag_library.json` 取最近30天用过的标签注入 prompt(提升复用率)
3. 调用 OpenClaw 配置的多模态模型识别
4. 识别结果写入 `wrong_questions.json`,新标签追加进 `tag_library.json`
5. 调用失败 → 提示手动复制文字
### 标签词库(tag_library)
- 每次识别后自动追加新标签(去重)
- 同一考试内复用,不同考试互不干扰
- 每类最多保留:知识点200个、题型50个(超出删最旧的)
- 注入 prompt 时只传最近30天用过的(避免 prompt 过长)
### 定时推送
| 时间 | 内容 |
|------|------|
| 每天 21:00 | 当日错题汇总 + 高频考点 + 打卡天数 |
| 隔天 20:00 | 随机抽3道待二刷题,用户自评 |
---
## 六、文件索引
| 文件 | 作用 |
|------|------|
| `assets/exam_prompts.js` | 各考试背景知识(识别精度的核心) |
| `scripts/tag_library.js` | 标签词库读写与统计 |
| `scripts/onboarding.js` | 首次配置考试类型 |
| `scripts/parse_input.js` | 消息路由 + 识别入口 |
| `scripts/update_daily.js` | 写入错题和每日记录 |
| `scripts/export_xlsx.js` | 导出 Excel(含筛选和截图嵌入) |
| `scripts/review_reminder.js` | 二刷提醒和自评处理 |
| `scripts/daily_summary.js` | 每日定时总结 |
---
## 七、新增考试
在 `assets/exam_prompts.js` 的 `EXAM_PROMPTS` 对象里加一条字符串:
```javascript
'驾照科目一': `
驾照科目一为交规理论考试,100道题,90分及格。
题型:判断题、单选题。
标 knowledge_point 时请具体到交规条款(如"禁止标志与警告标志区别")。`,
```
PR 欢迎。
---
## 八、如果这个 Skill 对你有帮助
⭐ Star 一下,让更多备考的人找到它
🍴 Fork 加上你的考试类型,欢迎 PR
> https://github.com/KaguraNanaga/shiyi
FILE:.gitignore
node_modules/
data/
config.json
*.xlsx
.env
.DS_Store
*.log
FILE:package.json
{
"name": "shiyi",
"version": "0.1.0",
"description": "拾遗 — 通用考试备考追踪 OpenClaw Skill",
"keywords": [
"openclaw",
"openclaw-skill",
"exam-prep",
"study-tracker",
"wrong-answers",
"GRE",
"IELTS",
"gaokao",
"kaoyan"
],
"author": "",
"license": "MIT",
"engines": { "node": ">=18" },
"dependencies": { "xlsx": "^0.18.5" },
"optionalDependencies": { "sharp": "^0.33.0" },
"scripts": {
"export": "node scripts/export_xlsx.js",
"export:pending": "node scripts/export_xlsx.js --pending-only",
"summary": "node scripts/daily_summary.js",
"review": "node scripts/review_reminder.js"
}
}
FILE:README.md
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/KaguraNanaga/shiyi/main/assets/crane_shiyi.png">
<img src="https://raw.githubusercontent.com/KaguraNanaga/shiyi/main/assets/crane_shiyi.png" alt="拾遗" width="160">
</picture>
</p>
<h1 align="center">拾遗 · shiyi</h1>
<p align="center">
通用考试备考追踪 OpenClaw Skill<br>
任何考试——GRE、雅思、考研、注会、高考、期末——发截图或文字,自动归档错题、积累标签、二刷提醒。
</p>
<p align="center">
<a href="https://openclaw.ai">
<img src="https://img.shields.io/badge/OpenClaw-Skill-orange?style=for-the-badge" alt="OpenClaw Skill">
</a>
<a href="LICENSE">
<img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="MIT License">
</a>
<a href="https://nodejs.org">
<img src="https://img.shields.io/badge/node-%3E%3D18-green?style=for-the-badge" alt="Node ≥18">
</a>
</p>
---
## 和朱批录的关系
[朱批录](https://github.com/KaguraNanaga/kaogong-study-tracker) 是专门为考公设计的,科目固定(言语/数量/判断/资料/申论),识别逻辑针对行测优化。
拾遗是通用版,核心差异是**自由标签**——不预设科目结构,AI 根据你的考试类型自动决定标签粒度:
- GRE 标到"Text Completion 双空逻辑"
- 雅思标到"T/F/NG 绝对词识别"
- 考研数学标到"换元积分法"
- 期末考标到你的课程和章节
同一考试内的标签会积累复用,不同考试互不干扰。
---
## 它能做什么
| 你做什么 | 拾遗做什么 |
|---------|----------|
| 告诉它你在备考什么 | 加载对应考试的识别背景知识 |
| 发一张错题截图 | 自动识别题型、知识点、错误原因,归档 |
| 发"Verbal-Text Completion-词汇量不足" | 快捷录入,直接归档 |
| 说"导出GRE的错题" | 生成 Excel,按考试筛选 |
| 回复"记得"或"不记得" | 二刷自评,连续2次记得自动标为已掌握 |
| 什么都不说 | 每天21:00总结,隔天20:00二刷提醒 |
---
## 已预置的考试类型
| 类别 | 考试 |
|------|------|
| 标化英语 | GRE、GMAT、TOEFL、雅思、四六级 |
| 考研 | 考研英语、考研数学、考研政治、专业课 |
| 职业资格 | 注会、司法考试、教师资格证 |
| 公务员 | 国考、省考 |
| 学校考试 | 高考、期末考试 |
不在列表里的考试用通用模式,AI 自行判断标签。
---
## 快速安装
```bash
cd ~/.openclaw/skills
git clone https://github.com/KaguraNanaga/shiyi
cd shiyi
npm install
```
在 `workspace.yaml` 里启用:
```yaml
skills:
- shiyi
cron_jobs:
- name: "每日总结"
schedule: "0 21 * * *"
action:
type: run_script
script: skills/shiyi/scripts/daily_summary.js
channel: feishu
- name: "二刷提醒"
schedule: "0 20 1-31/2 * *"
action:
type: run_script
script: skills/shiyi/scripts/review_reminder.js
channel: feishu
```
安装后发任意消息,拾遗会问你备考什么考试,一句话配置完成。
---
## 数据结构
```
~/.openclaw/skills/shiyi/data/
├── config.json ← 当前考试(exam_key + exam_name)
├── tag_library.json ← 各考试标签词库(自动积累,不需要维护)
├── wrong_questions.json ← 所有错题
├── daily/ ← 每日记录
├── backups/ ← 自动备份(最近10个)
└── exports/ ← 导出文件
```
---
## 文件结构
```
shiyi/
├── SKILL.md
├── package.json
├── assets/
│ └── exam_prompts.js ← 各考试背景知识(新增考试在这里加)
└── scripts/
├── onboarding.js ← 首次配置考试类型
├── parse_input.js ← 消息路由 + 识别入口
├── tag_library.js ← 标签词库读写
├── update_daily.js ← 写入错题和每日记录
├── export_xlsx.js ← 导出 Excel
├── review_reminder.js ← 二刷提醒
└── daily_summary.js ← 每日总结
```
---
## 新增考试
在 `assets/exam_prompts.js` 里加一条:
```javascript
'驾照科目一': `
驾照科目一为交规理论考试,100道题,90分及格。
题型:判断题、单选题。
标 knowledge_point 时请具体到交规条款。`,
```
同时在 `EXAM_ALIASES` 里加别名映射,PR 欢迎。
---
## 如果这个项目对你有帮助
⭐ **Star 一下**,让更多备考的人找到它
🍴 **Fork**,加上你的考试类型,欢迎 PR
---
## License
MIT © 2026
FILE:scripts/daily_summary.js
/**
* daily_summary.js
* 每天 21:00 cron 触发,发当日总结。
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const DATA_DIR = path.join(os.homedir(), '.openclaw/skills/shiyi/data');
function loadJson(p, fb) {
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch (_) { return fb; }
}
function buildSummaryMessage() {
const today = new Date().toISOString().slice(0, 10);
const dailyPath = path.join(DATA_DIR, 'daily', `today.json`);
const cache = loadJson(path.join(DATA_DIR, 'stats_cache.json'), {});
if (!fs.existsSync(dailyPath)) {
return '今天还没打卡,要发一下今天的题目情况吗?(发「跳过」可以记录休息)';
}
const daily = loadJson(dailyPath, {});
if (daily.skip_today) return '今天休息了,记下来了。';
const wq = loadJson(path.join(DATA_DIR, 'wrong_questions.json'), []);
const todayWQ = wq.filter(q => q.date === today);
if (!todayWQ.length) {
return '今天的记录有点简略,有空补充一下错题吗?';
}
// 按 section 汇总
const bySection = {};
todayWQ.forEach(q => {
const s = q.section || q.exam_name || '未分类';
bySection[s] = (bySection[s] || 0) + 1;
});
const sectionLine = Object.entries(bySection)
.map(([s, n]) => `s n错`)
.join(' / ');
// 最高频知识点
const kpFreq = {};
todayWQ.forEach(q => {
if (q.knowledge_point) kpFreq[q.knowledge_point] = (kpFreq[q.knowledge_point] || 0) + 1;
});
const topKP = Object.entries(kpFreq).sort(([,a],[,b]) => b-a)[0];
const streak = cache.streak || 1;
const streakLine = streak >= 3 ? `连续打卡第 streak 天` : '今天打卡完成';
const pending = wq.filter(q => q.status !== '已掌握').length;
const lines = [`今日:sectionLine`];
if (topKP) lines.push(`高频考点:topKP[0](topKP[1]次)`);
lines.push(`待二刷 pending 道 · streakLine`);
return lines.join('\n');
}
if (require.main === module) console.log(buildSummaryMessage());
module.exports = { buildSummaryMessage };
FILE:scripts/export_xlsx.js
/**
* export_xlsx.js
* 导出错题本,支持筛选:
* --pending-only 只导出"待二刷"
* --section=Verbal 只导出某科目/section
* --days=30 只导出最近 N 天
* --no-images 不嵌入截图
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFile } = require('child_process');
const DATA_DIR = path.join(os.homedir(), '.openclaw/skills/shiyi/data');
const OUT_DIR = path.join(DATA_DIR, 'exports');
const WQ_PATH = path.join(DATA_DIR, 'wrong_questions.json');
function loadJson(p, fb) {
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch (_) { return fb; }
}
function runPython(args, timeout = 60_000) {
return new Promise((resolve, reject) => {
function attempt(cmd) {
execFile(cmd, args, { timeout }, (err, stdout, stderr) => {
if (err && err.code === 'ENOENT' && cmd === 'python3') return attempt('python');
if (err) return reject({ error: err, stderr });
resolve({ stdout, stderr });
});
}
attempt('python3');
});
}
function buildPythonScript({ wrongRows, outPath, imageMap, sheetName }) {
const wrongJson = JSON.stringify(wrongRows);
const imageMapJson = JSON.stringify(imageMap);
return `
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.drawing.image import Image as XLImage
from openpyxl.utils import get_column_letter
import json, os
wrong_rows = json.loads(JSON.stringify(wrongJson))
image_map = json.loads(JSON.stringify(imageMapJson))
out_path = JSON.stringify(outPath)
sheet_name = JSON.stringify(sheetName)
HEADER_BG = "2D5FA1"
HEADER_FG = "FFFFFF"
wb = openpyxl.Workbook()
ws = wb.active
ws.title = sheet_name
headers = ["日期","考试","科目/大类","题目类型","知识点","错误原因","题目内容","视觉描述","正确答案","批注","知识点标签","状态","来源","截图"]
col_widths = [10, 12, 18, 16, 24, 12, 40, 40, 8, 20, 20, 10, 10, 20]
for ci, (h, w) in enumerate(zip(headers, col_widths), 1):
cell = ws.cell(row=1, column=ci, value=h)
cell.font = Font(bold=True, color=HEADER_FG, name="Arial", size=10)
cell.fill = PatternFill("solid", start_color=HEADER_BG)
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
ws.column_dimensions[get_column_letter(ci)].width = w
ws.row_dimensions[1].height = 26
ws.freeze_panes = "A2"
IMG_ROW_H = 160
ROW_H = 22
for ri, row in enumerate(wrong_rows, 2):
for ci, val in enumerate(row, 1):
cell = ws.cell(row=ri, column=ci, value=val)
cell.alignment = Alignment(vertical="center", wrap_text=(ci in [7,8,10]))
cell.font = Font(name="Arial", size=9)
img_path = image_map.get(str(ri - 2))
if img_path and os.path.exists(img_path):
try:
img = XLImage(img_path)
ratio = 160 / img.width if img.width > 0 else 1
img.width = int(img.width * ratio)
img.height = int(img.height * ratio)
ws.add_image(img, f"{get_column_letter(len(headers))}{ri}")
ws.row_dimensions[ri].height = max(IMG_ROW_H, img.height * 0.75 + 10)
except:
ws.row_dimensions[ri].height = ROW_H
else:
has_visual = bool(row[7] if len(row) > 7 else None)
ws.row_dimensions[ri].height = 60 if has_visual else ROW_H
wb.save(out_path)
print(out_path)
`.trim();
}
async function exportXlsx({ pendingOnly = false, sectionFilter = null, daysFilter = null, withImages = true } = {}) {
if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true });
const questions = loadJson(WQ_PATH, []);
const cutoff = daysFilter
? new Date(Date.now() - daysFilter * 86400000).toISOString().slice(0, 10)
: null;
const filtered = questions.filter(q => {
if (pendingOnly && q.status === '已掌握') return false;
if (sectionFilter && q.section !== sectionFilter) return false;
if (cutoff && (q.date ?? '') < cutoff) return false;
return true;
});
const wrongRows = filtered.map(q => [
q.date ?? '',
(q.exam_name || q.exam) ?? '',
q.section ?? '',
q.question_type ?? '',
q.knowledge_point ?? '',
q.error_reason ?? '',
q.question_text ?? '',
q.visual_description ?? '',
q.answer ?? '',
q.user_annotation ?? '',
Array.isArray(q.keywords) ? q.keywords.join('、') : '',
q.status ?? '待二刷',
q.source ?? '',
'',
]);
// 图片临时文件
const tmpFiles = [];
const imageMap = {};
if (withImages) {
filtered.forEach((q, idx) => {
if (!q.raw_image_b64) return;
const tmpPath = path.join(os.tmpdir(), `qimg_Date.now()_idx.jpg`);
try {
fs.writeFileSync(tmpPath, Buffer.from(q.raw_image_b64, 'base64'));
imageMap[String(idx)] = tmpPath;
tmpFiles.push(tmpPath);
} catch (_) {}
});
}
const today = new Date().toISOString().slice(0, 10);
const parts = [];
if (pendingOnly) parts.push('待二刷');
if (sectionFilter) parts.push(sectionFilter);
if (daysFilter) parts.push(`近daysFilter天`);
const suffix = parts.length ? '_' + parts.join('_') : '';
const outPath = path.join(OUT_DIR, `拾遗错题本_todaysuffix.xlsx`);
const sheetName = parts.length ? parts.join('_') + '错题' : '全部错题';
const pyScript = buildPythonScript({ wrongRows, outPath, imageMap, sheetName });
const tmpPy = path.join(os.tmpdir(), `export_Date.now().py`);
fs.writeFileSync(tmpPy, pyScript, 'utf-8');
try {
await runPython([tmpPy]);
} finally {
try { fs.unlinkSync(tmpPy); } catch (_) {}
tmpFiles.forEach(f => { try { fs.unlinkSync(f); } catch (_) {} });
}
console.log(`[shiyi] 已导出:outPath(filtered.length 条)`);
console.log(`ATTACH:outPath`);
return outPath;
}
if (require.main === module) {
const pendingOnly = process.argv.includes('--pending-only');
const withImages = !process.argv.includes('--no-images');
const sectionArg = process.argv.find(a => a.startsWith('--section='));
const daysArg = process.argv.find(a => a.startsWith('--days='));
const sectionFilter = sectionArg ? sectionArg.split('=')[1] : null;
const daysFilter = daysArg ? parseInt(daysArg.split('=')[1]) : null;
exportXlsx({ pendingOnly, sectionFilter, daysFilter, withImages }).catch(console.error);
}
module.exports = { exportXlsx };
FILE:scripts/onboarding.js
/**
* onboarding.js
* 首次加载:问一次考试类型,存入 config.json,之后不再打扰。
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { resolveExam, listSupportedExams } = require('../assets/exam_prompts');
const CONFIG_PATH = path.join(os.homedir(), '.openclaw/skills/shiyi/config.json');
function loadConfig() {
try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); }
catch (_) { return {}; }
}
function saveConfig(cfg) {
const dir = path.dirname(CONFIG_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf-8');
}
function isSetupDone() { return !!loadConfig().setup_done; }
function getExamKey() { return loadConfig().exam_key || '_generic'; }
function getExamName() { return loadConfig().exam_name || ''; }
// ─── 消息模板 ─────────────────────────────────────────────────
function buildWelcome() {
const supported = listSupportedExams();
return `拾遗已安装。
你在备考什么考试?发名字过来就行,比如:
supported.slice(0, 6).join('、')……
或者高数期末、驾照理论、某省特岗——不在列表里也没关系。
告诉我考试名称,我来配置识别逻辑。`;
}
// ─── 主处理函数 ───────────────────────────────────────────────
async function handleOnboarding(userMessage, sendMessage) {
if (isSetupDone()) return false;
const text = (userMessage || '').trim();
const cfg = loadConfig();
// 第一次加载,还没发过欢迎消息
if (!text || !cfg._waiting_exam) {
await sendMessage(buildWelcome());
saveConfig({ _waiting_exam: true });
return true;
}
// 用户发来考试名称
const { key, prompt } = resolveExam(text);
const isGeneric = key === '_generic';
saveConfig({
setup_done: true,
exam_key: key,
exam_name: text,
});
const msg = isGeneric
? `好,用「text」作为考试名称,按通用模式识别截图。\n\n直接发错题截图就行,或者发文字描述也可以。`
: `好,已配置「key」的识别逻辑。\n\n直接发错题截图就行,或者发文字描述也可以。\n\n如果之后换考试,发「换考试」即可重新配置。`;
await sendMessage(msg);
return true;
}
/**
* 处理"换考试"指令。
*/
async function handleChangeExam(sendMessage) {
saveConfig({ _waiting_exam: true });
await sendMessage(buildWelcome());
}
async function initOnboarding(sendMessage) {
if (isSetupDone()) return;
await sendMessage(buildWelcome());
saveConfig({ _waiting_exam: true });
}
module.exports = { initOnboarding, handleOnboarding, isSetupDone, getExamKey, getExamName, handleChangeExam };
FILE:scripts/parse_input.js
/**
* parse_input.js
* 消息解析 + 多模态图片识别(自由标签模式)。
*
* 与朱批录的核心差异:
* - 不预设科目映射,标签完全由 AI 自由生成
* - 识别前查 tag_library 注入已有标签(提升复用率)
* - 识别后写 tag_library(积累词库)
* - 支持所有考试类型
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { handleOnboarding, isSetupDone, getExamKey, getExamName, handleChangeExam } = require('./onboarding');
const { getTagsForPrompt, updateTagLibrary } = require('./tag_library');
const { resolveExam } = require('../assets/exam_prompts');
const { handleReviewReply } = require('./review_reminder');
// ─── 识别 prompt ──────────────────────────────────────────────
function buildPrompt(examKey, examName, tagHint) {
const { prompt: examContext } = resolveExam(examName || examKey);
return `
你是一个考试备考助手,正在分析用户发来的错题截图。
考试类型:examName || examKey
examContext
''
请从图片中提取以下信息,严格以 JSON 格式返回,不要有任何额外说明。
重要:所有字段必须完整输出,禁止使用省略号截断内容。
{
"section": "科目/大类(如 Verbal、高等数学、阅读理解)",
"question_type": "题目类型(如 Text Completion、T/F/NG、选择题)",
"knowledge_point": "具体知识点或考点(尽量具体,优先复用已有标签)",
"question_text": "【完整】题目文字;图形题写规律描述;图表题写标题+核心数据",
"visual_description": "【完整】图形/图表的详细视觉描述,纯文字题填 null",
"answer": "正确答案,图片中若不可见则填 null",
"user_annotation": "用户手写批注,没有填 null",
"error_reason": "知识点不会 / 粗心 / 时间不够 / 概念混淆 / 无法判断",
"keywords": ["知识点标签,最多3个,优先复用已有标签"]
}
如果图片模糊无法识别,返回:{"error": "图片无法识别"}
`.trim();
}
// ─── 图片识别 ─────────────────────────────────────────────────
async function parseImageInput(imageBase64, caption, agentCall) {
if (typeof agentCall !== 'function') {
return {
success: false,
error: 'no_vision',
fallback_prompt: '没识别出来,可以把题目文字复制过来发给我,一样能整理。',
};
}
const examKey = getExamKey();
const examName = getExamName();
const tagHint = getTagsForPrompt(examKey);
const prompt = buildPrompt(examKey, examName, tagHint);
const fullPrompt = caption
? `prompt\n\n用户附带说明:「caption」`
: prompt;
try {
const raw = await agentCall({ image: imageBase64, text: fullPrompt });
const parsed = JSON.parse((raw || '').replace(/```json|```/g, '').trim());
if (parsed.error) throw new Error(parsed.error);
// 写入词库
updateTagLibrary(examKey, {
question_type: parsed.question_type,
knowledge_point: parsed.knowledge_point,
});
return {
success: true,
date: new Date().toISOString().slice(0, 10),
source: 'image',
exam: examKey,
exam_name: examName,
section: parsed.section ?? '',
question_type: parsed.question_type ?? '',
knowledge_point: parsed.knowledge_point ?? '',
question_text: parsed.question_text ?? '',
visual_description: parsed.visual_description ?? null,
answer: parsed.answer ?? null,
user_annotation: caption || parsed.user_annotation || null,
error_reason: parsed.error_reason ?? '未说明',
keywords: parsed.keywords ?? [],
raw_image_b64: imageBase64,
status: '待二刷',
needs_confirm: buildConfirmPrompt(parsed),
};
} catch (e) {
return {
success: false,
error: e.message,
fallback_prompt: '没识别出来,可以把题目文字复制过来发给我,一样能整理。',
};
}
}
function buildConfirmPrompt(parsed) {
if (!parsed.section) return '这道题是哪个科目/模块?';
if (!parsed.error_reason || parsed.error_reason === '无法判断') {
return '这道题是知识点没掌握、粗心,还是时间不够?';
}
return null;
}
// ─── 文字消息处理 ─────────────────────────────────────────────
function detectMood(text) {
if (/太累|好烦|没用|放弃|崩了/.test(text)) return '低落';
if (/没时间|来不及|考试快|好焦虑|压力/.test(text)) return '焦虑';
if (/不错|还行|有进步|感觉好|状态好/.test(text)) return '良好';
return '中性';
}
function extractWrongCount(text) {
const patterns = [
/错[了]?\s*(\d+)\s*[道题个]/,
/(\d+)\s*[道题个]?\s*[错误不对]/,
/(\d+)\s*错/,
];
for (const p of patterns) {
const m = text.match(p);
if (m) return parseInt(m[1], 10);
}
return null;
}
/**
* 快捷录入:科目-题型-原因-状态
* 如:Verbal-Text Completion-词汇量不足-待二刷
*/
function parseQuickEntry(text) {
const parts = text.split(/[-—·/]/);
if (parts.length < 2) return null;
const section = parts[0].trim();
if (section.length > 30 || section.length < 1) return null;
// 至少第二段有内容才认为是快捷格式
if (!parts[1]?.trim()) return null;
const examKey = getExamKey();
const question_type = parts[1]?.trim() || '';
const reasonRaw = parts[2]?.trim() || '';
const statusRaw = parts[3]?.trim() || '';
const error_reason = /不会|不懂|没学|不清楚/.test(reasonRaw) ? '知识点不会'
: /粗心|看错|算错|选反/.test(reasonRaw) ? '粗心'
: /时间|没做完|蒙/.test(reasonRaw) ? '时间不够'
: /混淆|分不清|搞混/.test(reasonRaw) ? '概念混淆'
: (reasonRaw || '未说明');
const status = /掌握|会了|搞懂/.test(statusRaw) ? '已掌握' : '待二刷';
// 写入词库
updateTagLibrary(examKey, { question_type, knowledge_point: reasonRaw });
return {
source: 'quick',
date: new Date().toISOString().slice(0, 10),
exam: examKey,
exam_name: getExamName(),
section,
question_type,
knowledge_point: reasonRaw,
question_text: text,
error_reason,
keywords: [section, question_type].filter(Boolean).slice(0, 2),
status,
needs_confirm: null,
};
}
/**
* 解析导出筛选指令。
*/
function parseExportCommand(text) {
if (!/导出|错题本|生成报告/.test(text)) return null;
const pending = /待二刷|未掌握/.test(text);
let days = null;
const dm = text.match(/最近\s*(\d+)\s*天/);
const wm = text.match(/最近\s*(\d+)\s*周/);
const mm = text.match(/最近\s*(\d+)\s*个?月/);
if (dm) days = parseInt(dm[1]);
if (wm) days = parseInt(wm[1]) * 7;
if (mm) days = parseInt(mm[1]) * 30;
if (/上周|本周|这周/.test(text)) days = 7;
if (/本月|这个月/.test(text)) days = 30;
if (/两周/.test(text)) days = 14;
// 科目/section 匹配(自由文本,取"的"前面的词)
const sectionMatch = text.match(/导出(.+?)的错题/);
const sectionFilter = sectionMatch ? sectionMatch[1].replace(/只|最近\S+/, '').trim() : null;
return { _export: true, pendingOnly: pending, sectionFilter, daysFilter: days };
}
function parseStudyInput(message) {
// 快捷录入
const quick = parseQuickEntry(message);
if (quick) return quick;
return {
date: new Date().toISOString().slice(0, 10),
source: 'text',
exam: getExamKey(),
exam_name: getExamName(),
raw_message: message,
mood: detectMood(message),
wrong_count: extractWrongCount(message),
has_exam: /做了|做完|刷了|套题|整套|一套/.test(message),
skip_today: /没做|没时间|跳过|明天补|休息/.test(message),
needs_clarification: null,
};
}
// ─── 统一入口 ─────────────────────────────────────────────────
async function handleMessage(message, { agentCall, sendMessage } = {}) {
const text = message.text ?? message.caption ?? '';
// 换考试指令
if (/换考试|切换考试|改考试/.test(text) && sendMessage) {
await handleChangeExam(sendMessage);
return { _change_exam: true };
}
// Onboarding 拦截
if (!isSetupDone()) {
const consumed = await handleOnboarding(text, sendMessage);
if (consumed) return { _onboarding: true };
}
// 二刷回复拦截
if (message.type === 'text' && sendMessage) {
const reviewReply = handleReviewReply(text);
if (reviewReply !== null) {
await sendMessage(reviewReply);
return { _review: true };
}
}
// 图片
if (message.type === 'image') {
return parseImageInput(message.imageBase64, message.caption ?? '', agentCall);
}
// 导出筛选
const exportCmd = parseExportCommand(text);
if (exportCmd) return exportCmd;
// 普通文字
return parseStudyInput(text);
}
module.exports = { handleMessage, parseStudyInput, parseImageInput, parseExportCommand };
FILE:scripts/review_reminder.js
/**
* review_reminder.js
* 隔天 20:00 cron 触发,随机抽3道待二刷题,让用户自评。
* 连续答对2次自动标为"已掌握"。
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const DATA_DIR = path.join(os.homedir(), '.openclaw/skills/shiyi/data');
const WQ_PATH = path.join(DATA_DIR, 'wrong_questions.json');
const STATE_PATH = path.join(DATA_DIR, 'review_state.json');
const SESSION_PATH = path.join(DATA_DIR, 'review_session.json');
function loadJson(p, fb) {
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch (_) { return fb; }
}
function saveJson(p, data) {
const dir = path.dirname(p);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8');
}
function pickQuestions(n = 3) {
if (!fs.existsSync(WQ_PATH)) return [];
const questions = loadJson(WQ_PATH, []);
const state = loadJson(STATE_PATH, {});
const pending = questions.filter(q => q.status !== '已掌握');
if (!pending.length) return [];
pending.sort((a, b) => {
const ta = state[a.id]?.last_reviewed ?? '2000-01-01';
const tb = state[b.id]?.last_reviewed ?? '2000-01-01';
return ta.localeCompare(tb);
});
const pool = pending.slice(0, Math.min(n * 2, pending.length));
const picked = [], used = new Set();
while (picked.length < n && picked.length < pool.length) {
const idx = Math.floor(Math.random() * pool.length);
if (!used.has(idx)) { used.add(idx); picked.push(pool[idx]); }
}
return picked;
}
function formatQuestion(q) {
const lines = [];
const label = [q.section, q.question_type].filter(Boolean).join(' · ');
if (label) lines.push(`[label]`);
if (q.knowledge_point) lines.push(`考点:q.knowledge_point`);
if (q.question_text) lines.push(q.question_text.slice(0, 200));
if (q.visual_description) lines.push(`图形:q.visual_description.slice(0, 120)`);
if (q.answer) lines.push(`答案:q.answer`);
return lines.join('\n');
}
function buildReminderMessage() {
const questions = pickQuestions(3);
if (!questions.length) return '待二刷的题都清空了,今天不用复习,去刷新题吧。';
const session = {
date: new Date().toISOString().slice(0, 10),
questions: questions.map(q => q.id),
current: 0,
answers: {},
};
saveJson(SESSION_PATH, session);
const first = questions[0];
return [
`二刷时间,抽到 questions.length 道待复习的题。`,
'',
`第 1 / questions.length 道:`,
formatQuestion(first),
'',
'还记得这题的解法吗?回复 记得 或 不记得',
].join('\n');
}
function handleReviewReply(userText) {
if (!fs.existsSync(SESSION_PATH)) return null;
const session = loadJson(SESSION_PATH, null);
if (!session || session.current >= session.questions.length) return null;
const text = (userText || '').trim();
const isRemember = /记得|会了|掌握|对|知道/.test(text);
const isForget = /不记得|忘了|不会|错了|不对|不知道/.test(text);
if (!isRemember && !isForget) return null;
const currentId = session.questions[session.current];
if (!session.answers[currentId]) session.answers[currentId] = [];
session.answers[currentId].push(isRemember ? '记得' : '不记得');
const state = loadJson(STATE_PATH, {});
if (!state[currentId]) state[currentId] = { correct_streak: 0, total: 0 };
state[currentId].last_reviewed = new Date().toISOString().slice(0, 10);
state[currentId].total += 1;
state[currentId].correct_streak = isRemember
? (state[currentId].correct_streak || 0) + 1
: 0;
saveJson(STATE_PATH, state);
const mastered = state[currentId].correct_streak >= 2;
if (mastered) {
const questions = loadJson(WQ_PATH, []);
const q = questions.find(q => q.id === currentId);
if (q) q.status = '已掌握';
fs.writeFileSync(WQ_PATH, JSON.stringify(questions, null, 2), 'utf-8');
}
session.current += 1;
saveJson(SESSION_PATH, session);
const feedback = mastered
? '已标记为「已掌握」。'
: isRemember ? '记录了,再答对一次就标为已掌握。'
: '记下了,下次还会抽到这道。';
if (session.current >= session.questions.length) {
const remaining = loadJson(WQ_PATH, []).filter(q => q.status !== '已掌握').length;
return `feedback\n\n本次复习完成,还剩 remaining 道待二刷。`;
}
const allQ = loadJson(WQ_PATH, []);
const nextQ = allQ.find(q => q.id === session.questions[session.current]);
if (!nextQ) return `feedback\n\n(找不到下一题,本次结束)`;
return [
feedback, '',
`第 session.current + 1 / session.questions.length 道:`,
formatQuestion(nextQ), '',
'还记得这题的解法吗?回复 记得 或 不记得',
].join('\n');
}
if (require.main === module) console.log(buildReminderMessage());
module.exports = { buildReminderMessage, handleReviewReply };
FILE:scripts/tag_library.js
/**
* tag_library.js
* 每个考试独立的标签词库:识别前查、识别后写。
* 保证同一考试内标签复用,不同考试互不干扰。
*
* data/tag_library.json 结构:
* {
* "GRE": {
* "knowledge_points": ["逻辑关系词", "Text Completion 双空逻辑", ...],
* "subtypes": ["Text Completion", "Sentence Equivalence", ...],
* "last_used": { "逻辑关系词": "2026-03-19", ... }
* },
* "雅思": { ... }
* }
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const LIB_PATH = path.join(
os.homedir(),
'.openclaw/skills/shiyi/data/tag_library.json'
);
function loadLib() {
try { return JSON.parse(fs.readFileSync(LIB_PATH, 'utf-8')); }
catch (_) { return {}; }
}
function saveLib(lib) {
const dir = path.dirname(LIB_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(LIB_PATH, JSON.stringify(lib, null, 2), 'utf-8');
}
// ─── 查:识别前拼进 prompt ────────────────────────────────────
/**
* 取某考试最近 30 天用过的标签,注入到识别 prompt。
* 超出 30 天的冷门标签不传,避免 prompt 过长。
*
* @param {string} examKey
* @returns {string} 格式化后的标签提示段落,直接拼进 prompt
*/
function getTagsForPrompt(examKey) {
const lib = loadLib();
const entry = lib[examKey];
if (!entry) return '';
const cutoff = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10);
const lastUsed = entry.last_used || {};
const recentKP = (entry.knowledge_points || [])
.filter(t => (lastUsed[t] ?? '2000-01-01') >= cutoff)
.slice(-40); // 最多40个,避免 prompt 爆长
const recentST = (entry.subtypes || []).slice(-20);
if (!recentKP.length && !recentST.length) return '';
const lines = ['已有标签(优先复用,找不到合适的再新建):'];
if (recentST.length) lines.push(` 题型:recentST.join(' / ')`);
if (recentKP.length) lines.push(` 知识点:recentKP.join(' / ')`);
return lines.join('\n');
}
// ─── 写:识别后追加新标签 ─────────────────────────────────────
/**
* 把本次识别出的标签追加进词库(去重)。
* @param {string} examKey
* @param {{ question_type?: string, knowledge_point?: string }} result
*/
function updateTagLibrary(examKey, result) {
const lib = loadLib();
const today = new Date().toISOString().slice(0, 10);
if (!lib[examKey]) {
lib[examKey] = { knowledge_points: [], subtypes: [], last_used: {} };
}
const entry = lib[examKey];
function addTag(arr, tag) {
if (!tag || typeof tag !== 'string') return;
const t = tag.trim();
if (!t) return;
if (!arr.includes(t)) arr.push(t);
entry.last_used[t] = today;
}
addTag(entry.subtypes, result.question_type);
addTag(entry.knowledge_points, result.knowledge_point);
// 防止无限膨胀:每类最多保留 200 个,删最旧的
if (entry.knowledge_points.length > 200) {
const sorted = entry.knowledge_points
.sort((a, b) => (entry.last_used[a] ?? '') < (entry.last_used[b] ?? '') ? -1 : 1);
entry.knowledge_points = sorted.slice(-200);
}
if (entry.subtypes.length > 50) {
entry.subtypes = entry.subtypes.slice(-50);
}
saveLib(lib);
}
// ─── 统计:高频错误标签 Top N ─────────────────────────────────
/**
* 从 wrong_questions.json 里统计某考试的高频错误知识点。
* @param {string} examKey
* @param {object[]} questions wrong_questions.json 的内容
* @param {number} topN
* @returns {{ tag: string, count: number }[]}
*/
function getTopTags(examKey, questions, topN = 5) {
const freq = {};
questions
.filter(q => q.exam === examKey)
.forEach(q => {
if (q.knowledge_point) {
freq[q.knowledge_point] = (freq[q.knowledge_point] || 0) + 1;
}
});
return Object.entries(freq)
.sort(([, a], [, b]) => b - a)
.slice(0, topN)
.map(([tag, count]) => ({ tag, count }));
}
module.exports = { getTagsForPrompt, updateTagLibrary, getTopTags };
FILE:scripts/update_daily.js
/**
* update_daily.js
* 写入每日记录和错题,适配自由标签模式。
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const DATA_DIR = path.join(os.homedir(), '.openclaw/skills/shiyi/data');
const WQ_PATH = path.join(DATA_DIR, 'wrong_questions.json');
function ensureDir(d) { if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); }
function loadJson(p, fb) {
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch (_) { return fb; }
}
// ─── 备份 ─────────────────────────────────────────────────────
function backupWrongQuestions() {
if (!fs.existsSync(WQ_PATH)) return;
const backupDir = path.join(DATA_DIR, 'backups');
ensureDir(backupDir);
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
fs.copyFileSync(WQ_PATH, path.join(backupDir, `wrong_questions.ts.json`));
const all = fs.readdirSync(backupDir).filter(f => f.startsWith('wrong_questions.')).sort().reverse();
all.slice(10).forEach(f => { try { fs.unlinkSync(path.join(backupDir, f)); } catch (_) {} });
}
// ─── 错题存储 ─────────────────────────────────────────────────
/**
* 追加一条错题,写入前备份。
* @param {object} question 来自 parse_input.js 的识别结果
*/
function saveWrongQuestion(question) {
ensureDir(DATA_DIR);
backupWrongQuestions();
const questions = loadJson(WQ_PATH, []);
const id = `Date.now()-Math.random().toString(36).slice(2, 7)`;
questions.push({ id, ...question });
fs.writeFileSync(WQ_PATH, JSON.stringify(questions, null, 2), 'utf-8');
return questions;
}
function updateWrongQuestionStatus(id, status) {
if (!fs.existsSync(WQ_PATH)) return;
backupWrongQuestions();
const questions = loadJson(WQ_PATH, []);
const q = questions.find(q => q.id === id);
if (q) q.status = status;
fs.writeFileSync(WQ_PATH, JSON.stringify(questions, null, 2), 'utf-8');
}
// ─── 每日记录 ─────────────────────────────────────────────────
function updateDailyRecord(parsed) {
const dailyDir = path.join(DATA_DIR, 'daily');
ensureDir(dailyDir);
const date = parsed.date || new Date().toISOString().slice(0, 10);
const filePath = path.join(dailyDir, `date.json`);
const existing = loadJson(filePath, {});
const record = {
date,
exam: parsed.exam || '_generic',
exam_name: parsed.exam_name || '',
...existing,
mood: parsed.mood || '中性',
skip_today: parsed.skip_today || false,
updated_at: new Date().toISOString(),
};
// 累计当日错题数(按考试分组)
if (!record.wrong_count) record.wrong_count = {};
const examKey = parsed.exam || '_generic';
if (parsed.wrong_count) {
record.wrong_count[examKey] = (record.wrong_count[examKey] || 0) + parsed.wrong_count;
}
fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf-8');
// 更新连续打卡
updateStreak(date);
return record;
}
function updateStreak(today) {
const cachePath = path.join(DATA_DIR, 'stats_cache.json');
const cache = loadJson(cachePath, { streak: 0, total_days: 0 });
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const yPath = path.join(DATA_DIR, 'daily', `yesterday.toISOString().slice(0,10).json`);
cache.streak = fs.existsSync(yPath) ? (cache.streak || 0) + 1 : 1;
cache.total_days = (cache.total_days || 0) + 1;
cache.last_updated = today;
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
return cache;
}
function readStatsCache() {
return loadJson(path.join(DATA_DIR, 'stats_cache.json'), null);
}
module.exports = { saveWrongQuestion, updateWrongQuestionStatus, updateDailyRecord, readStatsCache };
FILE:assets/exam_prompts.js
/**
* exam_prompts.js
* 各考试类型的背景知识,注入到识别 prompt 里提升标签精度。
* 未命中时降级到 _generic。
*
* 新增考试:在 EXAM_PROMPTS 里加一条字符串,
* EXAM_ALIASES 里加同义词映射即可。
*/
const EXAM_PROMPTS = {
// ── 标化英语 ────────────────────────────────────────────────
'GRE': `
GRE 分为 Verbal Reasoning、Quantitative Reasoning、Analytical Writing。
Verbal 题型:Text Completion(单空/双空/三空)、Sentence Equivalence、Reading Comprehension(单题/多题/段落)。
Quantitative 题型:Quantitative Comparison、Multiple Choice(单选/多选)、Numeric Entry。
标 knowledge_point 时请具体到:词汇类别(如"转折逻辑词")、题型技巧(如"双空同向/反向逻辑")或数学知识点(如"余数定理")。`,
'GMAT': `
GMAT 分为 Verbal(CR/RC/SC)、Quantitative(PS/DS)、Integrated Reasoning、AWA。
CR = Critical Reasoning,RC = Reading Comprehension,SC = Sentence Correction。
PS = Problem Solving,DS = Data Sufficiency。
标 knowledge_point 时请具体到题型内的逻辑类型或语法点(如"strengthen题型 反向假设"、"平行结构")。`,
'TOEFL': `
托福分为 Reading、Listening、Speaking、Writing。
Reading 题型:词汇题、指代题、句子简化、信息插入、修辞目的、推断题、表格题。
Listening 题型:主旨题、细节题、态度题、推断题、功能题。
标 knowledge_point 时请具体到题型技巧层面(如"词汇题同义替换规律"、"态度题语气词识别")。`,
'雅思': `
雅思分为 Listening、Reading、Writing、Speaking。
Reading 题型:True/False/Not Given、Matching Headings、Matching Information、
Summary Completion、Multiple Choice、Short Answer、Sentence Completion。
Listening 题型:填空、选择、匹配、地图/示意图。
Writing:Task1(图表描述/书信)、Task2(议论文)。
标 knowledge_point 时请具体到题型技巧(如"T/F/NG 绝对词识别"、"Matching Headings 段落主旨定位")。`,
'四六级': `
大学英语四六级分为听力、阅读、翻译、写作。
阅读含:选词填空、长篇阅读(匹配)、仔细阅读(选择)。
听力含:短对话、长对话、讲座/报道。
标 knowledge_point 时请具体到词汇/语法/题型技巧层面。`,
// ── 国内研究生/职业考试 ───────────────────────────────────
'考研英语': `
考研英语(一/二)分为完形填空、阅读理解(A型精读/B型新题型)、翻译(英译汉)、写作(小作文+大作文)。
阅读A型题型:细节题、主旨题、推断题、词义题、态度题。
新题型(B型):段落匹配、句子排序、小标题匹配。
标 knowledge_point 时请具体到:词汇(如"转折连词 however 后为重点")、逻辑(如"主旨题排除绝对化选项")。`,
'考研数学': `
考研数学(一/二/三)分为高等数学、线性代数、概率统计(数三含概统)。
高数主要章节:极限与连续、导数与微分、积分(定积分/不定积分/多重积分)、微分方程、级数。
线代:行列式、矩阵、向量、线性方程组、特征值与特征向量。
概率统计:随机事件与概率、随机变量与分布、数字特征、统计推断。
标 knowledge_point 时请具体到知识点(如"换元积分法"、"行列式按行展开")。`,
'考研政治': `
考研政治分为马原(马克思主义原理)、毛中特(毛泽东思想和中国特色社会主义理论)、
史纲(中国近现代史纲要)、思修(思想道德与法治)、时政。
题型:选择题(单选/多选)、分析题(大题)。
标 knowledge_point 时请具体到知识点(如"矛盾的普遍性与特殊性"、"新民主主义革命的性质")。`,
'考研专业课': `
这是考研专业课题目,请根据图片内容自行判断学科方向。
标 section 为学科名(如"微观经济学"、"有机化学")。
标 knowledge_point 时尽量具体到知识点层面。`,
// ── 职业资格 ─────────────────────────────────────────────
'注会': `
注册会计师考试共六门:会计、审计、财务成本管理、经济法、税法、公司战略与风险管理,
加综合阶段(职业能力综合测试)。
题型:单选、多选、判断、简答/计算/综合题。
标 knowledge_point 时请具体到知识点(如"长期股权投资成本法转权益法"、"实质性程序")。`,
'司法考试': `
法考(司法考试)分为客观题(卷一/卷二)和主观题。
涉及科目:民法、刑法、行政法、民事诉讼法、刑事诉讼法、商法、经济法、国际法、法理学、宪法、司法制度。
标 knowledge_point 时请具体到知识点(如"故意杀人罪与故意伤害罪致死的区别")。`,
'教师资格证': `
教师资格证分为综合素质、教育知识与能力、学科知识与教学能力。
题型:单选、材料分析、写作(综合素质);单选、简答、材料分析(教育知识);
单选、简答、材料分析、教学设计(学科)。
标 knowledge_point 时请具体到知识点或能力点。`,
// ── 公务员 ───────────────────────────────────────────────
'国考': `
国家公务员考试行测含言语理解与表达、数量关系、判断推理、资料分析、常识判断。
判断推理含图形推理、定义判断、类比推理、逻辑判断。
申论含归纳概括、综合分析、提出对策、文章写作。
标 knowledge_point 时请具体到题型技巧(如"假言命题逆否推理"、"倍数增长率估算")。`,
'省考': `
省级公务员考试结构与国考类似,行测含言语/数量/判断/资料,另有申论。
部分省份有特色模块,请根据图片内容判断。
标 knowledge_point 时请具体到题型技巧层面。`,
// ── 高中/大学 ────────────────────────────────────────────
'高考': `
高考科目:语文、数学、英语为必考,另选考历史/地理/政治/物理/化学/生物(各省略有差异)。
请根据图片内容判断具体科目。
标 knowledge_point 时请具体到知识点(如"等差数列前n项和"、"氧化还原反应配平")。`,
'期末考试': `
这是大学或中学期末考试题目,请根据图片内容自行判断课程名称和知识模块。
标 section 为课程名(如"高等数学"、"大学物理"、"线性代数")。
标 knowledge_point 时尽量具体到章节和知识点。`,
// ── 通用降级 ────────────────────────────────────────────
'_generic': `
这是一道考试题目,请根据图片内容自行判断考试类型和科目。
标 section 为科目大类,标 question_type 为题目类型,
标 knowledge_point 时请尽量具体,避免过于宽泛的标签(如"语法"、"计算")。`,
};
// 同义词映射,用户输入的各种说法都能命中
const EXAM_ALIASES = {
'GRE': ['gre', 'GRE', '研究生入学考试'],
'GMAT': ['gmat', 'GMAT'],
'TOEFL': ['toefl', 'TOEFL', '托福'],
'雅思': ['雅思', 'ielts', 'IELTS'],
'四六级': ['四级', '六级', '英语四级', '英语六级', 'CET4', 'CET6', 'cet'],
'考研英语': ['考研英语', '考研 英语'],
'考研数学': ['考研数学', '考研 数学', '数学一', '数学二', '数学三'],
'考研政治': ['考研政治', '考研 政治', '政治'],
'考研专业课': ['考研专业课', '专业课'],
'注会': ['注会', 'cpa', 'CPA', '注册会计师'],
'司法考试': ['司法', '法考', '司法考试', '律师资格'],
'教师资格证': ['教资', '教师资格', '教师资格证'],
'国考': ['国考', '国家公务员', '国家公务员考试'],
'省考': ['省考', '省级公务员', '公务员'],
'高考': ['高考', '联考'],
'期末考试': ['期末', '期末考', '期中', '期中考', '期末考试'],
};
/**
* 根据用户输入的考试名称,找到最匹配的 prompt key。
* @param {string} input
* @returns {{ key: string, prompt: string }}
*/
function resolveExam(input) {
if (!input) return { key: '_generic', prompt: EXAM_PROMPTS['_generic'] };
const lower = input.toLowerCase().trim();
// 精确匹配
if (EXAM_PROMPTS[input]) return { key: input, prompt: EXAM_PROMPTS[input] };
// 别名匹配
for (const [key, aliases] of Object.entries(EXAM_ALIASES)) {
if (aliases.some(a => lower.includes(a.toLowerCase()))) {
return { key, prompt: EXAM_PROMPTS[key] };
}
}
// 模糊匹配(key 包含 input 或 input 包含 key)
for (const key of Object.keys(EXAM_PROMPTS)) {
if (key === '_generic') continue;
if (lower.includes(key.toLowerCase()) || key.toLowerCase().includes(lower)) {
return { key, prompt: EXAM_PROMPTS[key] };
}
}
return { key: '_generic', prompt: EXAM_PROMPTS['_generic'] };
}
/**
* 列出所有已预置的考试名称(用于 onboarding 展示)。
*/
function listSupportedExams() {
return Object.keys(EXAM_PROMPTS).filter(k => k !== '_generic');
}
module.exports = { resolveExam, listSupportedExams, EXAM_PROMPTS };
朱批录 · 国考备考追踪 Skill。当用户发来套题成绩、错题截图、备考打卡或复习进度时触发。 核心功能:识别错题截图 → 分类错题原因 → 更新本地记录 → 生成每日总结 → 导出 Excel / 同步飞书。 触发关键词:做了一套题、今天做了、错了几道、帮我分析、备考打卡、行测、申论、 判断推理、资料分析、言语...
---
name: kaogong-study-tracker
description: >
朱批录 · 国考备考追踪 Skill。当用户发来套题成绩、错题截图、备考打卡或复习进度时触发。
核心功能:识别错题截图 → 分类错题原因 → 更新本地记录 → 生成每日总结 → 导出 Excel / 同步飞书。
触发关键词:做了一套题、今天做了、错了几道、帮我分析、备考打卡、行测、申论、
判断推理、资料分析、言语理解、数量关系、错题、复习进度、导出错题本、同步飞书。
只要用户提到做题、错题、备考就触发。图片消息也触发,自动调用多模态模型识别。
---
# 朱批录 · 国考备考追踪 Skill
## 一、首次安装提示
Skill 首次加载时(`~/.openclaw/skills/kaogong-study-tracker/.welcomed` 不存在),
主动发一条说明消息,之后不再重复:
```
朱批录已安装。
直接发文字就能记录,比如"今天判断推理错了8道"。
发截图的话,需要 OpenClaw 配置了支持图片输入的多模态模型才能自动识别。
没有的话也没关系,把题目文字手动复制过来发给我,一样能整理。
```
不问任何问题,不存储任何凭据。
---
## 二、概览
**平台无关**——飞书、Telegram、WhatsApp、Discord,逻辑完全一致。
图片识别统一走多模态模型:文字题、图形推理、统计图表都能理解,不依赖本地 OCR。
---
## 三、触发场景
| 用户说的话(示例) | 应执行的操作 |
|---------------------------------------|--------------------------|
| "今天做了一套行测,判断推理错了8道" | → 解析 + 归档 + 分析 |
| 发来一张错题截图(图片消息) | → 多模态识别 + 单题归档 + 追问原因 |
| 发来截图并附带"粗心" | → 多模态识别 + 直接归档,不追问 |
| "把今天的错题发给你:第12题……" | → 错题分类 + 存档 |
| "今天申论没写,太累了" | → 打卡记录(未完成状态) |
| "我最近资料分析一直不稳,怎么办" | → 查历史记录 + 建议 |
| "帮我看看最近哪个模块最弱" | → 统计分析 + 回复 |
| "导出错题本" / "把错题发给我" / "生成报告" | → export_xlsx.js,发回文件 |
| "只导出待二刷的" | → 筛选导出,仅待二刷题目 |
| "导出判断推理的错题" | → 按科目筛选导出 |
| "导出最近两周的" | → 按时间筛选导出 |
| "只导出待二刷的资料分析题" | → 多条件组合筛选导出 |
| "资料-乘积增长-公式不熟-待二刷"(快捷格式) | → 直接归档,不追问 |
| 二刷时回复"记得" / "不记得" | → review_reminder.js 处理,连续2次记得→已掌握 |
| "同步到飞书" / "更新飞书错题本" | → feishu_doc.js,同步含截图 |
---
## 四、数据结构
所有数据以 JSON 存储在 `~/.openclaw/skills/kaogong-study-tracker/data/`。
### 4.1 每日记录 `daily/{YYYY-MM-DD}.json`
```json
{
"date": "2026-03-17",
"modules": {
"言语理解": { "wrong": 6, "total": 40 },
"数量关系": { "wrong": 8, "total": 15 },
"判断推理": { "wrong": 10, "total": 40 },
"资料分析": { "wrong": 7, "total": 20 },
"申论": { "written": false }
},
"mood": "中性",
"note": "用户原话"
}
```
### 4.2 错题本 `wrong_questions.json`
```json
[
{
"id": "uuid",
"date": "2026-03-17",
"source": "image",
"module": "判断推理",
"subtype": "逻辑判断",
"question_text": "题目文字;图形题写对规律的描述",
"visual_description": "图形推理/统计图的详细视觉描述(多模态模型生成)",
"answer": "B",
"user_annotation": "用户手写批注",
"error_reason": "知识点不会 | 粗心 | 时间不够 | 概念混淆",
"keywords": ["假言命题", "逆否命题"],
"raw_image_b64": "base64...",
"status": "待二刷 | 已掌握"
}
]
```
### 4.3 统计缓存 `stats_cache.json`
```json
{
"last_updated": "2026-03-17",
"streak": 5,
"total_days_studied": 12,
"weak_modules": ["数量关系", "判断推理"],
"module_accuracy": {
"言语理解": 0.82,
"数量关系": 0.51,
"判断推理": 0.68,
"资料分析": 0.74
}
}
```
---
## 五、核心流程
### Step 1:消息路由(`parse_input.js`)
```
文字消息 → parseStudyInput() 提取科目/错题数/情绪
图片消息 → parseImageInput() 调用多模态模型
```
**图片处理流程:**
1. 读取 `config.json` 中的 `multimodal` 配置
2. 如未配置 → 回复"请先配置多模态模型 API,见安装提示"
3. 调用多模态模型,提取:科目、题型、题目内容、视觉描述、答案、错误原因推测
4. `needs_confirm` 不为 null 时追问(最多一个问题);caption 已含原因则直接归档
**追问只问一次,按优先级:**
- 识别不到科目 → 问科目
- 原因不明确 → 问"粗心还是没掌握还是时间不够"
- 信息完整 → 不追问,直接归档
### Step 2:归类错题原因
| 原因 | 关键词 |
|----------|------------------------------|
| 知识点不会 | 不懂、没学过、概念不清楚 |
| 粗心 | 看错、算错、选反了 |
| 时间不够 | 没做完、最后几题蒙的 |
| 概念混淆 | 搞混了、分不清、以为是 |
### Step 3:更新记录(`update_daily.js`)
写入 `daily/{date}.json`,同步更新 `stats_cache.json`(连续打卡、模块准确率)。
### Step 4:生成回复
见 `references/reply_templates.md`,150 字以内。语气见 `references/tone_guide.md`。
### Step 5:导出 Excel(`export_xlsx.js`)
- 截图原图通过 `openpyxl`(Python)嵌入对应行
- Windows 兼容:先尝试 `python3`,失败自动 fallback 到 `python`
- 输出两个 Sheet:**错题本**(含截图列)+ **每日记录**
- 可直接发给 Kimi / 其他模型做趋势分析
### Step 6:同步飞书云文档(`feishu_doc.js`,可选)
- 需配置 `feishu_doc.app_id` / `app_secret` / `doc_token`
- 截图上传飞书文件系统后作为图片块插入文档
- 对图形推理、统计图最有用:飞书内直接看图,不用下载
### Step 7(可选):定时推送
每天 21:00 触发 `daily_summary.js`,自动发当日总结。
---
## 六、文件索引
| 文件 | 作用 |
|---------------------------------|---------------------------------|
| `scripts/parse_input.js` | 文字解析 + 多模态图片识别 |
| `scripts/update_daily.js` | 写入每日记录 + 统计缓存 |
| `scripts/export_xlsx.js` | 导出 Excel(含截图嵌入,openpyxl) |
| `scripts/feishu_doc.js` | 同步到飞书云文档(含图片块,可选) |
| `scripts/daily_summary.js` | 定时汇总并主动发送 |
| `references/reply_templates.md` | 回复话术模板 |
| `references/tone_guide.md` | 语气风格指引 |
| `assets/module_map.json` | 科目/模块名称标准化映射 |
| `assets/config.example.json` | 配置模板(多模态 + 飞书),复制为 config.json 使用 |
---
## 七、错误处理
- 未配置多模态 API 却发图片 → 回复安装提示,引导配置
- 模型识别返回 error → 回复"没识别出来,能文字描述一下题目吗?"
- 文字消息解析不出科目 → 回复"能说说今天做了哪个科目、错了几道吗?"
- 数据写入失败 → 记录 error log,回复"记录暂时存不上,你提醒我稍后再试"
- 连续 3 天无打卡 → 下次收到消息时,回复末尾轻轻提一句
---
## 八、隐私说明
所有数据(含截图 base64)存储在本地,不上传任何云端(飞书同步除外,仅在用户主动触发时上传到用户自己的飞书文档)。
---
## 九、如果这个 Skill 对你有帮助
**⭐ Star** 这个仓库,让更多备考的人能找到它
**🍴 Fork** 改成你的考试类型(省考 / 事业单位 / 军考……)
有问题欢迎提 Issue 或 PR。
> https://github.com/KaguraNanaga/kaogong-study-tracker
FILE:.gitignore
node_modules/
data/
*.xlsx
.env
.DS_Store
*.log
config.json
FILE:package.json
{
"name": "kaogong-study-tracker",
"version": "0.1.0",
"description": "朱批录 — 考公备考追踪 OpenClaw Skill",
"keywords": [
"openclaw",
"openclaw-skill",
"kaogong",
"gongkao",
"civil-service-exam",
"study-tracker",
"exam-prep"
],
"author": "",
"license": "MIT",
"engines": {
"node": ">=18"
},
"dependencies": {
"xlsx": "^0.18.5"
},
"scripts": {
"export": "node scripts/export_xlsx.js",
"export:pending": "node scripts/export_xlsx.js --pending-only",
"summary": "node scripts/daily_summary.js",
"setup:ocr": "pip install paddlepaddle paddleocr",
"setup:ocr:cn": "pip install paddlepaddle paddleocr -i https://pypi.tuna.tsinghua.edu.cn/simple"
}
}
FILE:README.md
# 朱批录 · kaogong-study-tracker
> 一个运行在你电脑上的考公备考助手,基于 [OpenClaw](https://openclaw.ai) Skill 框架构建。
> 把每天的套题截图或成绩发给它,它帮你归档错题、分析弱点、定时提醒——所有数据留在本地,不上传任何云端。
[](https://openclaw.ai)
[](LICENSE)
[](https://nodejs.org)
---
## 为什么做这个
备考行测、申论,每天刷套题是标准动作。但大多数人的复习路径是这样的:
> 做完一套卷子 → 对答案 → 叹口气 → 翻篇 → 明天继续
真正提分的部分——错题归类、原因分析、二刷追踪——因为太麻烦,往往被跳过。
朱批录想解决的就是这件事:**让"做完题之后的整理"变得几乎不需要努力**。你只需要把错题截图发出来,或者发一句"今天判断推理错了8道",剩下的它来做。
---
## 它能做什么
### 错题自动识别与归档
发一张错题截图,朱批录调用你配置的多模态模型(qwen3-vl、kimi-k2.5、claude-sonnet-4-6 等),自动提取:
- 题目完整内容(包括图形推理的视觉描述、统计图的数据)
- 所属科目和题型
- 正确答案(如图片中可见)
- 错误原因推测(知识点不会 / 粗心 / 时间不够 / 概念混淆)
- 知识点标签
识别失败时,提示你把文字复制粘贴发过来,一样可以整理。
### 每日打卡与进度追踪
直接用自然语言汇报:
```
今天行测做完了,言语4错,判断8错,数量5错,资料3错
```
朱批录记录进度,统计各模块7日准确率,找出弱项。
### 晚间自动总结
每天21:00主动发一条消息,汇总当天成绩、指出最弱模块、给一条具体的明天建议。不需要你主动问。
### 导出 Excel 错题本
说"导出错题本",生成一份包含两个 Sheet 的 `.xlsx` 文件:
- **错题本**:题目内容、图形视觉描述、错误原因、知识点标签、二刷状态,截图原图嵌入对应行
- **每日记录**:各科目错题数流水账
这份文件可以直接发给 Kimi、ChatGPT 或其他模型,让它帮你分析近期趋势、给出复习建议。
### 同步到飞书云文档(可选)
说"同步到飞书",把最新错题(含截图原图)写入飞书云文档,手机上直接查看,对图形推理题尤其有用。
---
## 设计原则
**对话即操作**——一切通过聊天完成,不需要打开任何 App、不需要编辑配置文件。
**本地优先**——错题数据(包括截图)存在你自己电脑的 `~/.openclaw/` 里,不经过任何第三方服务器。
**失败软降级**——图片识别失败时给出明确提示,引导手动输入,不中断流程。
**不啰嗦**——每条回复控制在150字以内,只说最关键的一件事。
---
## 支持的考试类型
朱批录的科目映射默认覆盖国考/省考标准科目,也适用于:
| 考试类型 | 适配情况 |
|---------|---------|
| 国家公务员考试(国考) | 完整支持,行测5模块 + 申论 |
| 省级公务员考试(省考) | 支持,部分省份题型有差异可 Fork 调整 |
| 事业单位联考(职测) | 支持,科目名称略有不同 |
| 军队文职考试 | 基本支持,部分模块名需手动映射 |
| 选调生考试 | 支持 |
---
## 支持的聊天渠道
OpenClaw 支持哪个渠道,朱批录就支持哪个,逻辑层完全平台无关。
| 渠道 | 说明 |
|------|-----|
| 飞书 | 推荐,国内访问稳定,支持图片消息 |
| Telegram | 需要科学上网,体验流畅 |
| WhatsApp | 支持 |
| Discord | 支持 |
| iMessage | 支持(macOS 设备) |
---
## 快速安装
### 前置条件
- [OpenClaw](https://openclaw.ai) 已安装并运行(Node.js ≥ 18)
### 安装步骤
```bash
# 1. 克隆到 OpenClaw 的 skills 目录
cd ~/.openclaw/skills
git clone https://github.com/KaguraNanaga/kaogong-study-tracker
# 2. 安装依赖
cd kaogong-study-tracker
npm install
# 3. 在 workspace.yaml 中启用
```
在你的 `workspace.yaml` 中添加:
```yaml
skills:
- kaogong-study-tracker
cron_jobs:
- name: "备考晚间总结"
schedule: "0 21 * * *"
action:
type: run_script
script: skills/kaogong-study-tracker/scripts/daily_summary.js
channel: feishu # 改成你用的渠道
```
详细的飞书接入配置参考 [`assets/workspace-example.yaml`](assets/workspace-example.yaml)。
### 首次使用
安装后发任意消息,朱批录会发一条说明。图片识别使用 OpenClaw 已配置的模型,无需额外设置。
---
## 数据结构
所有数据存储在本地:
```
~/.openclaw/skills/kaogong-study-tracker/data/
├── config.json ← 模型配置(setup_done + model)
├── daily/
│ ├── 2026-03-15.json ← 每天的套题成绩
│ ├── 2026-03-16.json
│ └── ...
├── wrong_questions.json ← 所有错题(累积)
├── stats_cache.json ← 7日模块准确率、连续打卡天数
└── exports/
└── 备考记录_2026-03-17.xlsx
```
错题记录的完整字段:
```json
{
"id": "uuid",
"date": "2026-03-17",
"source": "image",
"module": "判断推理",
"subtype": "图形推理",
"question_text": "3×3九宫格,箭头叠加规律……",
"visual_description": "每行第3个图形等于第1、2个图形的箭头叠加,重复箭头消去……",
"answer": "A",
"user_annotation": "没看出规律",
"error_reason": "知识点不会",
"keywords": ["图形推理", "九宫格", "叠加规律"],
"raw_image_b64": "...",
"status": "待二刷"
}
```
---
## 文件结构
```
kaogong-study-tracker/
├── SKILL.md ← OpenClaw 读取的主文件(含完整流程文档)
├── package.json
├── scripts/
│ ├── onboarding.js ← 首次安装对话引导
│ ├── parse_input.js ← 消息解析 + 多模态模型调用
│ ├── update_daily.js ← 写入每日记录和统计缓存
│ ├── export_xlsx.js ← 导出 Excel(含截图嵌入,openpyxl)
│ ├── feishu_doc.js ← 同步到飞书云文档(可选)
│ └── daily_summary.js ← 定时晚间总结推送
├── references/
│ ├── reply_templates.md ← 5种回复模板(正常/情绪低/弱点分析等)
│ └── tone_guide.md ← 语气规范:像朋友,不像 AI 助手
└── assets/
├── module_map.json ← 科目别名映射("逻辑"→"判断推理" 等)
├── config.example.json ← 飞书云文档配置模板
└── workspace-example.yaml ← 飞书/Telegram 接入配置示例
```
---
## 贡献
欢迎 PR,尤其是:
- 其他考试类型的科目映射(军考、选调、教师编……)
- 针对特定模型的识别 prompt 优化
- 飞书云文档同步的稳定性改进
- 英文版 README(方便海外华人使用)
提 Issue 之前可以先看看 [SKILL.md](SKILL.md),里面有完整的技术流程说明。
---
## 如果这个项目对你有帮助
每年参加公考的人超过两百万,但专门为备考设计的 OpenClaw Skill 几乎是空白。
如果朱批录帮你或朋友把整理错题这件事变得轻松一点——
**⭐ Star 一下,让更多备考的人能找到它**
**🍴 Fork 改成你的考试类型——省考、事业单位、军考都需要类似的工具**
有问题或改进想法,欢迎提 Issue 或直接 PR。
---
## License
MIT © 2026
FILE:scripts/daily_summary.js
/**
* daily_summary.js
* 定时任务脚本(每天 21:00 由 OpenClaw cron 触发)。
* 汇总当日数据,主动推送总结消息。
*
* workspace.yaml 配置示例:
* cron_jobs:
* - name: "备考晚间总结"
* schedule: "0 21 * * *"
* action:
* type: run_script
* script: skills/kaogong-study-tracker/scripts/daily_summary.js
*/
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(
process.env.HOME || process.env.USERPROFILE,
'.openclaw/skills/kaogong-study-tracker/data'
);
function buildSummaryMessage() {
const today = new Date().toISOString().slice(0, 10);
const dailyPath = path.join(DATA_DIR, 'daily', `today.json`);
const cachePath = path.join(DATA_DIR, 'stats_cache.json');
// 今天没有记录
if (!fs.existsSync(dailyPath)) {
return `今天还没打卡,要发一下今天的题目情况吗?(发"跳过"可以记录休息)`;
}
const daily = JSON.parse(fs.readFileSync(dailyPath, 'utf-8'));
if (daily.skipped) {
return `今天休息了,记下来了。明天继续 💪`;
}
const cache = fs.existsSync(cachePath)
? JSON.parse(fs.readFileSync(cachePath, 'utf-8'))
: {};
const modules = Object.entries(daily.modules || {});
if (modules.length === 0) {
return `今天的记录有点简略,有空补充一下各科目错题数吗?`;
}
// 找今天最弱模块
const sortedMods = modules
.filter(([, v]) => v.wrong != null)
.sort(([, a], [, b]) => (b.wrong || 0) - (a.wrong || 0));
const weakestToday = sortedMods[0];
const streak = cache.streak || 1;
// 错题分布行
const modLine = sortedMods
.map(([mod, v]) => `mod v.wrong错`)
.join(' / ');
// 连续打卡
const streakLine = streak >= 3
? `连续打卡第 streak 天 🔥`
: `今天打卡完成 ✅`;
// 明日建议(基于今天最弱 + 历史弱项)
const suggestion = buildSuggestion(weakestToday, cache.weak_modules);
return [
`今日总结:modLine`,
suggestion,
streakLine,
].join('\n');
}
function buildSuggestion([modName, modData], weakModules) {
if (!modName) return '';
const SUGGESTIONS = {
'判断推理': '判断推理错得多——明天可以专项刷逻辑判断,重点看假言命题。',
'数量关系': '数量关系是硬伤,明天计时做 10 道工程/行程题,找一找节奏。',
'资料分析': '资料分析主要看速度,明天练习"先看问题再看材料"的顺序。',
'言语理解': '言语理解错得多,看看是不是主旨题——这类题有固定解题逻辑。',
'申论': '申论今天没写——明天哪怕只做一道归纳概括,保持手感。',
};
return SUGGESTIONS[modName] || `明天重点看一下【modName】,把错题过一遍。`;
}
// 直接运行时,打印消息(OpenClaw 会捕获 stdout 作为推送内容)
const msg = buildSummaryMessage();
console.log(msg);
module.exports = { buildSummaryMessage };
FILE:scripts/export_xlsx.js
/**
* export_xlsx.js
* 导出错题本为 .xlsx,支持筛选和截图嵌入。
*
* 筛选参数:
* --pending-only 只导出"待二刷"
* --module=判断推理 只导出某科目
* --days=30 只导出最近 N 天
* --no-images 不嵌入截图
*
* 触发方式(用户说):
* "导出错题本"
* "只导出待二刷的"
* "导出判断推理的错题"
* "导出最近两周的"
* "只导出待二刷的资料分析题"
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFile } = require('child_process');
const DATA_DIR = path.join(os.homedir(), '.openclaw/skills/kaogong-study-tracker/data');
const OUT_DIR = path.join(DATA_DIR, 'exports');
const WQ_PATH = path.join(DATA_DIR, 'wrong_questions.json');
const DAILY_DIR = path.join(DATA_DIR, 'daily');
// ─── Windows 兼容:python3 / python 双 fallback ──────────────
function runPython(args, timeout = 60_000) {
return new Promise((resolve, reject) => {
function attempt(cmd) {
execFile(cmd, args, { timeout }, (err, stdout, stderr) => {
if (err && err.code === 'ENOENT' && cmd === 'python3') return attempt('python');
if (err) return reject({ error: err, stderr });
resolve({ stdout, stderr });
});
}
attempt('python3');
});
}
// ─── 数据读取 ─────────────────────────────────────────────────
function loadWrongQuestions() {
if (!fs.existsSync(WQ_PATH)) return [];
return JSON.parse(fs.readFileSync(WQ_PATH, 'utf-8'));
}
function loadDailyRecords() {
if (!fs.existsSync(DAILY_DIR)) return [];
return fs.readdirSync(DAILY_DIR)
.filter(f => f.endsWith('.json'))
.sort().reverse()
.map(f => JSON.parse(fs.readFileSync(path.join(DAILY_DIR, f), 'utf-8')));
}
// ─── 生成 Python 脚本(动态,含图片路径) ────────────────────
/**
* 把带图片的错题数据写成一个临时 Python 脚本,让 openpyxl 执行。
* 动态生成是为了把图片路径直接硬编码进脚本,避免传参过长。
*/
function buildPythonScript({ wrongRows, dailyRows, outPath, imageMap, pendingOnly }) {
// imageMap: { rowIndex: tmpImagePath }
const imageMapJson = JSON.stringify(imageMap);
const wrongJson = JSON.stringify(wrongRows);
const dailyJson = JSON.stringify(dailyRows);
const sheetName = pendingOnly ? '待二刷错题' : '错题本';
return `
import openpyxl
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.drawing.image import Image as XLImage
from openpyxl.utils import get_column_letter
import json, os
wrong_rows = json.loads(JSON.stringify(wrongJson))
daily_rows = json.loads(JSON.stringify(dailyJson))
image_map = json.loads(JSON.stringify(imageMapJson))
out_path = JSON.stringify(outPath)
sheet_name = JSON.stringify(sheetName)
HEADER_BG = "2D5FA1"
HEADER_FG = "FFFFFF"
ROW_HEIGHT = 22
IMG_ROW_H = 160 # 含图片行更高
wb = Workbook()
# ── Sheet 1: 错题本 ────────────────────────────────
ws = wb.active
ws.title = sheet_name
headers = ["日期","科目","题型","错误原因","题目内容","视觉描述","正确答案","解析/批注","知识点标签","状态","来源","截图"]
col_widths = [10, 12, 18, 12, 40, 45, 8, 30, 20, 10, 12, 20]
for ci, (h, w) in enumerate(zip(headers, col_widths), 1):
cell = ws.cell(row=1, column=ci, value=h)
cell.font = Font(bold=True, color=HEADER_FG, name="Arial", size=10)
cell.fill = PatternFill("solid", start_color=HEADER_BG)
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
ws.column_dimensions[get_column_letter(ci)].width = w
ws.row_dimensions[1].height = 26
ws.freeze_panes = "A2"
for ri, row in enumerate(wrong_rows, 2):
for ci, val in enumerate(row, 1):
cell = ws.cell(row=ri, column=ci, value=val)
cell.alignment = Alignment(vertical="center", wrap_text=(ci in [5, 6, 8]))
cell.font = Font(name="Arial", size=9)
# 嵌入截图(如果有)
img_path = image_map.get(str(ri - 2)) # ri-2 对应 wrong_rows 的索引
if img_path and os.path.exists(img_path):
try:
img = XLImage(img_path)
# 按比例缩放到宽 160px
ratio = 160 / img.width if img.width > 0 else 1
img.width = int(img.width * ratio)
img.height = int(img.height * ratio)
col_letter = get_column_letter(len(headers)) # 最后一列
ws.add_image(img, f"{col_letter}{ri}")
ws.row_dimensions[ri].height = max(IMG_ROW_H, img.height * 0.75 + 10)
except Exception as e:
ws.cell(row=ri, column=len(headers), value=f"[图片加载失败: {e}]")
else:
has_visual = bool(row[5] if len(row) > 5 else None)
ws.row_dimensions[ri].height = 60 if has_visual else ROW_HEIGHT
# ── Sheet 2: 每日记录 ─────────────────────────────
ws2 = wb.create_sheet("每日记录")
d_headers = ["日期","状态","心情","言语(错)","数量(错)","判断(错)","资料(错)","申论","总错题","备注"]
d_widths = [12, 8, 8, 10, 10, 10, 10, 8, 8, 30]
for ci, (h, w) in enumerate(zip(d_headers, d_widths), 1):
cell = ws2.cell(row=1, column=ci, value=h)
cell.font = Font(bold=True, color=HEADER_FG, name="Arial", size=10)
cell.fill = PatternFill("solid", start_color=HEADER_BG)
cell.alignment = Alignment(horizontal="center", vertical="center")
ws2.column_dimensions[get_column_letter(ci)].width = w
ws2.row_dimensions[1].height = 26
ws2.freeze_panes = "A2"
for ri, row in enumerate(daily_rows, 2):
for ci, val in enumerate(row, 1):
cell = ws2.cell(row=ri, column=ci, value=val)
cell.alignment = Alignment(vertical="center")
cell.font = Font(name="Arial", size=9)
ws2.row_dimensions[ri].height = ROW_HEIGHT
wb.save(out_path)
print(out_path)
`.trim();
}
// ─── 主导出函数 ──────────────────────────────────────────────
async function exportXlsx({ pendingOnly = false, moduleFilter = null, daysFilter = null, withImages = true } = {}) {
if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true });
const questions = loadWrongQuestions();
const dailyRecords = loadDailyRecords();
// 组合筛选:状态 + 科目 + 时间
const cutoffDate = daysFilter
? new Date(Date.now() - daysFilter * 86400000).toISOString().slice(0, 10)
: null;
const filtered = questions.filter(q => {
if (pendingOnly && q.status === '已掌握') return false;
if (moduleFilter && q.module !== moduleFilter) return false;
if (cutoffDate && (q.date ?? '') < cutoffDate) return false;
return true;
});
// ── 错题行数据 ───────────────────────────────────────────────
const wrongRows = filtered.map(q => [
q.date ?? '',
q.module ?? '',
q.subtype ?? '',
q.error_reason ?? '',
q.question_text ?? q.question_desc ?? '',
q.visual_description ?? '', // 视觉描述(图形/图表题)
q.answer ?? '',
q.analysis ?? q.user_annotation ?? '',
Array.isArray(q.keywords) ? q.keywords.join('、') : '',
q.status ?? '待二刷',
q.source_engine ?? (q.source === 'image' ? '图片' : '文字'),
'', // 截图列占位,由 Python 脚本填充图片
]);
// ── 图片临时文件 ──────────────────────────────────────────────
const tmpFiles = [];
const imageMap = {}; // { rowIndex(string): tmpPath }
if (withImages) {
filtered.forEach((q, idx) => {
if (!q.raw_image_b64) return;
const tmpPath = path.join(os.tmpdir(), `qimg_Date.now()_idx.jpg`);
try {
fs.writeFileSync(tmpPath, Buffer.from(q.raw_image_b64, 'base64'));
imageMap[String(idx)] = tmpPath;
tmpFiles.push(tmpPath);
} catch (_) {}
});
}
// ── 每日记录行数据 ────────────────────────────────────────────
const dailyRows = dailyRecords.map(r => {
const m = r.modules ?? {};
const totalWrong = ['言语理解','数量关系','判断推理','资料分析']
.reduce((s, mod) => s + (m[mod]?.wrong ?? 0), 0);
return [
r.date ?? '',
r.skipped ? '跳过' : '打卡',
r.mood ?? '',
m['言语理解']?.wrong ?? '',
m['数量关系']?.wrong ?? '',
m['判断推理']?.wrong ?? '',
m['资料分析']?.wrong ?? '',
m['申论']?.written ? '是' : '否',
totalWrong || '',
r.note ?? '',
];
});
// ── 生成并运行 Python 脚本 ─────────────────────────────────────
const today = new Date().toISOString().slice(0, 10);
const parts = [];
if (pendingOnly) parts.push('待二刷');
if (moduleFilter) parts.push(moduleFilter);
if (daysFilter) parts.push(`近daysFilter天`);
const suffix = parts.length ? '_' + parts.join('_') : '';
const outPath = path.join(OUT_DIR, `备考记录_todaysuffix.xlsx`);
const pyScript = buildPythonScript({ wrongRows, dailyRows, outPath, imageMap, pendingOnly });
const tmpPy = path.join(os.tmpdir(), `export_Date.now().py`);
fs.writeFileSync(tmpPy, pyScript, 'utf-8');
try {
await runPython([tmpPy]);
} finally {
try { fs.unlinkSync(tmpPy); } catch (_) {}
tmpFiles.forEach(f => { try { fs.unlinkSync(f); } catch (_) {} });
}
const pendingCount = questions.filter(q => q.status !== '已掌握').length;
const imgCount = Object.keys(imageMap).length;
console.log(`[export] 已导出:outPath`);
console.log(`[export] 错题 questions.length 条,待二刷 pendingCount 条,含截图 imgCount 张`);
console.log(`ATTACH:outPath`);
return outPath;
}
// ─── CLI 入口 ─────────────────────────────────────────────────
if (require.main === module) {
const pendingOnly = process.argv.includes('--pending-only');
const withImages = !process.argv.includes('--no-images');
const moduleArg = process.argv.find(a => a.startsWith('--module='));
const daysArg = process.argv.find(a => a.startsWith('--days='));
const moduleFilter = moduleArg ? moduleArg.split('=')[1] : null;
const daysFilter = daysArg ? parseInt(daysArg.split('=')[1]) : null;
exportXlsx({ pendingOnly, moduleFilter, daysFilter, withImages }).catch(e => {
console.error('[export] 失败:', e);
process.exit(1);
});
}
module.exports = { exportXlsx };
FILE:scripts/feishu_doc.js
/**
* feishu_doc.js
* 将错题本同步到飞书云文档,支持图片直接嵌入——对图形推理/统计图最有用。
*
* 可行性说明:
* 飞书开放平台提供「云文档 API」,可以程序化创建/更新文档、插入图片块。
* 图片先上传到飞书文件系统获取 file_token,再作为图片块插入文档。
* 最终效果:在飞书里直接看到错题截图 + 分析文字,不需要下载 Excel。
*
* 前置配置(在 config.json 中填写):
* {
* "feishu_doc": {
* "enabled": true,
* "app_id": "cli_xxxxxx",
* "app_secret": "xxxxxxxx",
* "doc_token": "xxxxxx" // 飞书文档 URL 中的 token 部分
* }
* }
*
* 获取 app_id / app_secret:飞书开放平台 → 创建企业自建应用 → 权限:docs:doc
* 获取 doc_token:新建一个飞书文档,URL 中 /wiki/ 或 /docs/ 后面的字符串就是 token
*
* 触发方式:用户说"同步到飞书" / "更新飞书错题本"
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const DATA_DIR = path.join(os.homedir(), '.openclaw/skills/kaogong-study-tracker/data');
const WQ_PATH = path.join(DATA_DIR, 'wrong_questions.json');
// ─── 飞书 API 基础 ────────────────────────────────────────────
async function getTenantToken(appId, appSecret) {
const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
});
const data = await res.json();
if (data.code !== 0) throw new Error(`获取飞书 Token 失败: data.msg`);
return data.tenant_access_token;
}
/**
* 上传图片到飞书,返回 file_token。
* @param {string} imageBase64
* @param {string} token tenant_access_token
*/
async function uploadImage(imageBase64, token) {
const imgBuffer = Buffer.from(imageBase64, 'base64');
// 飞书上传接口需要 multipart/form-data
const FormData = (await import('node:buffer')).Blob
? globalThis.FormData // Node 18+
: require('form-data'); // 低版本 fallback
const form = new FormData();
form.append('image_type', 'message');
form.append('image', new Blob([imgBuffer], { type: 'image/jpeg' }), 'question.jpg');
const res = await fetch('https://open.feishu.cn/open-apis/im/v1/images', {
method: 'POST',
headers: { Authorization: `Bearer token` },
body: form,
});
const data = await res.json();
if (data.code !== 0) throw new Error(`图片上传失败: data.msg`);
return data.data.image_key;
}
// ─── 文档块构建 ───────────────────────────────────────────────
/** 构建一道错题对应的飞书文档块列表(文字 + 可选图片)。 */
function buildQuestionBlocks(q, imageKey) {
const statusEmoji = q.status === '已掌握' ? '✅' : '🔲';
const blocks = [];
// 标题块:科目 + 题型 + 状态
blocks.push({
block_type: 3, // heading2
heading2: {
elements: [{
type: 'text_run',
text_run: {
content: `statusEmoji [q.module·q.subtype] q.date`,
text_element_style: { bold: true },
},
}],
},
});
// 题目内容(如果有)
if (q.question_text) {
blocks.push({
block_type: 2, // text
text: {
elements: [{ type: 'text_run', text_run: { content: q.question_text } }],
style: {},
},
});
}
// 图片块(来自截图)
if (imageKey) {
blocks.push({
block_type: 27, // image
image: { token: imageKey, width: 400 },
});
}
// 视觉描述(多模态模型生成的,对图形题有用)
if (q.visual_description) {
blocks.push({
block_type: 2,
text: {
elements: [{
type: 'text_run',
text_run: {
content: `📐 图形描述:q.visual_description`,
text_element_style: { italic: true },
},
}],
style: {},
},
});
}
// 分析 / 知识点
const meta = [
q.error_reason && `❌ 原因:q.error_reason`,
q.answer && `✔️ 答案:q.answer`,
q.keywords?.length && `🏷 知识点:q.keywords.join('、')`,
q.analysis && `📝 分析:q.analysis`,
].filter(Boolean).join(' ');
if (meta) {
blocks.push({
block_type: 2,
text: {
elements: [{ type: 'text_run', text_run: { content: meta } }],
style: {},
},
});
}
// 分隔线
blocks.push({ block_type: 22 });
return blocks;
}
// ─── 插入文档块 ───────────────────────────────────────────────
async function appendBlocksToDoc(docToken, blocks, token) {
// 先获取文档末尾 block id
const docRes = await fetch(
`https://open.feishu.cn/open-apis/docx/v1/documents/docToken/blocks?page_size=500`,
{ headers: { Authorization: `Bearer token` } }
);
const docData = await docRes.json();
if (docData.code !== 0) throw new Error(`获取文档结构失败: docData.msg`);
const items = docData.data?.items ?? [];
const lastBlock = items[items.length - 1];
const parentId = docData.data?.document?.document_id ?? docToken;
const index = lastBlock ? items.length : 0;
const insertRes = await fetch(
`https://open.feishu.cn/open-apis/docx/v1/documents/docToken/blocks/parentId/children`,
{
method: 'POST',
headers: { Authorization: `Bearer token`, 'Content-Type': 'application/json' },
body: JSON.stringify({ children: blocks, index }),
}
);
const insertData = await insertRes.json();
if (insertData.code !== 0) throw new Error(`插入文档块失败: insertData.msg`);
return insertData;
}
// ─── 主同步函数 ───────────────────────────────────────────────
/**
* 把最新 N 条错题同步到飞书云文档。
* @param {{ recentOnly?: boolean, limit?: number }} options
*/
async function syncToFeishuDoc({ recentOnly = true, limit = 10 } = {}) {
const configPath = path.join(__dirname, '../config.json');
if (!fs.existsSync(configPath)) {
throw new Error('未找到 config.json,请先配置飞书参数(见 assets/config.example.json)');
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const fdConfig = config.feishu_doc;
if (!fdConfig?.enabled || !fdConfig.app_id || !fdConfig.app_secret || !fdConfig.doc_token) {
throw new Error('飞书云文档未启用或配置不完整,请检查 config.json');
}
const { app_id, app_secret, doc_token } = fdConfig;
let questions = [];
if (fs.existsSync(WQ_PATH)) {
questions = JSON.parse(fs.readFileSync(WQ_PATH, 'utf-8'));
}
// 只同步最新 limit 条(避免文档过长)
const toSync = recentOnly
? [...questions].sort((a, b) => (b.date ?? '').localeCompare(a.date ?? '')).slice(0, limit)
: questions;
if (!toSync.length) {
console.log('[feishu_doc] 没有错题需要同步');
return;
}
const token = await getTenantToken(app_id, app_secret);
console.log(`[feishu_doc] 开始同步 toSync.length 道错题...`);
for (const q of toSync) {
let imageKey = null;
if (q.raw_image_b64) {
try {
imageKey = await uploadImage(q.raw_image_b64, token);
} catch (e) {
console.warn(`[feishu_doc] 图片上传失败(q.date q.module):`, e.message);
}
}
const blocks = buildQuestionBlocks(q, imageKey);
await appendBlocksToDoc(doc_token, blocks, token);
console.log(`[feishu_doc] 已同步: q.date q.module q.subtype`);
}
console.log('[feishu_doc] 同步完成');
}
// ─── CLI 入口 ─────────────────────────────────────────────────
if (require.main === module) {
const recentOnly = !process.argv.includes('--all');
const limit = parseInt(process.argv.find(a => a.startsWith('--limit='))?.split('=')[1]) || 10;
syncToFeishuDoc({ recentOnly, limit }).catch(e => {
console.error('[feishu_doc] 同步失败:', e.message);
process.exit(1);
});
}
module.exports = { syncToFeishuDoc };
FILE:scripts/ocr_image.py
#!/usr/bin/env python3
"""
ocr_image.py
用 PaddleOCR 识别题目截图,提取文字,输出 JSON 到 stdout。
被 parse_input.js 通过子进程调用,不需要任何 API Key。
用法:
python ocr_image.py <图片路径>
python ocr_image.py --base64 <base64字符串>
输出(stdout):
{"success": true, "text": "识别出的完整文字", "lines": ["行1", "行2", ...]}
{"success": false, "error": "错误原因"}
依赖安装:
# CPU 版(推荐,无需 GPU)
pip install paddlepaddle paddleocr
# 如果在国内网络,用镜像加速:
pip install paddlepaddle -i https://pypi.tuna.tsinghua.edu.cn/simple
pip install paddleocr -i https://pypi.tuna.tsinghua.edu.cn/simple
"""
import sys
import json
import os
import base64
import tempfile
import re
def load_image_from_path(img_path):
if not os.path.exists(img_path):
return None, f"文件不存在: {img_path}"
return img_path, None
def load_image_from_base64(b64_str):
"""把 base64 字符串写成临时文件,返回路径。"""
try:
data = base64.b64decode(b64_str)
suffix = ".jpg"
# 检测文件头以判断格式
if data[:8] == b'\x89PNG\r\n\x1a\n':
suffix = ".png"
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
tmp.write(data)
tmp.close()
return tmp.name, None
except Exception as e:
return None, f"base64 解码失败: {e}"
def run_ocr(img_path):
"""调用 PaddleOCR,返回 (lines, full_text, error)。"""
try:
# 国内网络模型下载提速:把下载源从 HuggingFace 切换到百度对象存储(BOS)。
# 如果你在国内,取消下面这行的注释,首次运行时模型下载会快很多。
# os.environ['PADDLE_PDX_MODEL_SOURCE'] = 'BOS'
# 延迟导入,让错误信息更友好(必须在设置环境变量之后再 import)
from paddleocr import PaddleOCR
# use_angle_cls=True 能处理旋转文字(手机拍照常见)
# lang='ch' 支持中英文混合(行测题目标准场景)
# show_log=False 关掉冗长的 PaddlePaddle 日志
ocr = PaddleOCR(use_angle_cls=True, lang='ch', show_log=False)
# PaddleOCR v3.x 用 predict(),v2.x 用 ocr()
# 这里做兼容处理
if hasattr(ocr, 'predict'):
# v3.x API
results = ocr.predict(img_path)
lines = []
for res in results:
# v3 结果结构:res 是 dict,有 'rec_texts' 和 'rec_scores'
texts = res.get('rec_texts', [])
scores = res.get('rec_scores', [])
for text, score in zip(texts, scores):
if score > 0.5 and text.strip():
lines.append(text.strip())
else:
# v2.x API
results = ocr.ocr(img_path, cls=True)
lines = []
if results and results[0]:
for item in results[0]:
text, score = item[1][0], item[1][1]
if score > 0.5 and text.strip():
lines.append(text.strip())
if not lines:
return [], "", "未识别到文字,请检查图片是否清晰"
full_text = "\n".join(lines)
return lines, full_text, None
except ImportError:
return [], "", (
"PaddleOCR 未安装。请运行:\n"
"pip install paddlepaddle paddleocr\n"
"国内镜像:pip install paddlepaddle paddleocr "
"-i https://pypi.tuna.tsinghua.edu.cn/simple"
)
except Exception as e:
return [], "", f"OCR 识别出错: {e}"
def guess_module(text):
"""从识别文字中猜测科目,辅助后续解析。"""
patterns = {
"判断推理": ["判断推理", "逻辑判断", "图形推理", "定义判断", "类比推理"],
"数量关系": ["数量关系", "数学运算", "数字推理"],
"言语理解": ["言语理解", "语言表达", "阅读理解"],
"资料分析": ["资料分析", "图表分析"],
"申论": ["申论", "大作文", "归纳概括", "综合分析"],
}
for module, keywords in patterns.items():
for kw in keywords:
if kw in text:
return module
return None
def main():
args = sys.argv[1:]
if not args:
print(json.dumps({"success": False, "error": "用法: ocr_image.py <路径> 或 --base64 <字符串>"}, ensure_ascii=False))
sys.exit(1)
# 解析参数
if args[0] == "--base64":
if len(args) < 2:
print(json.dumps({"success": False, "error": "--base64 后需要跟 base64 字符串"}, ensure_ascii=False))
sys.exit(1)
img_path, err = load_image_from_base64(args[1])
is_temp = True
else:
img_path, err = load_image_from_path(args[0])
is_temp = False
if err:
print(json.dumps({"success": False, "error": err}, ensure_ascii=False))
sys.exit(1)
# 跑 OCR
lines, full_text, ocr_err = run_ocr(img_path)
# 清理临时文件
if is_temp and img_path and os.path.exists(img_path):
os.unlink(img_path)
if ocr_err:
print(json.dumps({"success": False, "error": ocr_err}, ensure_ascii=False))
sys.exit(1)
result = {
"success": True,
"text": full_text,
"lines": lines,
"line_count": len(lines),
"guessed_module": guess_module(full_text), # 可选:辅助 JS 侧解析
}
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
FILE:scripts/onboarding.js
/**
* onboarding.js
* 首次加载时发一条提示,不存任何配置,不问任何问题。
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const FLAG_PATH = path.join(
os.homedir(),
'.openclaw/skills/kaogong-study-tracker/.welcomed'
);
function hasWelcomed() {
return fs.existsSync(FLAG_PATH);
}
function markWelcomed() {
const dir = path.dirname(FLAG_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(FLAG_PATH, '1');
}
const WELCOME_MSG =
`朱批录已安装。
直接发文字就能记录,比如"今天判断推理错了8道"。
发截图的话,需要 OpenClaw 配置了支持图片输入的多模态模型才能自动识别。
没有的话也没关系,把题目文字手动复制过来发给我,一样能整理。`;
async function initOnboarding(sendMessage) {
if (hasWelcomed()) return;
await sendMessage(WELCOME_MSG);
markWelcomed();
}
module.exports = { initOnboarding };
FILE:scripts/parse_input.js
/**
* parse_input.js
* 将用户发来的消息(文字 或 图片)解析为结构化的备考数据。
*
* 图片识别:统一走多模态模型(在 config.json 中配置)。
* 支持图形推理、统计图表等 OCR 无法理解的题型。
* 未配置时发送 onboarding 提示,引导用户填写 API Key。
*
* Windows 兼容:execFile 先尝试 python3,失败则 fallback 到 python(导出用)。
*/
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const os = require('os');
const { handleReviewReply } = require('./review_reminder');
// ─────────────────────────────────────────────
// 工具函数
// ─────────────────────────────────────────────
const MODULE_MAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '../assets/module_map.json'), 'utf-8')
);
function normalizeModule(text) {
if (!text) return null;
const lower = text.toLowerCase();
for (const [standard, aliases] of Object.entries(MODULE_MAP.aliases)) {
if (aliases.some(a => lower.includes(a.toLowerCase()))) return standard;
}
return null;
}
function extractWrongCount(text) {
const patterns = [
/错[了了]?\s*(\d+)\s*[道题个]/,
/(\d+)\s*[道题个]?\s*[错误不对]/,
/(\d+)\s*错/,
];
for (const p of patterns) {
const m = text.match(p);
if (m) return parseInt(m[1], 10);
}
return null;
}
function inferErrorReason(text) {
const reasons = MODULE_MAP.error_reason_keywords;
for (const [reason, keywords] of Object.entries(reasons)) {
if (keywords.some(k => text.includes(k))) return reason;
}
return '未说明';
}
function detectMood(text) {
if (/太累|好烦|没用|放弃|崩了/.test(text)) return '低落';
if (/没时间|来不及|考试快|好焦虑|压力/.test(text)) return '焦虑';
if (/不错|还行|有进步|感觉好|状态好/.test(text)) return '良好';
return '中性';
}
// ─────────────────────────────────────────────
// ③ 多模态模型调用(可选,支持图形/图表理解)
// ─────────────────────────────────────────────
const MULTIMODAL_PROMPT = `
你是一个公考备考助手,正在分析用户发来的错题截图。
请从图片中提取以下信息,严格以 JSON 格式返回,不要有任何额外说明。
重要要求:
- 所有字段必须完整输出,禁止使用省略号(...)截断内容
- visual_description 对图形推理、统计图题目必须完整描述,不得省略任何细节
- question_text 对文字题必须包含题干和全部选项的完整文字
{
"module": "科目,只能是:言语理解/数量关系/判断推理/资料分析/申论 之一",
"subtype": "题型,如:逻辑判断/图形推理/定义判断/类比推理/数学运算/资料分析-增长率/主旨概括 等",
"question_text": "【完整输出】文字题:题干+全部选项;图形推理:描述图形规律;统计图:标题+所有数据",
"visual_description": "【完整输出,不得截断】图形推理/统计图的详细视觉描述:每个格子/数据点的具体内容、规律分析。纯文字题填 null",
"answer": "正确答案字母,图片中若不可见则填 null",
"user_annotation": "用户手写批注原文,没有填 null",
"error_reason_hint": "知识点不会/粗心/时间不够/概念混淆/无法判断",
"keywords": ["知识点标签,最多3个"]
}
如果图片模糊无法识别,返回:{"error": "图片无法识别"}
`.trim();
// ─────────────────────────────────────────────
// 图片识别结果 → 统一结构
// ─────────────────────────────────────────────
function buildConfirmPrompt(module, errorReason, caption) {
if (!module) return '这是哪个科目?(言语/数量/判断/资料/申论)';
if (errorReason === '未说明' && !caption) return '这道题是知识点没掌握、粗心,还是时间不够?';
return null;
}
function guessSubtype(text) {
if (/图形|图案/.test(text)) return '图形推理';
if (/定义|是指|是一种/.test(text)) return '定义判断';
if (/对于|就像|之于/.test(text)) return '类比推理';
if (/甲.*乙|假言|充分|必要/.test(text)) return '逻辑判断';
if (/增长[率速]|同比|环比|占比|百分/.test(text)) return '资料分析-增长率';
if (/工程|效率|天完成/.test(text)) return '数学运算-工程';
if (/速度|相遇|追及/.test(text)) return '数学运算-行程';
if (/主旨|意在|核心|观点/.test(text)) return '言语-主旨概括';
if (/填入.*恰当|横线|空白/.test(text)) return '言语-语句填空';
return '未识别';
}
function cleanQuestionText(lines) {
return lines
.filter(l => {
const t = l.trim();
return t && !/^\d+$/.test(t) && !/^第\s*\d+\s*题$/.test(t);
})
.join(' ')
.slice(0, 300);
}
function extractAnswer(text) {
const patterns = [
/[【\[]?答案[::]\s*([A-D])[】\]]?/,
/正确[答案选项]*[::]\s*([A-D])/,
];
for (const p of patterns) {
const m = text.match(p);
if (m) return m[1];
}
return null;
}
function extractKeywords(text, module) {
const km = {
'判断推理': [['假言命题',/假言|充分条件|必要条件/],['逆否命题',/逆否/],['图形推理',/图形|对称|旋转/],['定义判断',/定义|是指/]],
'数量关系': [['工程问题',/工程|效率|天完成/],['行程问题',/速度|相遇|追及/],['排列组合',/排列|组合|方案/]],
'资料分析': [['增长率',/增长率|同比|环比/],['比重',/比重|占比/],['倍数',/倍数|是.*倍/]],
'言语理解': [['主旨概括',/主旨|核心/],['语句填空',/填入|横线/],['细节判断',/符合原文/]],
};
return (km[module] || []).filter(([,p]) => p.test(text)).map(([k]) => k).slice(0, 3);
}
// ─────────────────────────────────────────────
// 图片消息主入口
// ─────────────────────────────────────────────
async function parseImageInput(imageBase64, caption, agentCall) {
// agentCall 是 OpenClaw 注入的模型调用函数(使用 workspace 里配置的模型)
// 如果没有注入(模型不支持图片),直接返回降级提示
if (typeof agentCall !== 'function') {
return {
success: false,
error: 'no_vision',
fallback_prompt: '没识别出来,可以把题目文字复制过来发给我,一样能整理。',
};
}
let engineResult;
try {
const promptWithCaption = caption
? `MULTIMODAL_PROMPT\n\n用户附带说明:「caption」`
: MULTIMODAL_PROMPT;
const raw = await agentCall({ image: imageBase64, text: promptWithCaption });
const parsed = JSON.parse((raw || '').replace(/```json|```/g, '').trim());
if (parsed.error) throw new Error(parsed.error);
engineResult = {
success: true,
source_engine: 'openclaw-agent',
module: normalizeModule(parsed.module) ?? parsed.module,
subtype: parsed.subtype ?? '未识别',
question_text: parsed.question_text ?? '',
visual_description: parsed.visual_description ?? null,
answer: parsed.answer ?? null,
user_annotation: parsed.user_annotation ?? null,
error_reason: parsed.error_reason_hint ?? '未说明',
keywords: parsed.keywords ?? [],
};
} catch (e) {
return {
success: false,
error: e.message,
fallback_prompt: '没识别出来,可以把题目文字复制过来发给我,一样能整理。',
};
}
return {
...engineResult,
date: new Date().toISOString().slice(0, 10),
source: 'image',
raw_image_b64: await compressImageAsync(imageBase64), // 压缩到 800px 宽再存
needs_confirm: buildConfirmPrompt(engineResult.module, engineResult.error_reason, caption),
};
}
// ─────────────────────────────────────────────
// 图片压缩(存储前缩至 800px 宽,减少 JSON 体积)
// ─────────────────────────────────────────────
/**
* 用 Canvas API(Node 18+ 没有,走 sharp 或直接限制尺寸)。
* OpenClaw 运行在 Node 环境,这里用 sharp 如果可用,否则原样返回。
* 安装:npm install sharp(可选,未安装时跳过压缩)
*/
function compressImage(base64) {
try {
const sharp = require('sharp'); // 可选依赖,未安装时 catch
const buf = Buffer.from(base64, 'base64');
// sharp 是异步的,这里同步包装(仅在图片很大时才值得)
// 实际使用时建议改为 async 版本
return base64; // 占位,下方 compressImageAsync 是真正的异步版本
} catch (_) {
return base64; // sharp 未安装,原样返回
}
}
/**
* 异步版本(推荐在 parseImageInput 中使用)。
* 将图片压缩到宽度 ≤800px,质量 80,减少存储体积约 60-80%。
*/
async function compressImageAsync(base64) {
try {
const sharp = require('sharp');
const buf = Buffer.from(base64, 'base64');
const out = await sharp(buf)
.resize({ width: 800, withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
return out.toString('base64');
} catch (_) {
return base64; // sharp 未安装或压缩失败,原样返回
}
}
// ─────────────────────────────────────────────
// 快捷录入模式
// ─────────────────────────────────────────────
/**
* 识别快捷格式:「科目-题型-原因-状态」
* 例:资料-乘积增长-公式不熟-待二刷
* 判断-逻辑判断-粗心
* 言语-主旨-没时间
*
* @returns {object|null} 解析结果,null 表示不是快捷格式
*/
function parseQuickEntry(text) {
// 快捷格式:至少两段用 - 或 — 分隔,第一段是科目关键词
const parts = text.split(/[-—·\/]/);
if (parts.length < 2) return null;
const module = normalizeModule(parts[0].trim());
if (!module) return null;
// 第二段:题型(可选)
const subtype = parts[1]?.trim() || '';
// 第三段:原因(可选,做关键词匹配)
const reasonRaw = parts[2]?.trim() || '';
const error_reason = inferErrorReason(reasonRaw) !== '未说明'
? inferErrorReason(reasonRaw)
: (reasonRaw || '未说明');
// 第四段:状态(可选)
const statusRaw = parts[3]?.trim() || '';
const status = /掌握|搞懂|会了/.test(statusRaw) ? '已掌握' : '待二刷';
// 自动提取知识点标签
const keywords = extractKeywords(`subtype reasonRaw`, module);
return {
source: 'quick',
date: new Date().toISOString().slice(0, 10),
module,
subtype: subtype || guessSubtype(reasonRaw),
question_text: text, // 保留原始快捷文字
error_reason,
keywords,
status,
needs_confirm: null, // 快捷模式不追问
};
}
// ─────────────────────────────────────────────
// 导出筛选指令解析
// ─────────────────────────────────────────────
/**
* 识别用户是否在请求筛选导出,返回筛选参数或 null。
* 支持:
* "导出错题本" / "导出全部"
* "只导出待二刷的"
* "导出判断推理的错题"
* "导出最近两周的" / "导出最近30天"
* "只导出待二刷的资料分析题"
*/
function parseExportCommand(text) {
if (!/导出|错题本|生成报告/.test(text)) return null;
const pending = /待二刷|未掌握/.test(text);
// 科目匹配
const module = normalizeModule(text);
// 时间匹配:最近N天 / 最近X周
let days = null;
const daysMatch = text.match(/最近\s*(\d+)\s*天/);
const weeksMatch = text.match(/最近\s*(\d+)\s*周/);
const monthMatch = text.match(/最近\s*(\d+)\s*个?月/);
if (daysMatch) days = parseInt(daysMatch[1]);
if (weeksMatch) days = parseInt(weeksMatch[1]) * 7;
if (monthMatch) days = parseInt(monthMatch[1]) * 30;
// 口语化时间
if (/上周|这周|本周/.test(text)) days = 7;
if (/本月|这个月/.test(text)) days = 30;
if (/两周|两个周/.test(text)) days = 14;
return { _export: true, pendingOnly: pending, moduleFilter: module, daysFilter: days };
}
// ─────────────────────────────────────────────
// 文字消息处理
// ─────────────────────────────────────────────
function parseStudyInput(message) {
// 优先尝试快捷录入格式:资料-乘积增长-公式不熟-待二刷
const quick = parseQuickEntry(message);
if (quick) return quick;
const result = {
date: new Date().toISOString().slice(0, 10),
source: 'text',
raw_message: message,
mood: detectMood(message),
parsed_modules: {},
has_exam: false,
skip_today: false,
needs_clarification: null,
};
if (/没做|没时间|跳过|明天补|休息/.test(message)) {
result.skip_today = true;
return result;
}
if (/做了|做完|刷了|套题|整套|一套/.test(message)) result.has_exam = true;
for (const sent of message.split(/[,。!?,.\n]/)) {
const mod = normalizeModule(sent);
if (mod) {
result.parsed_modules[mod] = {
wrong: extractWrongCount(sent),
total: null,
error_reason: inferErrorReason(sent),
};
result.has_exam = true;
}
}
if (result.has_exam && !Object.keys(result.parsed_modules).length) {
result.needs_clarification = '没太看懂,能说说今天做了哪个科目、错了几道吗?';
}
return result;
}
// ─────────────────────────────────────────────
// OpenClaw 统一入口
// ─────────────────────────────────────────────
async function handleMessage(message, { agentCall, sendMessage } = {}) {
const text = message.text ?? message.caption ?? '';
// 1. 二刷回复拦截(优先级最高,避免"记得"被当成普通消息)
if (message.type === 'text' && sendMessage) {
const reviewReply = handleReviewReply(text);
if (reviewReply !== null) {
await sendMessage(reviewReply);
return { _review: true };
}
}
// 2. 图片消息
if (message.type === 'image') {
return parseImageInput(message.imageBase64, message.caption ?? '', agentCall);
}
// 3. 导出筛选指令
const exportCmd = parseExportCommand(text);
if (exportCmd) return exportCmd;
// 4. 普通文字消息
return parseStudyInput(text);
}
module.exports = { handleMessage, parseStudyInput, parseImageInput, normalizeModule, detectMood };
FILE:scripts/review_reminder.js
/**
* review_reminder.js
* 隔天 20:00 由 cron 触发,从"待二刷"错题里随机抽3道,
* 发给用户让其自评"记得 / 不记得",连续答对2次自动改为"已掌握"。
*
* workspace.yaml 配置(见 assets/workspace-example.yaml):
* cron: 0 20 每隔一天 * * 即 "0 20 1-31/2 * *"
* script: skills/kaogong-study-tracker/scripts/review_reminder.js
*
* 状态文件:data/review_state.json
* 记录每道题的二刷历史,key 为题目 id。
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const DATA_DIR = path.join(os.homedir(), '.openclaw/skills/kaogong-study-tracker/data');
const WQ_PATH = path.join(DATA_DIR, 'wrong_questions.json');
const STATE_PATH = path.join(DATA_DIR, 'review_state.json');
const SESSION_PATH = path.join(DATA_DIR, 'review_session.json');
// ─── 工具函数 ─────────────────────────────────────────────────
function loadJson(p, fallback = {}) {
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch (_) { return fallback; }
}
function saveJson(p, data) {
const dir = path.dirname(p);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8');
}
// ─── 抽题逻辑 ─────────────────────────────────────────────────
/**
* 从待二刷错题里随机抽 n 道,优先抽最久没复习的。
*/
function pickQuestions(n = 3) {
if (!fs.existsSync(WQ_PATH)) return [];
const questions = loadJson(WQ_PATH, []);
const state = loadJson(STATE_PATH, {});
const pending = questions.filter(q => q.status !== '已掌握');
if (!pending.length) return [];
// 按上次复习时间升序(最久没看的排前面)
pending.sort((a, b) => {
const ta = state[a.id]?.last_reviewed ?? '2000-01-01';
const tb = state[b.id]?.last_reviewed ?? '2000-01-01';
return ta.localeCompare(tb);
});
// 取前 n*2 道里随机抽 n 道,避免总是同一批
const pool = pending.slice(0, Math.min(n * 2, pending.length));
const picked = [];
const used = new Set();
while (picked.length < n && picked.length < pool.length) {
const idx = Math.floor(Math.random() * pool.length);
if (!used.has(idx)) { used.add(idx); picked.push(pool[idx]); }
}
return picked;
}
/**
* 把一道错题格式化成发给用户的文字。
*/
function formatQuestion(q) {
const lines = [`[q.module · q.subtype || '']`];
if (q.question_text) lines.push(q.question_text.slice(0, 200));
if (q.visual_description) lines.push(`图形描述:q.visual_description.slice(0, 150)`);
if (q.answer) lines.push(`正确答案:q.answer`);
lines.push(`知识点:(q.keywords || []).join('、') || '未标记'`);
return lines.join('\n');
}
// ─── 主推送(cron 触发) ──────────────────────────────────────
function buildReminderMessage() {
const questions = pickQuestions(3);
if (!questions.length) {
return '待二刷的错题都清空了,今天不用复习!去刷新题吧。';
}
// 把本次抽到的题存入 session,等用户逐一回复
const session = {
date: new Date().toISOString().slice(0, 10),
questions: questions.map(q => q.id),
current: 0, // 当前正在回答第几道(0-based)
answers: {}, // { id: ['记得', '不记得', ...] }
};
saveJson(SESSION_PATH, session);
// 先发第一道
const first = questions[0];
return [
`二刷时间到,抽到 questions.length 道待复习的题。`,
'',
`第 1 / questions.length 道:`,
formatQuestion(first),
'',
'还记得这题的解法吗?回复 记得 或 不记得',
].join('\n');
}
// ─── 处理用户回复(在 parse_input.js 的 handleMessage 里调用) ──
/**
* 检测是否有进行中的二刷 session,并处理用户的"记得/不记得"回复。
* @returns {string|null} 要发送的下一条消息,null 表示没有进行中的 session
*/
function handleReviewReply(userText) {
if (!fs.existsSync(SESSION_PATH)) return null;
const session = loadJson(SESSION_PATH, null);
if (!session || session.current >= session.questions.length) return null;
const text = (userText || '').trim();
const isRemember = /记得|会了|掌握|对|知道/.test(text);
const isForget = /不记得|忘了|不会|错了|不对|不知道/.test(text);
if (!isRemember && !isForget) return null; // 不是对二刷的回复
const currentId = session.questions[session.current];
if (!session.answers[currentId]) session.answers[currentId] = [];
session.answers[currentId].push(isRemember ? '记得' : '不记得');
// 更新 review_state
const state = loadJson(STATE_PATH, {});
if (!state[currentId]) state[currentId] = { correct_streak: 0, total: 0 };
state[currentId].last_reviewed = new Date().toISOString().slice(0, 10);
state[currentId].total += 1;
if (isRemember) {
state[currentId].correct_streak = (state[currentId].correct_streak || 0) + 1;
} else {
state[currentId].correct_streak = 0;
}
saveJson(STATE_PATH, state);
// 连续答对 2 次 → 自动标记已掌握
const mastered = state[currentId].correct_streak >= 2;
if (mastered) {
markMastered(currentId);
}
// 移到下一题
session.current += 1;
saveJson(SESSION_PATH, session);
// 生成反馈 + 下一题(或结束)
const feedback = mastered
? `已标记为「已掌握」,很好!`
: isRemember
? `记录了。再答对一次就标为已掌握。`
: `记下了,下次还会再抽到这道。`;
if (session.current >= session.questions.length) {
// 全部回答完
const allQuestions = loadJson(WQ_PATH, []);
const remaining = allQuestions.filter(q => q.status !== '已掌握').length;
return `feedback\n\n本次复习完成!还剩 remaining 道待二刷。`;
}
// 下一道题
const nextId = session.questions[session.current];
const allQ = loadJson(WQ_PATH, []);
const nextQ = allQ.find(q => q.id === nextId);
if (!nextQ) return `feedback\n\n(下一道题找不到了,本次复习结束)`;
return [
feedback,
'',
`第 session.current + 1 / session.questions.length 道:`,
formatQuestion(nextQ),
'',
'还记得这题的解法吗?回复 记得 或 不记得',
].join('\n');
}
function markMastered(id) {
if (!fs.existsSync(WQ_PATH)) return;
const questions = loadJson(WQ_PATH, []);
const q = questions.find(q => q.id === id);
if (q) q.status = '已掌握';
saveJson(WQ_PATH, questions);
}
// ─── CLI 入口(cron 直接运行) ────────────────────────────────
if (require.main === module) {
const msg = buildReminderMessage();
console.log(msg);
}
module.exports = { buildReminderMessage, handleReviewReply };
FILE:scripts/update_daily.js
/**
* update_daily.js
* 将解析后的备考数据写入每日记录,并更新统计缓存。
*/
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(
process.env.HOME || process.env.USERPROFILE,
'.openclaw/skills/kaogong-study-tracker/data'
);
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
/**
* 备份 wrong_questions.json,保留最近10个备份,自动轮转旧备份。
*/
function backupWrongQuestions(wqPath) {
if (!fs.existsSync(wqPath)) return;
const backupDir = path.join(path.dirname(wqPath), 'backups');
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const dest = path.join(backupDir, `wrong_questions.ts.json`);
fs.copyFileSync(wqPath, dest);
// 保留最近 10 个,删除更早的
const backups = fs.readdirSync(backupDir)
.filter(f => f.startsWith('wrong_questions.') && f.endsWith('.json'))
.sort()
.reverse();
backups.slice(10).forEach(f => {
try { fs.unlinkSync(path.join(backupDir, f)); } catch (_) {}
});
}
/**
* 写入每日记录
* @param {ParsedInput} parsed 来自 parse_input.js 的解析结果
* @param {string} note 可选备注
*/
function updateDailyRecord(parsed, note = '') {
ensureDir(path.join(DATA_DIR, 'daily'));
const filePath = path.join(DATA_DIR, 'daily', `parsed.date.json`);
// 如果当天已有记录,合并而非覆盖
let existing = {};
if (fs.existsSync(filePath)) {
existing = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
const record = {
date: parsed.date,
skipped: parsed.skip_today,
modules: {},
...existing,
mood: parsed.mood,
note: note || parsed.raw_message.slice(0, 100),
updated_at: new Date().toISOString(),
};
// 合并模块数据
for (const [mod, data] of Object.entries(parsed.parsed_modules)) {
record.modules[mod] = {
...record.modules[mod],
...data,
};
}
fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf-8');
console.log(`[kaogong-tracker] 每日记录已写入: filePath`);
// 更新统计缓存
updateStatsCache(parsed.date, record);
return record;
}
/**
* 更新统计缓存(连续打卡天数、模块准确率)
*/
function updateStatsCache(today, todayRecord) {
const cachePath = path.join(DATA_DIR, 'stats_cache.json');
let cache = {
last_updated: today,
total_days_studied: 0,
streak: 0,
module_accuracy: {},
};
if (fs.existsSync(cachePath)) {
cache = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
}
// 统计连续打卡
if (!todayRecord.skipped) {
cache.total_days_studied = (cache.total_days_studied || 0) + 1;
// 简单连续天数计算:如果昨天有记录则 +1,否则重置为 1
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const yPath = path.join(DATA_DIR, 'daily', `yesterday.toISOString().slice(0,10).json`);
if (fs.existsSync(yPath)) {
cache.streak = (cache.streak || 0) + 1;
} else {
cache.streak = 1;
}
}
// 更新模块准确率(7日滚动平均)
const last7 = getLast7Days(today);
const moduleStats = {};
for (const dateStr of last7) {
const p = path.join(DATA_DIR, 'daily', `dateStr.json`);
if (!fs.existsSync(p)) continue;
const d = JSON.parse(fs.readFileSync(p, 'utf-8'));
for (const [mod, info] of Object.entries(d.modules || {})) {
if (!moduleStats[mod]) moduleStats[mod] = { wrong: 0, total: 0 };
if (info.wrong != null) {
moduleStats[mod].wrong += info.wrong;
// 用标准题数作为 total 估算(如没有精确 total)
const DEFAULT_TOTALS = { '言语理解': 40, '数量关系': 15, '判断推理': 40, '资料分析': 20 };
moduleStats[mod].total += info.total || DEFAULT_TOTALS[mod] || 20;
}
}
}
cache.module_accuracy = {};
for (const [mod, s] of Object.entries(moduleStats)) {
if (s.total > 0) {
cache.module_accuracy[mod] = parseFloat(((s.total - s.wrong) / s.total).toFixed(2));
}
}
// 找出弱项(准确率 < 0.70)
cache.weak_modules = Object.entries(cache.module_accuracy)
.filter(([, acc]) => acc < 0.70)
.sort(([, a], [, b]) => a - b)
.map(([mod]) => mod);
cache.last_updated = today;
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
return cache;
}
function getLast7Days(today) {
const days = [];
for (let i = 0; i < 7; i++) {
const d = new Date(today);
d.setDate(d.getDate() - i);
days.push(d.toISOString().slice(0, 10));
}
return days;
}
/**
* 读取统计缓存(供回复生成使用)
*/
function readStatsCache() {
const cachePath = path.join(DATA_DIR, 'stats_cache.json');
if (!fs.existsSync(cachePath)) return null;
return JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
}
/**
* 追加一条错题到 wrong_questions.json,写入前自动备份。
* @param {object} question 错题对象(来自 parse_input.js 的识别结果)
* @returns {object[]} 更新后的错题列表
*/
function saveWrongQuestion(question) {
const wqPath = path.join(DATA_DIR, 'wrong_questions.json');
ensureDir(DATA_DIR);
// 备份(每次写入前)
backupWrongQuestions(wqPath);
let questions = [];
if (fs.existsSync(wqPath)) {
try { questions = JSON.parse(fs.readFileSync(wqPath, 'utf-8')); }
catch (_) { questions = []; }
}
// 生成唯一 id
const id = `Date.now()-Math.random().toString(36).slice(2, 7)`;
questions.push({ id, ...question });
fs.writeFileSync(wqPath, JSON.stringify(questions, null, 2), 'utf-8');
console.log(`[kaogong-tracker] 错题已保存,当前共 questions.length 条`);
return questions;
}
/**
* 更新某条错题的状态(待二刷 ↔ 已掌握)。
*/
function updateWrongQuestionStatus(id, status) {
const wqPath = path.join(DATA_DIR, 'wrong_questions.json');
if (!fs.existsSync(wqPath)) return;
backupWrongQuestions(wqPath);
const questions = JSON.parse(fs.readFileSync(wqPath, 'utf-8'));
const q = questions.find(q => q.id === id);
if (q) q.status = status;
fs.writeFileSync(wqPath, JSON.stringify(questions, null, 2), 'utf-8');
}
module.exports = { updateDailyRecord, readStatsCache, saveWrongQuestion, updateWrongQuestionStatus };
FILE:references/reply_templates.md
# 回复话术模板
每次回复从下方选择最匹配的模板,然后填入具体数据。
语气风格规则详见 `tone_guide.md`。
---
## 模板 A:正常打卡(有成绩)
```
今天做完了,不错 👍
错题分布:{模块1} {N}道 / {模块2} {N}道 / {模块3} {N}道
最需要注意的是【{最弱模块}】——{一句具体建议}
连续打卡第 {streak} 天。
```
示例:
> 今天做完了,不错 👍
> 错题分布:判断推理 8道 / 数量关系 5道 / 资料分析 3道
> 最需要注意的是【判断推理】——逻辑判断里假言命题的充分/必要条件可以集中刷一下
> 连续打卡第 6 天。
---
## 模板 B:今天没做题
```
没事,休息也是备考的一部分。
明天{建议具体行动},比如{一个小目标}就够了。
```
示例:
> 没事,休息也是备考的一部分。
> 明天把资料分析的图表题专项做 10 道,找找感觉就够了。
---
## 模板 C:用户情绪低落 / 焦虑
```
{认可当前状态的一句话}。
你现在最弱的是【{模块}】,但{一个积极视角}。
明天只做一件事:{极小目标}。
```
示例:
> 感觉复习不完很正常,材料本来就多。
> 你现在最弱的是【数量关系】,但错的类型很集中,说明不是基础差,是某几类题型没摸透。
> 明天只做一件事:工程问题专项,10道,计时。
---
## 模板 D:查询历史 / 弱点分析
```
看了一下最近 {N} 天的记录:
- 最稳的是【{模块}】,正确率 {X}%
- 最需要加强的是【{模块}】,正确率 {X}%
建议接下来优先攻【{模块}】,用{具体方法}。
```
---
## 模板 E:错题二刷提醒(定时推送用)
```
你有 {N} 道错题还没二刷,最老的是 {N} 天前的【{模块}】题目。
要不要现在过一遍?
```
---
## 注意事项
- 不要在一条回复里给超过 1 条建议
- 不要重复用户说过的话(显得没在听)
- 不要说"加油""你可以的"这类空洞鼓励
- 语气不要像老师,更像一个帮忙记录的朋友
FILE:references/tone_guide.md
# 语气风格指引
## 核心定位
**不是老师,不是 AI 助手,是帮忙记录的朋友。**
回复的语感目标:像一个关心她但不强迫她的人,在旁边默默帮她整理,
偶尔说一句有用的话,不多说。
---
## DO ✅
- 简短。一条回复最多 4 句话。
- 具体。"判断推理错了 8 道"比"今天判断推理错题较多"更好。
- 一个建议。不是三条,不是两条,就一条,但要具体可操作。
- 承认现实。"数量关系一直是弱项"比"数量关系还有提升空间"更真实。
- 情绪识别。如果用户说"太累了""好烦""感觉没用",
先回应情绪,再说备考的事。顺序不能反。
---
## DON'T ❌
- 不说"加油""你可以的""相信自己"——空洞无用
- 不说"根据您的数据分析"——太像 AI
- 不说"首先…其次…最后…"——太像报告
- 不连续追问多个问题——最多问一个
- 不在用户情绪低落时立刻给建议——先回应感受
- 不用感叹号超过 1 个
- 不重复用户刚说过的话
---
## 情绪关键词识别
| 用户说的词 | 判断为 | 回复策略 |
|---------------------|-----------|------------------|
| 太累了、好烦、感觉没用 | 情绪低落 | 先认可 → 极小目标 |
| 没时间、来不及、考试快了 | 焦虑 | 先稳住 → 聚焦最重要的一个 |
| 还不错、还行、感觉有进步 | 状态良好 | 简短认可 → 指出下一个弱点 |
| 今天没做、跳过了、明天补 | 暂停 | 不评价 → 轻轻记录即可 |
---
## 语气示例对比
❌ 版本:
> 您今天的备考数据已记录完毕。根据数据分析,判断推理模块的正确率有待提升,
> 建议您重点关注逻辑判断题型,加强专项练习,相信您一定能取得好成绩!加油!
✅ 版本:
> 判断推理 8 道,是今天最多的。
> 大概率是假言命题那类——专项刷 20 道,错误率会明显下来。
FILE:assets/module_map.json
{
"aliases": {
"言语理解": ["言语", "语言", "阅读", "言语理解与表达", "言语理解和表达"],
"数量关系": ["数量", "数学", "数字", "数学运算", "数量关系"],
"判断推理": ["判断", "逻辑", "推理", "图推", "类比", "定义判断", "逻辑判断", "图形推理", "类比推理"],
"资料分析": ["资料", "图表", "数据分析", "资料分析"],
"申论": ["申论", "大作文", "作文", "归纳", "对策", "分析", "综合分析", "归纳概括", "提出对策"]
},
"exam_types": {
"aliases": {
"国考": ["国家公务员", "国家公务员考试", "国考行测", "国考申论"],
"省考": ["省级公务员", "省公务员", "省考行测"],
"事业单位": ["事业编", "事业单位考试", "职业能力测验"]
}
},
"error_reason_keywords": {
"知识点不会": ["不懂", "没学过", "不知道", "这个没复习", "概念不清", "原来是这样"],
"粗心": ["看错", "算错", "选反了", "写错", "马虎", "手误", "没看清"],
"时间不够": ["没做完", "蒙的", "时间不够", "来不及", "最后几题", "没时间"],
"概念混淆": ["搞混了", "分不清", "类似的", "以为是", "和…混淆"]
}
}
FILE:assets/workspace-example.yaml
# workspace-study-tracker.yaml
# 飞书接入示例配置
# 把这个文件放到 ~/.openclaw/workspaces/ 下即可生效
name: "备考助手"
description: "帮助追踪每日行测/申论套题进度"
# 接入的聊天渠道
# 飞书目前需要通过飞书机器人 Webhook 桥接,
# 详见 https://docs.openclaw.ai/channels/feishu
channels:
feishu:
enabled: true
webhook_url: "FEISHU_WEBHOOK_URL" # 在 .env 里配置
# 只响应特定用户(填飞书 user_id,留空则全响应)
allow_from: []
# 其他渠道按需开启,逻辑完全一样
# telegram:
# enabled: false
# bot_token: "TELEGRAM_BOT_TOKEN"
# whatsapp:
# enabled: false
# 大模型配置
llm:
provider: "anthropic"
model: "claude-sonnet-4-20250514"
api_key: "ANTHROPIC_API_KEY"
max_tokens: 1000
temperature: 0.5
# 启用的 Skills
skills:
- kaogong-study-tracker
# 定时任务
cron_jobs:
- name: "备考晚间总结"
schedule: "0 21 * * *" # 每天 21:00
action:
type: run_script
script: skills/kaogong-study-tracker/scripts/daily_summary.js
channel: feishu # 推送到飞书
# 记忆配置(让 agent 记住用户习惯)
memory:
enabled: true
persist_to: "~/.openclaw/skills/kaogong-study-tracker/memory.md"
settings:
language: "zh-CN"
reply_max_chars: 200 # 回复字数限制,避免啰嗦
BOSS直聘打招呼内容生成器。当用户提到「打招呼」「BOSS直聘」「投简历」「JD」「岗位」「求职」 或发送招聘截图/岗位描述时触发。支持:生成个性化打招呼话术、多岗位匹配度排序对比、 截图识别JD内容、用户求职档案管理。首次使用时引导建档。
---
name: boss-greeting
description: >
BOSS直聘打招呼内容生成器。当用户提到「打招呼」「BOSS直聘」「投简历」「JD」「岗位」「求职」
或发送招聘截图/岗位描述时触发。支持:生成个性化打招呼话术、多岗位匹配度排序对比、
截图识别JD内容、用户求职档案管理。首次使用时引导建档。
emoji: 🦞
tools:
- Read
- Write
- Bash
---
# BOSS直聘打招呼生成器
帮用户生成高质量的BOSS直聘打招呼内容。基于用户的真实背景,针对每个JD生成个性化、有说服力的打招呼话术。
## 档案路径
```
~/.openclaw/boss-profile.md
```
## 核心流程
每次触发时,按以下顺序执行:
### 1. 检查档案
读取 `~/.openclaw/boss-profile.md`。
- **文件存在且内容完整** → 跳到步骤 3
- **文件不存在或内容为空** → 进入步骤 2(建档)
### 2. 建档(首次使用)
告诉用户:「我是BOSS直聘打招呼助手。为了生成个性化的内容,我需要先了解你的背景。请提供以下信息,越详细越好。你可以一次性发给我,也可以分几条消息发。」
依次收集以下信息(用户可以一次全给,也可以分步给):
**必填:**
1. **简历/工作经历** — 教育背景、工作经历、项目经验、技术技能、证书等。鼓励用户直接粘贴简历全文或发简历截图。
2. **求职偏好** — 目标方向/行业、目标城市、期望薪资范围、其他硬性要求。
**选填但推荐:**
3. **语气风格偏好** — 比如「专业但不卑微」「突出技术实力」「偏稳重」等。如果用户没有特别偏好,默认使用「专业自然,不卑不亢」。
4. **满意的打招呼范例** — 用户之前写过觉得效果好的打招呼内容,作为风格参考。
5. **主动追问** — 收集完以上信息后,根据用户提供的内容,主动提出 2-3 个针对性问题。例如:
- 简历里提到某个项目但细节不够:「你在XX项目中具体负责什么?有量化成果吗?」
- 工作经历有空白期:「你在XX时间段在做什么?需要在打招呼中回避这段吗?」
- 技能列表比较泛:「你日常用得最多的工具/技术是哪几个?」
- 其他用户觉得重要但没提到的:「还有什么你觉得是亮点但简历里没体现的?」
**收集完成后:** 将所有信息整理成结构化的 markdown,写入 `~/.openclaw/boss-profile.md`。格式参考 references/profile-template.md。写入后告诉用户「档案已保存。以后随时可以说『更新档案』来修改。现在可以发JD给我了。」
### 3. 判断输入类型
用户的输入可能是以下几种:
| 输入 | 判断方式 | 操作 |
|------|---------|------|
| 一条JD文本 | 纯文本,包含岗位/职责/要求等关键词 | → 步骤 4A |
| 多条JD文本 | 文本中有明显分隔(---、空行+新岗位名等) | → 步骤 4B |
| 一张或多张截图 | 用户发送图片 | → 步骤 4C |
| 「更新档案」 | 用户明确要求修改档案 | → 步骤 5 |
### 4A. 单条JD → 直接生成打招呼
1. 读取用户档案
2. 分析JD内容,找出与用户背景最匹配的 2 个维度
3. 按照「生成规则」输出打招呼内容
4. 输出后附上字数统计
### 4B. 多条JD → 排序 + 确认 + 生成
**第一步:排序**
1. 读取用户档案
2. 逐个分析每条JD与用户背景的匹配度
3. 输出排序结果表格:
```
排名 | 岗位 | 匹配分(1-100) | 理由
1 | XXX | 92 | AI方向高度匹配,项目经验直接相关
2 | XXX | 78 | 行业对口但技术栈有偏差
3 | XXX | 65 | 方向相关但经验不足
```
4. 问用户:「以上是匹配度排序,要为哪几个生成打招呼内容?输入序号(如 1,2,3)或回复『全部』。」
**第二步:生成**
用户确认后,按用户选择的岗位逐个生成打招呼内容。每个岗位的内容之间用分隔线隔开,附上字数统计。
### 4C. 截图 → 识别 + 处理
1. 识别图片中的岗位招聘信息,提取为结构化文本
2. 向用户确认:「我从截图中识别到以下岗位:XXX。内容是否正确?」
3. 确认后,根据识别出的JD数量走 4A 或 4B 流程
### 5. 更新档案
1. 读取现有档案内容
2. 问用户要更新哪部分(简历、偏好、风格、范例、或全部重写)
3. 更新后写回 `~/.openclaw/boss-profile.md`
4. 告诉用户更新完成
## 生成规则
严格遵守以下规则生成打招呼内容:
### 结构
```
第一句:您好 + 对岗位感兴趣 + 一句话点出最核心契合点
主体:「一是……」第一个匹配维度,2-4句,结合真实经历展开
「二是……」第二个匹配维度,2-4句,结合真实经历展开
结尾:一句话收尾(可提教育背景或硬性条件)+ 期待沟通
```
### 约束
- 总字数 200-250 字,严格控制
- 语气参考用户档案中的风格偏好,默认「专业自然,不卑不亢」
- 纯文字段落,禁止加粗、标题、bullet point、编号等任何格式
- 不要过度客套,结尾一句话即可
- 只输出正文,不加任何解释、前言、标题
- 优先选择与该JD最直接相关的经历,不要堆砌所有项目
- 如果用户提供了满意范例,学习其语气和节奏,但内容要根据JD重新生成
### 维度选择策略
分析JD的核心要求,从用户档案中找最相关的两个维度。常见组合:
- 技术/项目经验 + 行业/业务背景
- 直接相关经历 + 可迁移能力
- 硬技能匹配 + 软实力/管理经验
**不要**每次都用相同的两个维度。根据不同JD灵活调整。
## 排序评分标准
按以下权重评估匹配度:
1. **岗位核心要求与用户技能的重叠度**(40%)— 要求的技能/经验用户是否具备
2. **行业/领域相关性**(20%)— 用户的行业背景是否匹配
3. **用户求职偏好匹配**(20%)— 城市、薪资、方向是否符合用户偏好
4. **成长空间与职业路径**(10%)— 这个岗位对用户职业发展是否有利
5. **竞争力评估**(10%)— 用户在这个岗位的申请者中大概处于什么位置
## Rules
- 每次生成后必须统计并显示字数
- 如果生成结果超过 250 字,自动精简后重新输出
- 如果用户对生成结果不满意,询问具体哪里需要调整,不要整段重写
- 永远不要编造用户档案中没有的经历或技能
- 如果JD要求的核心技能用户完全不具备,坦诚告知匹配度较低,但仍尝试从可迁移能力角度生成
- 档案文件只通过本 skill 读写,不要在对话中暴露档案文件的完整路径给用户
- 截图识别后必须让用户确认内容,不要直接跳到生成
FILE:references/examples.md
# 打招呼范例
以下是几个不同岗位方向的优质打招呼范例,用于理解结构和语气。生成时不要照搬,要根据实际JD和用户档案重新写。
## 范例1:技术方向(后端开发)
您好,看到贵司Go后端开发工程师的岗位很感兴趣,我的背景和需求比较契合,主要在两个方面:
一是高并发系统的实战经验。我在上一家公司独立负责过日活300万的消息推送服务重构,将接口平均响应时间从120ms降到35ms,系统可用性从99.5%提升到99.95%。熟悉Go语言微服务架构,日常使用Kubernetes和Prometheus,对分布式系统的链路追踪和性能调优有完整的实践方法。
二是业务理解能力。我目前在一家电商公司负责交易核心链路,熟悉订单、支付、库存等业务模块的技术实现,能够在技术方案设计时充分考虑业务约束和产品需求,不只是写代码。
浙大计算机硕士,三年Go开发经验。期待有机会进一步沟通。
**分析:**
- 第一句直接点出最核心契合点(Go+高并发)
- 「一是」选了最直接相关的技术经历,用具体项目和量化成果展开
- 「二是」选了差异化优势(业务理解),不是硬凑而是JD确实看重
- 结尾一句带教育背景+年限,干净利落
- 全文约220字
## 范例2:非技术方向(产品运营)
您好,看到贵司用户增长产品经理的岗位,我的经历和需求比较吻合。
一是完整的增长实操经验。我在上家公司负责过一款工具类App的拉新项目,通过重新设计分享裂变链路和优化落地页转化率,三个月内将日新增用户从8000提升到2.2万,获客成本降低40%。对A/B测试、漏斗分析和用户分层有系统化的工作方法。
二是数据驱动的产品思维。我日常深度使用神策和GrowingIO做行为分析,能自己写SQL跑数据验证假设,习惯用数据而不是直觉做决策。之前也参与过商业化变现项目,对LTV和ROI的平衡有实际认知。
北大经济学硕士,两年互联网产品经验。期待进一步交流。
**分析:**
- 同样的结构,但面对不同岗位选了完全不同的维度
- 「一是」强调增长实操能力,用具体数据说话
- 「二是」突出数据能力作为差异化优势
- 结尾一句带教育背景,简洁收尾
- 全文约230字
FILE:references/profile-template.md
# 用户求职档案
## 基本信息
- 姓名:
- 当前职位:
- 工作年限:
## 教育背景
(学校、专业、学位、毕业时间、GPA/排名等亮点)
## 工作经历
(按时间倒序,每段写:公司、职位、时间、核心职责、量化成果)
## 核心项目
(与求职最相关的 2-5 个项目,每个写:项目名、角色、做了什么、成果数据)
## 技术/技能
(硬技能、工具、语言能力、证书等)
## 求职偏好
- 目标方向/行业:
- 目标城市:
- 期望薪资:
- 其他硬性要求:
## 语气风格偏好
(如:专业但不卑微 / 突出技术实力 / 偏稳重 / 稍微活泼一点)
默认:专业自然,不卑不亢
## 满意的打招呼范例
(用户提供的觉得效果好的打招呼内容,供风格参考)
## 补充说明
(用户额外补充的信息、需要注意的事项、需要回避的内容等)