tiantian-grader

作业批改专家,支持多份作业连续批改,自动生成班级成绩统计表

Safety Notice

This listing is from the official public ClawHub registry. Review SKILL.md and referenced scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "tiantian-grader" with this command: npx skills add pengdagang1975/tiantian-grader

match

  • 初始化
  • 开始新作业
  • 类型A
  • 类型B
  • 确认无误
  • 逐份批改
  • 批量导入
  • 生成报表
  • 查询历史
  • 查看统计
  • 全部改为严格模式
  • 全部改为宽松模式

📚 天天老师助手 - 作业批改专家(完整版)

核心依赖

const { exec } = require('child_process');
const fs = require('fs');
const path = require('path');
const util = require('util');
const crypto = require('crypto');
const execAsync = util.promisify(exec);

// ==================== 工作区路径常量 ====================
const WORKSPACE = '/root/.openclaw/workspace/teacher_tiantian';
const PATHS = {
  homework: path.join(WORKSPACE, 'homework'),
  original: path.join(WORKSPACE, 'homework/original'),
  processed: path.join(WORKSPACE, 'homework/processed'),
  corrected: path.join(WORKSPACE, 'corrected'),
  byStudent: path.join(WORKSPACE, 'corrected/by_student'),
  byDate: path.join(WORKSPACE, 'corrected/by_date'),
  questions: path.join(WORKSPACE, 'questions'),
  questionBank: path.join(WORKSPACE, 'questions/bank'),
  questionHistory: path.join(WORKSPACE, 'questions/history'),
  statistics: path.join(WORKSPACE, 'statistics'),
  statsClass: path.join(WORKSPACE, 'statistics/class'),
  statsStudent: path.join(WORKSPACE, 'statistics/student'),
  statsQuestion: path.join(WORKSPACE, 'statistics/question'),
  exports: path.join(WORKSPACE, 'exports'),
  exportCsv: path.join(WORKSPACE, 'exports/csv'),
  rosters: path.join(WORKSPACE, 'rosters'),
  currentRoster: path.join(WORKSPACE, 'current_roster/active.csv'),
  tools: path.join(WORKSPACE, 'tools'),
  uploads: path.join(WORKSPACE, 'uploads'),
  temp: path.join(WORKSPACE, 'temp'),
  ocrCache: path.join(WORKSPACE, 'ocr_cache'),
  memory: path.join(WORKSPACE, 'memory'),
  memoryFile: path.join(WORKSPACE, 'memory/MEMORY.md')
};

// ==================== 确保目录存在 ====================
Object.values(PATHS).forEach(dir => {
  if (typeof dir === 'string' && !dir.includes('.')) {
    try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {}
  }
});

// ==================== 会话状态存储 ====================
const sessions = new Map();

// ==================== 花名册管理 ====================
function readRoster() {
  try {
    if (!fs.existsSync(PATHS.currentRoster)) return [];
    const content = fs.readFileSync(PATHS.currentRoster, 'utf8');
    const lines = content.split('\n').filter(l => l.trim());
    if (lines.length < 2) return [];

    const headers = lines[0].split(',').map(h => h.trim());
    const students = [];
    for (let i = 1; i < lines.length; i++) {
      const values = lines[i].split(',').map(v => v.trim());
      if (values.length === headers.length) {
        const student = {};
        headers.forEach((h, idx) => student[h] = values[idx]);
        students.push(student);
      }
    }
    return students;
  } catch (error) {
    console.error('读取花名册失败:', error);
    return [];
  }
}

// ==================== 记忆文件读取 ====================
function readMemory() {
  try {
    if (!fs.existsSync(PATHS.memoryFile)) {
      fs.writeFileSync(PATHS.memoryFile, '# 天天老师助手记忆文件\n\n');
      return [];
    }
    const content = fs.readFileSync(PATHS.memoryFile, 'utf8');
    const lines = content.split('\n').filter(l => l.startsWith('- '));
    return lines.map(l => l.substring(2));
  } catch (error) {
    return [];
  }
}

// ==================== 写入记忆 ====================
function writeMemory(entry) {
  try {
    const date = new Date().toISOString().split('T')[0];
    const content = `\n## ${date}\n- ${entry}\n`;
    fs.appendFileSync(PATHS.memoryFile, content);
    return true;
  } catch (error) {
    return false;
  }
}

// ==================== 文件上传处理 ====================
function saveUploadedFile(file) {
  try {
    console.log("📸 保存文件:", file.name || file.path);

    let originalPath, fileName;

    if (typeof file === 'string') {
      originalPath = file;
      fileName = path.basename(file);
    } else if (file.path) {
      originalPath = file.path;
      fileName = file.name || path.basename(originalPath);
    } else if (file.url) {
      originalPath = file.url;
      fileName = file.name || `url_${Date.now()}.jpg`;
    } else {
      console.error("未知文件格式:", Object.keys(file));
      return null;
    }

    if (!fs.existsSync(originalPath)) {
      console.error(`文件不存在: ${originalPath}`);
      return null;
    }

    const ext = path.extname(fileName) || '.jpg';
    const baseName = path.basename(fileName, ext);
    const newName = `${baseName}_${Date.now()}${ext}`;
    const destPath = path.join(PATHS.original, newName);

    fs.copyFileSync(originalPath, destPath);
    console.log(`✅ 已保存: ${destPath}`);

    return { originalName: fileName, savedPath: destPath };
  } catch (error) {
    console.error('文件保存失败:', error);
    return null;
  }
}

function saveUploadedFiles(files) {
  return files.map(f => saveUploadedFile(f)).filter(f => f !== null);
}

// ==================== 题库管理 ====================
function saveQuestionsToBank(questions, subject = 'english') {
  try {
    const date = new Date().toISOString().split('T')[0];
    const filePath = path.join(PATHS.questionBank, `${subject}_${date}.json`);

    let bank = [];
    if (fs.existsSync(filePath)) {
      bank = JSON.parse(fs.readFileSync(filePath, 'utf8'));
    }

    const newQuestions = questions.filter(q =>
      !bank.some(b => b.content === q.content)
    );

    bank.push(...newQuestions);
    fs.writeFileSync(filePath, JSON.stringify(bank, null, 2), 'utf8');

    return newQuestions.length;
  } catch (error) {
    console.error('保存题目失败:', error);
    return 0;
  }
}

// ==================== 批改结果存储 ====================
function saveGradingResult(studentName, homeworkTitle, questions, results, totalScore, maxScore) {
  try {
    const date = new Date().toISOString().split('T')[0];
    const timeStr = new Date().toTimeString().split(' ')[0].replace(/:/g, '-');

    const studentFile = path.join(PATHS.byStudent, `${studentName}_${date}.json`);

    const questionData = questions.map((q, idx) => ({
      id: q.id,
      content: q.content,
      answer: q.answer,
      studentAnswer: results[idx]?.studentAnswer || '',
      score: results[idx]?.score || 0,
      maxScore: q.score,
      isCorrect: results[idx]?.isCorrect || false,
      mode: q.gradingMode || 'strict'
    }));

    const studentData = {
      student: studentName,
      date: date,
      time: timeStr,
      homework: homeworkTitle,
      questions: questionData,
      totalScore,
      maxScore,
      accuracy: (totalScore / maxScore * 100).toFixed(1) + '%'
    };

    fs.writeFileSync(studentFile, JSON.stringify(studentData, null, 2), 'utf8');

    const dateFile = path.join(PATHS.byDate, `${date}.json`);
    let daily = [];
    if (fs.existsSync(dateFile)) {
      daily = JSON.parse(fs.readFileSync(dateFile, 'utf8'));
    }
    daily.push(studentData);
    fs.writeFileSync(dateFile, JSON.stringify(daily, null, 2), 'utf8');

    return studentFile;
  } catch (error) {
    console.error('保存批改结果失败:', error);
    return null;
  }
}

// ==================== 生成报表 ====================
async function generateSummary(results, homeworkTitle) {
  try {
    const scores = results.map(r => r.total);
    const maxScore = results[0]?.maxScore || 100;
    const avgScore = (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1);

    let summary = `📊 批改小结:${homeworkTitle}\n\n`;
    summary += `参与人数:${results.length}人\n`;
    summary += `平均分:${avgScore}/${maxScore}\n`;
    summary += `最高分:${Math.max(...scores)}\n`;
    summary += `最低分:${Math.min(...scores)}\n\n`;

    const dist = {
      '90-100': scores.filter(s => s >= maxScore * 0.9).length,
      '80-89': scores.filter(s => s >= maxScore * 0.8 && s < maxScore * 0.9).length,
      '70-79': scores.filter(s => s >= maxScore * 0.7 && s < maxScore * 0.8).length,
      '60-69': scores.filter(s => s >= maxScore * 0.6 && s < maxScore * 0.7).length,
      '60以下': scores.filter(s => s < maxScore * 0.6).length
    };

    summary += `📈 分数分布:\n`;
    for (const [range, count] of Object.entries(dist)) {
      if (count > 0) summary += `  ${range}分:${count}人\n`;
    }

    return summary;
  } catch (error) {
    return '生成小结失败';
  }
}

// ==================== 导出CSV ====================
function exportToCSV(results, filename) {
  try {
    const csvPath = path.join(PATHS.exportCsv, `${filename}.csv`);

    let headers = ['姓名', '总分', '满分', '正确率'];
    if (results[0]?.details) {
      results[0].details.forEach((d, i) => {
        headers.push(`第${d.id}题`);
      });
    }

    let csvContent = headers.join(',') + '\n';

    results.forEach(r => {
      const accuracy = (r.total / r.maxScore * 100).toFixed(1);
      let row = [r.name, r.total, r.maxScore, `${accuracy}%`];

      if (r.details) {
        r.details.forEach(d => row.push(d.score));
      }

      csvContent += row.join(',') + '\n';
    });

    fs.writeFileSync(csvPath, csvContent, 'utf8');
    return csvPath;
  } catch (error) {
    return null;
  }
}

// ==================== UI 渲染函数 ====================
function getConfidenceIcon(confidence) {
  if (confidence === 'high') return '✅';
  if (confidence === 'medium') return '⚠️';
  return '❓';
}

function getModeIcon(mode) {
  return mode === 'strict' ? '🔍' : '🎯';
}

function renderConfirmationUI(questions) {
  let output = "✅ 作业识别完成!\n\n";
  output += "=".repeat(60) + "\n\n";

  questions.forEach((q, idx) => {
    const confIcon = getConfidenceIcon(q.confidence || 'medium');
    const modeIcon = getModeIcon(q.gradingMode || 'strict');
    const modeText = q.gradingMode === 'strict' ? '严格' : '宽松';

    if (q.type === 'compound' && q.sub_questions) {
      output += `【第${idx+1}题】📚 ${q.content} (含${q.sub_questions.length}子题)\n`;
      output += `├─ 模式:[${modeIcon} ${modeText} 切换]\n`;

      q.sub_questions.forEach((sub, subIdx) => {
        const prefix = subIdx === q.sub_questions.length - 1 ? '└─' : '├─';
        output += `${prefix} 【${sub.id}】\n`;
        output += `   题目:${sub.content} [修改]\n`;
        output += `   答案:${sub.answer || '待确认'} [修改] [${sub.score || 5}分]\n`;
      });
    } else {
      output += `【第${idx+1}题】${confIcon} ${q.content}\n`;
      output += `  答案:${q.answer || '待确认'} [修改] [${q.score || 10}分]\n`;
      output += `  模式:[${modeIcon} ${modeText} 切换]\n`;
    }
    output += "\n";
  });

  output += "=".repeat(60) + "\n";
  output += "请逐题确认,或发送:\n";
  output += "- 修改第X题答案改为...\n";
  output += "- 第X题改为严格模式/宽松模式\n";
  output += "- 全部改为严格模式\n";
  output += "- 全部改为宽松模式\n";
  output += "- 【确认无误,开始批改】\n";

  return output;
}

// ==================== 主处理函数 ====================
async function handler(input, context) {
  console.log("🔥🔥🔥 tiantian-grader 技能被调用!🔥🔥🔥");
  console.log("=".repeat(60));
  console.log("🎯 作业批改技能触发", new Date().toISOString());
  console.log("📨 收到消息:", input?.message?.content);
  console.log("📎 文件数量:", input?.message?.files?.length || 0);
  
  const sessionId = context?.session?.id || 'default';
  if (!sessions.has(sessionId)) {
    sessions.set(sessionId, {
      stage: 'idle',
      homework: {
        title: '',
        questions: [],
        totalScore: 0,
        pageFormat: null
      },
      students: {
        roster: [],
        current: null,
        completed: []
      }
    });
    console.log("🆕 创建新会话:", sessionId);
  }

  const session = sessions.get(sessionId);
  console.log("🔄 当前会话阶段:", session.stage);
  console.log("=".repeat(60));

  const message = input?.message?.content || "";
  const files = input?.message?.files || [];

  try {
    // ========== 初始化 ==========
    if (message.includes("初始化")) {
      console.log("📋 执行初始化...");
      const roster = readRoster();
      session.students.roster = roster;
      const memory = readMemory();
      const memoryHint = memory.length > 0 ? `\n\n📝 历史记忆:${memory.slice(-3).join('、')}` : '';
      return roster.length > 0
        ? `✅ 已加载花名册,共${roster.length}名学生${memoryHint}\n\n发送【开始新作业】启动批改`
        : "⚠️ 未找到花名册,请上传CSV文件或使用管理脚本创建";
    }

    // ========== 开始新作业 ==========
    if (message.includes("开始新作业")) {
      console.log("📝 开始新作业...");
      if (session.students.roster.length === 0) {
        return "⚠️ 请先发送【初始化】加载花名册";
      }
      session.stage = 'format_select';
      return "📋 请选择本次作业的格式:\n\n📄 类型A:分开页(学生信息在单独封面页)\n📑 类型B:混合页(学生信息和作业在同一页)\n\n请发送 \"类型A\" 或 \"类型B\" 来选择作业格式。";
    }

    // ========== 格式选择 ==========
    if (session.stage === 'format_select') {
      console.log("📐 选择格式:", message);
      if (message.includes("类型A")) {
        session.homework.pageFormat = 'separate';
        session.stage = 'upload_answers';
        return "✅ 已设置为分开页\n\n请上传作业题目和标准答案图片";
      }
      if (message.includes("类型B")) {
        session.homework.pageFormat = 'mixed';
        session.stage = 'upload_answers';
        return "✅ 已设置为混合页\n\n请上传作业题目和标准答案图片";
      }
      return "请发送“类型A”或“类型B”";
    }

    // ========== 上传答案图片(模拟OCR识别)==========
    if (session.stage === 'upload_answers' && files.length > 0) {
      console.log("📸 处理上传的图片...");
      const saved = saveUploadedFiles(files);
      if (saved.length === 0) return "❌ 文件保存失败";

      // 模拟OCR识别结果
      const questions = [
        { id: 1, content: "adj.出乎意料的;始料不及的", answer: "unexpected", score: 5, confidence: "high", type: "simple" },
        { id: 2, content: "n.背包;旅行包", answer: "backpack", score: 5, confidence: "medium", type: "simple" },
        { id: 3, content: "v.睡过头;睡得太久", answer: "oversleep", score: 10, confidence: "low", type: "simple" },
        { 
          id: 4, 
          content: "阅读理解", 
          type: "compound", 
          gradingMode: "loose",
          sub_questions: [
            { id: "4.1", content: "根据第一段,作者心情是?", answer: "excited", score: 5 },
            { id: "4.2", content: "第二段中“it”指代什么?", answer: "the book", score: 5 },
            { id: "4.3", content: "作者最后建议什么?", answer: "read more", score: 5 }
          ]
        },
        { id: 5, content: "n.街区", answer: "district", score: 5, confidence: "high", type: "simple" }
      ];

      session.pendingQuestions = questions;
      session.stage = 'confirm_questions';

      console.log(`✅ 提取到 ${questions.length} 个题目,进入确认阶段`);
      return renderConfirmationUI(questions);
    }

    // ========== 确认/修改题目 ==========
    if (session.stage === 'confirm_questions') {
      console.log("✏️ 确认阶段收到:", message);

      // 修改答案
      const answerMatch = message.match(/第(\d+)题答案改为(.+)/);
      if (answerMatch) {
        const idx = parseInt(answerMatch[1]) - 1;
        const newAnswer = answerMatch[2].trim();
        if (session.pendingQuestions[idx]) {
          session.pendingQuestions[idx].answer = newAnswer;
          return renderConfirmationUI(session.pendingQuestions);
        }
      }

      // 切换模式
      const strictMatch = message.match(/第(\d+)题改为严格模式/);
      const looseMatch = message.match(/第(\d+)题改为宽松模式/);

      if (strictMatch) {
        const idx = parseInt(strictMatch[1]) - 1;
        if (session.pendingQuestions[idx]) {
          session.pendingQuestions[idx].gradingMode = 'strict';
          return renderConfirmationUI(session.pendingQuestions);
        }
      }

      if (looseMatch) {
        const idx = parseInt(looseMatch[1]) - 1;
        if (session.pendingQuestions[idx]) {
          session.pendingQuestions[idx].gradingMode = 'loose';
          return renderConfirmationUI(session.pendingQuestions);
        }
      }

      // 批量切换
      if (message.includes("全部改为严格模式")) {
        session.pendingQuestions.forEach(q => q.gradingMode = 'strict');
        return renderConfirmationUI(session.pendingQuestions);
      }

      if (message.includes("全部改为宽松模式")) {
        session.pendingQuestions.forEach(q => q.gradingMode = 'loose');
        return renderConfirmationUI(session.pendingQuestions);
      }

      // 确认无误
      if (message.includes("确认无误")) {
        session.homework.questions = session.pendingQuestions;
        session.homework.totalScore = session.pendingQuestions.reduce((sum, q) => {
          if (q.type === 'compound' && q.sub_questions) {
            return sum + q.sub_questions.reduce((s, sub) => s + (sub.score || 0), 0);
          }
          return sum + (q.score || 0);
        }, 0);

        const savedCount = saveQuestionsToBank(session.homework.questions);
        writeMemory(`新增题目 ${session.homework.questions.length} 道`);
        
        session.stage = 'mode_select';
        delete session.pendingQuestions;

        return `📚 已保存 ${savedCount} 道新题到题库\n\n` +
               `📋 请选择批改模式:\n\n` +
               `📄 逐份批改:逐页拍照,实时反馈\n` +
               `📚 批量导入:一次性上传所有作业\n\n` +
               `请发送“逐份批改”或“批量导入”`;
      }
    }

    // ========== 选择批改模式 ==========
    if (session.stage === 'mode_select') {
      if (message.includes("逐份批改")) {
        session.gradingMode = 'sequential';
        session.stage = 'grading';
        return "✅ 逐份批改模式\n\n请拍摄第一份作业的封面页或第一页";
      }
      if (message.includes("批量导入")) {
        session.gradingMode = 'batch';
        session.stage = 'batch_upload';
        return "✅ 批量导入模式\n\n请上传所有学生作业图片(按文件名排序)";
      }
    }

    // ========== 批量上传 ==========
    if (session.stage === 'batch_upload' && files.length > 0) {
      const saved = saveUploadedFiles(files);
      session.uploadedFiles = saved;
      return `📁 已收到${saved.length}张图片\n\n请发送【开始批量批改】`;
    }

    // ========== 开始批量批改(模拟)==========
    if (session.stage === 'batch_processing' || message.includes("开始批量批改")) {
      if (!session.uploadedFiles || session.uploadedFiles.length === 0) {
        return "❌ 没有待批改的文件";
      }

      const results = [];
      for (let i = 0; i < Math.min(session.uploadedFiles.length, session.students.roster.length); i++) {
        const studentName = session.students.roster[i]?.姓名 || `学生${i+1}`;
        
        // 模拟批改结果
        let totalScore = Math.floor(Math.random() * 20) + 10; // 10-30分随机
        const details = session.homework.questions.map(q => ({
          id: q.id,
          studentAnswer: "模拟答案",
          isCorrect: Math.random() > 0.3,
          score: Math.random() > 0.3 ? (q.score || 5) : 0
        }));

        results.push({
          name: studentName,
          total: totalScore,
          maxScore: session.homework.totalScore,
          details: details
        });

        saveGradingResult(
          studentName,
          session.homework.title || '未命名作业',
          session.homework.questions,
          details,
          totalScore,
          session.homework.totalScore
        );
        
        session.students.completed.push({ name: studentName, total: totalScore });
      }

      const csvPath = exportToCSV(results, `batch_${Date.now()}`);
      const summary = await generateSummary(results, session.homework.title || '未命名作业');

      writeMemory(`批量批改 ${results.length} 人,平均分 ${summary.split('\n')[2].split(':')[1]}`);

      let response = `✅ 批量批改完成\n\n`;
      results.forEach(r => {
        response += `${r.name}: ${r.total}/${r.maxScore}分\n`;
      });
      response += `\n${summary}`;
      if (csvPath) response += `\n\n📎 CSV导出: ${csvPath}`;

      return response;
    }

    // ========== 生成报表 ==========
    if (message.includes("生成报表")) {
      if (session.students.completed.length === 0) return "暂无已批改数据";

      const results = session.students.completed.map(s => ({
        name: s.name,
        total: s.total,
        maxScore: session.homework.totalScore,
        details: []
      }));

      const csvPath = exportToCSV(results, `report_${Date.now()}`);
      const summary = await generateSummary(results, session.homework.title || '未命名作业');

      return summary + (csvPath ? `\n\n📎 CSV导出: ${csvPath}` : '');
    }

    // ========== 查询历史 ==========
    if (message.includes("查询历史")) {
      const memory = readMemory();
      if (memory.length === 0) return "暂无历史记录";
      return "📝 历史记忆:\n" + memory.slice(-10).map(m => `- ${m}`).join('\n');
    }

    // ========== 查看统计 ==========
    if (message.includes("查看统计")) {
      if (session.students.completed.length === 0) return "暂无统计数据";
      const avg = session.students.completed.reduce((sum, s) => sum + s.total, 0) / session.students.completed.length;
      return `📊 当前统计\n\n已批改:${session.students.completed.length}人\n平均分:${avg.toFixed(1)}/${session.homework.totalScore}\n最高分:${Math.max(...session.students.completed.map(s => s.total))}\n最低分:${Math.min(...session.students.completed.map(s => s.total))}`;
    }

    // ========== 默认响应 ==========
    return "老师好!发送【初始化】加载花名册,或【开始新作业】启动批改";

  } catch (error) {
    console.error('❌ 处理出错:', error);
    return `❌ 处理出错:${error.message}`;
  }
}

module.exports = { handler };

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

三色人格陪伴

恋人、损友、死敌三种陪伴模式。记忆完全隔离不串档,一秒切换情绪状态,承包你所有治愈、解压与情绪拉扯需求。

Registry SourceRecently Updated
General

Zero Api Key Web Search

OpenClaw skill for source-backed web search, page reading, and evidence-aware claim checking. No API keys required by default; optional providers can be enab...

Registry SourceRecently Updated
General

Novel Writer V3.2 - 小说写作引擎

专业小说写作引擎V3.2,支持短篇(3章)到超长篇(500万字)。内置AI味量化检测、四层质检、伏笔管理、角色状态追踪、断点续传。 自动根据字数裁剪流程:短篇模式(<10章)/ 中篇模式(10-50章)/ 长篇模式(50章+)。 触发场景:写小说、小说大纲、小说创作、网文写作、长篇小说、百万字小说、章节规划、 角...

Registry SourceRecently Updated
General

Hk Stock Morning Report

Generate HK stock market morning report (股市晨報) for bank trading desks. Triggers: "生成晨报","股市晨报","今日股市","港股晨報" 推送:微信個人 + 飛書群 | 數據:騰訊財經+stcn.com+格隆匯+實時搜索

Registry SourceRecently Updated