feat: 添加第12课课评、班级总结及学生画像更新
- K4周日1900班第12课《花朵随心画》课评(梁境城、钟嘉逸、王睿意补课) - AICODE03/CSP03各班级第12课课评及班级总结 - 更新多班级学生画像 - 课评生成技能优化
This commit is contained in:
486
scripts/check-api-vs-local.js
Normal file
486
scripts/check-api-vs-local.js
Normal file
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* API出勤记录与本地课评交叉核对脚本
|
||||
* 严格遵循《课评写入和查找规则V1.0》的两级查找逻辑
|
||||
*
|
||||
* 用法: node check-api-vs-local.js <开始日期> <结束日期>
|
||||
* 示例: node check-api-vs-local.js 2026-05-15 2026-05-17
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL;
|
||||
const AUTHORIZATION = process.env.AUTHORIZATION;
|
||||
const MEMORY_DIR = path.join(__dirname, '..', '.claude', 'memory', 'class');
|
||||
|
||||
// ============ 课程编号别名映射 ============
|
||||
|
||||
// API课程编号 → 本地目录名可能的大小写变体
|
||||
const DIR_CODE_ALIASES = {
|
||||
'KITTEN04': ['Kitten04', 'KITTEN04', 'kitten04'],
|
||||
'KITTEN': ['Kitten', 'KITTEN', 'kitten'],
|
||||
'AICODE03': ['AICODE03'],
|
||||
'AICODE01': ['AICODE01'],
|
||||
'CSP03': ['CSP03'],
|
||||
'CSP05': ['CSP05'],
|
||||
};
|
||||
|
||||
// API课程编号 → 文件名中可能的别名
|
||||
const FILE_CODE_ALIASES = {
|
||||
'KITTEN04': ['KITTEN04', 'Kitten04', 'kitten04', 'K4', 'k4'],
|
||||
'KITTEN': ['KITTEN', 'Kitten', 'kitten', 'K4', 'k4'],
|
||||
'AICODE03': ['AICODE03'],
|
||||
'AICODE01': ['AICODE01'],
|
||||
'CSP03': ['CSP03'],
|
||||
'CSP05': ['CSP05'],
|
||||
};
|
||||
|
||||
// 非常规班目录关键词
|
||||
const IRREGULAR_KEYWORDS = ['集训', '体验', '临时', '测试'];
|
||||
|
||||
// ============ 辅助函数 ============
|
||||
|
||||
function extractCourseCode(className) {
|
||||
const match = className.match(/^([A-Z]+\d+)/);
|
||||
return match ? match[1] : className;
|
||||
}
|
||||
|
||||
function getWeekday(dateStr) {
|
||||
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return weekdays[d.getDay()];
|
||||
}
|
||||
|
||||
function extractTimeCode(timePeriod) {
|
||||
if (!timePeriod) return '';
|
||||
const match = timePeriod.match(/(\d{2}):(\d{2})/);
|
||||
return match ? match[1] + match[2] : '';
|
||||
}
|
||||
|
||||
function getFileAliases(courseCode) {
|
||||
return FILE_CODE_ALIASES[courseCode] || [courseCode];
|
||||
}
|
||||
|
||||
function getDirAliases(courseCode) {
|
||||
return DIR_CODE_ALIASES[courseCode] || [courseCode];
|
||||
}
|
||||
|
||||
// 列出所有本地班级目录
|
||||
function listAllClassDirs() {
|
||||
if (!fs.existsSync(MEMORY_DIR)) return [];
|
||||
return fs.readdirSync(MEMORY_DIR, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name);
|
||||
}
|
||||
|
||||
// 判断是否非常规班目录
|
||||
function isIrregularDir(dirName) {
|
||||
return IRREGULAR_KEYWORDS.some(k => dirName.includes(k));
|
||||
}
|
||||
|
||||
// ============ 两级查找逻辑 ============
|
||||
|
||||
/**
|
||||
* 第1级:精确匹配(课程编号 + 老师 + 星期 + 时间)
|
||||
*/
|
||||
function findLevel1Dir(courseCode, weekday, timeCode) {
|
||||
const dirs = listAllClassDirs();
|
||||
const aliases = getDirAliases(courseCode);
|
||||
|
||||
for (const code of aliases) {
|
||||
const pattern = new RegExp(`${code}.*橙子.*${weekday}.*${timeCode}`, 'i');
|
||||
const match = dirs.find(d => pattern.test(d));
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
// 模糊回退
|
||||
for (const code of aliases) {
|
||||
const match = dirs.find(d =>
|
||||
d.toLowerCase().includes(code.toLowerCase()) &&
|
||||
d.includes(weekday) &&
|
||||
(timeCode === '' || d.includes(timeCode))
|
||||
);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 第2级:放宽时间(课程编号 + 老师),排除非常规班和第1级已匹配的目录
|
||||
*/
|
||||
function findLevel2Dirs(courseCode, excludeDir) {
|
||||
const dirs = listAllClassDirs();
|
||||
const aliases = getDirAliases(courseCode);
|
||||
|
||||
return dirs.filter(dir => {
|
||||
if (dir === excludeDir) return false;
|
||||
if (isIrregularDir(dir)) return false;
|
||||
if (!dir.includes('橙子')) return false;
|
||||
return aliases.some(code => dir.toLowerCase().includes(code.toLowerCase()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定目录下检查学生课评文件
|
||||
* 返回: { hasFeedback, files, fileTypes, note }
|
||||
*/
|
||||
function checkStudentFeedback(baseDir, studentName, dateNum, aliases) {
|
||||
const feedbackDir = path.join(baseDir, studentName, 'feedback');
|
||||
if (!fs.existsSync(feedbackDir)) {
|
||||
return { hasFeedback: false, files: [], fileTypes: [], note: '无feedback目录' };
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(feedbackDir)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
.filter(f => {
|
||||
const dateMatch = f.startsWith(dateNum);
|
||||
const codeMatch = aliases.some(a => f.includes(a));
|
||||
return dateMatch && codeMatch;
|
||||
});
|
||||
|
||||
const fileTypes = files.map(f => {
|
||||
if (f.includes('(请假)')) return '请假';
|
||||
if (f.includes('(补课')) return '补课';
|
||||
if (f.includes('(未到')) return '未到';
|
||||
return '正常课评';
|
||||
});
|
||||
|
||||
return {
|
||||
hasFeedback: files.length > 0,
|
||||
files,
|
||||
fileTypes,
|
||||
note: files.length > 0 ? fileTypes.join(', ') : '无匹配文件'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查学生目录是否存在(用于判断是"新增"还是"有目录但无文件")
|
||||
*/
|
||||
function hasStudentDir(baseDir, studentName) {
|
||||
return fs.existsSync(path.join(baseDir, studentName));
|
||||
}
|
||||
|
||||
// ============ API查询 ============
|
||||
|
||||
async function queryDailySchedule(date) {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE_URL}/reports/teaching-schedule`, {
|
||||
headers: { 'Authorization': AUTHORIZATION },
|
||||
params: {
|
||||
teacher_name: '橙子(程城)',
|
||||
teaching_date: date
|
||||
},
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
if (response.data.code === 0 && response.data.data.items.length > 0) {
|
||||
return response.data.data.items;
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`查询 ${date} 失败:`, error.response?.data?.message || error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 主函数 ============
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 2) {
|
||||
console.log('用法: node check-api-vs-local.js <开始日期> <结束日期>');
|
||||
console.log('示例: node check-api-vs-local.js 2026-05-15 2026-05-17');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const beginDate = args[0];
|
||||
const endDate = args[1];
|
||||
|
||||
console.log('========================================');
|
||||
console.log(' API出勤记录 vs 本地课评 交叉核对');
|
||||
console.log(` 日期范围: ${beginDate} 至 ${endDate}`);
|
||||
console.log(' 查找规则: V1.0 两级查找(精确 → 放宽时间)');
|
||||
console.log('========================================\n');
|
||||
|
||||
// 生成日期列表(本地时区)
|
||||
const dates = [];
|
||||
let curr = new Date(beginDate + 'T00:00:00');
|
||||
const end = new Date(endDate + 'T00:00:00');
|
||||
while (curr <= end) {
|
||||
const y = curr.getFullYear();
|
||||
const m = String(curr.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(curr.getDate()).padStart(2, '0');
|
||||
dates.push(`${y}-${m}-${d}`);
|
||||
curr.setDate(curr.getDate() + 1);
|
||||
}
|
||||
|
||||
const allResults = [];
|
||||
|
||||
for (const date of dates) {
|
||||
console.log(`\n[${date} ${getWeekday(date)}]`);
|
||||
const courses = await queryDailySchedule(date);
|
||||
|
||||
if (courses.length === 0) {
|
||||
console.log(' 无课程安排');
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const course of courses) {
|
||||
const courseCode = extractCourseCode(course.class_name);
|
||||
const weekday = getWeekday(date);
|
||||
const timeCode = extractTimeCode(course.teaching_time_period);
|
||||
const dateNum = date.replace(/-/g, '');
|
||||
const fileAliases = getFileAliases(courseCode);
|
||||
|
||||
console.log(`\n 课程: ${course.class_name}`);
|
||||
console.log(` 时间: ${course.teaching_time_period}`);
|
||||
console.log(` API应到: ${course.student_count}人 (${course.student_names?.join(', ') || '无名单'})`);
|
||||
|
||||
// ====== 两级查找:确定搜索目录范围 ======
|
||||
const level1Dir = findLevel1Dir(courseCode, weekday, timeCode);
|
||||
const level2Dirs = findLevel2Dirs(courseCode, level1Dir);
|
||||
|
||||
const searchDirs = [];
|
||||
if (level1Dir) searchDirs.push({ dir: level1Dir, level: 1 });
|
||||
for (const d of level2Dirs) searchDirs.push({ dir: d, level: 2 });
|
||||
|
||||
if (searchDirs.length === 0) {
|
||||
console.log(` 本地目录: ❌ 未找到任何匹配目录 (第1/2级均失败)`);
|
||||
allResults.push({
|
||||
date, courseCode, time: course.teaching_time_period,
|
||||
className: course.class_name, level1Dir: null, level2Dirs: [], searchDirs: [],
|
||||
apiStudents: course.student_names || [], apiCount: course.student_count,
|
||||
studentResults: [], feedbackCount: 0, issues: ['本地无匹配班级目录']
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` 第1级目录: ${level1Dir || '无'}`);
|
||||
if (level2Dirs.length > 0) console.log(` 第2级目录: ${level2Dirs.join(', ')}`);
|
||||
|
||||
// ====== 以学生为中心查找课评 ======
|
||||
const apiNames = (course.student_names || []).map(n => n.replace(/\(.*?\)/g, '').trim());
|
||||
const studentResults = [];
|
||||
|
||||
for (const apiName of apiNames) {
|
||||
let found = false;
|
||||
let foundAt = null;
|
||||
|
||||
for (const { dir, level } of searchDirs) {
|
||||
// 1a/2a: 主目录 S/feedback/
|
||||
const mainResult = checkStudentFeedback(
|
||||
path.join(MEMORY_DIR, dir), apiName, dateNum, fileAliases
|
||||
);
|
||||
if (mainResult.hasFeedback) {
|
||||
found = true;
|
||||
foundAt = { dir, level, type: '主目录', ...mainResult };
|
||||
break;
|
||||
}
|
||||
|
||||
// 1b/2b: 补课目录 补课/S/feedback/
|
||||
const makeupResult = checkStudentFeedback(
|
||||
path.join(MEMORY_DIR, dir, '补课'), apiName, dateNum, fileAliases
|
||||
);
|
||||
if (makeupResult.hasFeedback) {
|
||||
found = true;
|
||||
foundAt = { dir, level, type: '补课目录', ...makeupResult };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
studentResults.push({ name: apiName, found, foundAt });
|
||||
}
|
||||
|
||||
// ====== 比对:收集问题 ======
|
||||
const issues = [];
|
||||
const foundStudents = studentResults.filter(s => s.found);
|
||||
const missingStudents = studentResults.filter(s => !s.found);
|
||||
const foundInLevel2 = foundStudents.filter(s => s.foundAt.level === 2);
|
||||
|
||||
// 1. 未找到课评的学生
|
||||
for (const s of missingStudents) {
|
||||
// 检查在哪些目录中有学生目录(但无课评文件)
|
||||
const dirsWithStudent = searchDirs.filter(({ dir }) =>
|
||||
hasStudentDir(path.join(MEMORY_DIR, dir), s.name)
|
||||
);
|
||||
|
||||
if (dirsWithStudent.length > 0) {
|
||||
const dirNames = dirsWithStudent.map(d => d.dir).join(', ');
|
||||
issues.push(`${s.name}: 有学生目录但无课评文件 (在: ${dirNames})`);
|
||||
} else {
|
||||
issues.push(`${s.name}: 本地无学生目录(可能新增/转班)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 在第2级目录中找到的学生(提示,非错误)
|
||||
for (const s of foundInLevel2) {
|
||||
issues.push(`${s.name}: 课评在「${s.foundAt.dir}」找到(${s.foundAt.type}),建议确认是否为补课/调时间`);
|
||||
}
|
||||
|
||||
// 3. 检查课评文件日期(对已找到的学生)
|
||||
for (const s of foundStudents) {
|
||||
for (const f of s.foundAt.files) {
|
||||
const fileDate = f.substring(0, 8);
|
||||
if (fileDate !== dateNum) {
|
||||
issues.push(`${s.name}: 文件日期不匹配 ${f} (文件${fileDate} vs API${dateNum})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 检查课程编号(对已找到的学生)
|
||||
for (const s of foundStudents) {
|
||||
for (const f of s.foundAt.files) {
|
||||
const codeMatch = fileAliases.some(a => f.includes(a));
|
||||
if (!codeMatch) {
|
||||
issues.push(`${s.name}: 课程编号不匹配 ${f} (期望含${courseCode}或其别名)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 输出结果 ======
|
||||
const feedbackCount = foundStudents.length;
|
||||
if (issues.length === 0) {
|
||||
console.log(` ✅ 核对通过 (${feedbackCount}/${apiNames.length}人有课评)`);
|
||||
} else {
|
||||
const realIssues = issues.filter(i => !i.includes('建议确认')); // 第2级找到是提示不是错误
|
||||
const hints = issues.filter(i => i.includes('建议确认'));
|
||||
if (realIssues.length > 0) {
|
||||
console.log(` ⚠️ 发现 ${realIssues.length} 个问题:`);
|
||||
realIssues.forEach(issue => console.log(` - ${issue}`));
|
||||
}
|
||||
if (hints.length > 0) {
|
||||
console.log(` 💡 ${hints.length} 个提示:`);
|
||||
hints.forEach(h => console.log(` - ${h}`));
|
||||
}
|
||||
}
|
||||
|
||||
allResults.push({
|
||||
date, courseCode, time: course.teaching_time_period,
|
||||
className: course.class_name,
|
||||
level1Dir, level2Dirs, searchDirs,
|
||||
apiStudents: course.student_names || [], apiCount: course.student_count,
|
||||
studentResults, feedbackCount,
|
||||
issues
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 汇总报告 ============
|
||||
console.log('\n\n========================================');
|
||||
console.log(' 核对汇总报告');
|
||||
console.log('========================================');
|
||||
|
||||
const totalCourses = allResults.length;
|
||||
const okCourses = allResults.filter(r =>
|
||||
r.issues.filter(i => !i.includes('建议确认')).length === 0
|
||||
).length;
|
||||
const issueCourses = allResults.filter(r =>
|
||||
r.issues.filter(i => !i.includes('建议确认')).length > 0
|
||||
);
|
||||
const missingDirCourses = allResults.filter(r => r.searchDirs && r.searchDirs.length === 0);
|
||||
const level2Hints = allResults.filter(r =>
|
||||
r.issues.some(i => i.includes('建议确认'))
|
||||
).length;
|
||||
|
||||
console.log(`\n总课程数: ${totalCourses}`);
|
||||
console.log(`核对通过: ${okCourses}`);
|
||||
console.log(`存在问题: ${issueCourses.length}`);
|
||||
console.log(`无本地目录: ${missingDirCourses.length}`);
|
||||
console.log(`有第2级提示: ${level2Hints}`);
|
||||
|
||||
if (issueCourses.length > 0) {
|
||||
console.log('\n--- 问题详情 ---');
|
||||
issueCourses.forEach(r => {
|
||||
const realIssues = r.issues.filter(i => !i.includes('建议确认'));
|
||||
console.log(`\n[${r.date} ${r.time}] ${r.className}`);
|
||||
console.log(` 第1级: ${r.level1Dir || '无'}, 第2级: ${r.level2Dirs.join(', ') || '无'}`);
|
||||
realIssues.forEach(issue => console.log(` ⚠️ ${issue}`));
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 保存报告 ============
|
||||
const reportPath = path.join(__dirname, '..', 'output', `api核对报告_${beginDate}_${endDate}.md`);
|
||||
const reportDir = path.dirname(reportPath);
|
||||
if (!fs.existsSync(reportDir)) fs.mkdirSync(reportDir, { recursive: true });
|
||||
|
||||
let md = `# API出勤记录 vs 本地课评核对报告\n\n`;
|
||||
md += `- **日期范围**: ${beginDate} 至 ${endDate}\n`;
|
||||
md += `- **核对时间**: ${new Date().toLocaleString()}\n`;
|
||||
md += `- **老师**: 橙子(程城)\n`;
|
||||
md += `- **查找规则**: V1.0 两级查找(精确→放宽时间)\n\n`;
|
||||
|
||||
md += `## 汇总\n\n`;
|
||||
md += `- 总课程数: ${totalCourses}\n`;
|
||||
md += `- 核对通过: ${okCourses}\n`;
|
||||
md += `- 存在问题: ${issueCourses.length}\n`;
|
||||
md += `- 无本地目录: ${missingDirCourses.length}\n`;
|
||||
md += `- 有第2级查找提示: ${level2Hints}\n\n`;
|
||||
|
||||
md += `## 详细核对结果\n\n`;
|
||||
md += `| 日期 | 时间 | 课程 | 第1级目录 | 第2级目录 | API人数 | 找到课评 | 状态 |\n`;
|
||||
md += `|------|------|------|-----------|-----------|---------|----------|------|\n`;
|
||||
|
||||
for (const r of allResults) {
|
||||
const hasRealIssues = r.issues.filter(i => !i.includes('建议确认')).length > 0;
|
||||
const hasHints = r.issues.some(i => i.includes('建议确认'));
|
||||
let status = '✅';
|
||||
if (r.searchDirs.length === 0) status = '❌';
|
||||
else if (hasRealIssues) status = '⚠️';
|
||||
else if (hasHints) status = '💡';
|
||||
|
||||
md += `| ${r.date} | ${r.time} | ${r.className} | ${r.level1Dir || '-'} | ${r.level2Dirs.join(', ') || '-'} | ${r.apiCount} | ${r.feedbackCount || 0} | ${status} |\n`;
|
||||
}
|
||||
|
||||
if (issueCourses.length > 0) {
|
||||
md += `\n## 问题详情\n\n`;
|
||||
for (const r of issueCourses) {
|
||||
const realIssues = r.issues.filter(i => !i.includes('建议确认'));
|
||||
if (realIssues.length === 0) continue;
|
||||
|
||||
md += `### ${r.date} ${r.time} - ${r.className}\n\n`;
|
||||
md += `- 第1级目录: ${r.level1Dir || '无'}\n`;
|
||||
md += `- 第2级目录: ${r.level2Dirs.join(', ') || '无'}\n`;
|
||||
md += `- API学生: ${r.apiStudents.join(', ')}\n\n`;
|
||||
|
||||
const foundList = r.studentResults.filter(s => s.found).map(s => {
|
||||
const loc = s.foundAt.level === 1 ? '第1级' : '第2级';
|
||||
return `${s.name}(${loc},${s.foundAt.type})`;
|
||||
}).join(', ');
|
||||
|
||||
const missingList = r.studentResults.filter(s => !s.found).map(s => s.name).join(', ');
|
||||
|
||||
if (foundList) md += `- 已找到: ${foundList}\n`;
|
||||
if (missingList) md += `- 未找到: ${missingList}\n`;
|
||||
md += `\n`;
|
||||
|
||||
for (const issue of realIssues) {
|
||||
md += `- ⚠️ ${issue}\n`;
|
||||
}
|
||||
md += `\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// 第2级查找提示详情
|
||||
const hintCourses = allResults.filter(r => r.issues.some(i => i.includes('建议确认')));
|
||||
if (hintCourses.length > 0) {
|
||||
md += `\n## 第2级查找提示(课评在非精确匹配目录中找到)\n\n`;
|
||||
for (const r of hintCourses) {
|
||||
const hints = r.issues.filter(i => i.includes('建议确认'));
|
||||
md += `### ${r.date} ${r.time} - ${r.className}\n\n`;
|
||||
md += `- 精确目录: ${r.level1Dir || '无'}\n`;
|
||||
md += `- 实际找到目录: ${r.level2Dirs.join(', ')}\n\n`;
|
||||
for (const h of hints) {
|
||||
md += `- 💡 ${h}\n`;
|
||||
}
|
||||
md += `\n`;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(reportPath, md);
|
||||
console.log(`\n\n详细报告已保存: ${reportPath}`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
Reference in New Issue
Block a user