|
@@ -0,0 +1,336 @@
|
|
|
|
|
+const express = require("express");
|
|
|
|
|
+const axios = require("axios");
|
|
|
|
|
+const schedule = require("node-schedule");
|
|
|
|
|
+const mysql = require("./mysql");
|
|
|
|
|
+const router = express.Router();
|
|
|
|
|
+
|
|
|
|
|
+// 本地
|
|
|
|
|
+const _mysqlLabor = ["183.36.26.8", "pbl"];
|
|
|
|
|
+const _mysqluser = ["183.36.26.8", "cocorobouser"];
|
|
|
|
|
+const _getmysqlLabor2 = ["183.36.26.8", "pbl"];
|
|
|
|
|
+const _getmysqlLabor = ["183.36.26.8", "pbl"];
|
|
|
|
|
+
|
|
|
|
|
+// 线上
|
|
|
|
|
+// const _mysqlLabor = ["172.16.12.5", "pbl"];
|
|
|
|
|
+// const _mysqluser = ["172.16.12.5", "cocorobouser"];
|
|
|
|
|
+// const _getmysqlLabor2 = ["172.16.12.7", "pbl"];
|
|
|
|
|
+// const _getmysqlLabor = ["172.16.12.7", "pbl"];
|
|
|
|
|
+
|
|
|
|
|
+const testUrl = "https://test-paper-analyzer.cocorobo.cn";
|
|
|
|
|
+const GRADE_RETRY_TIMES = 3;
|
|
|
|
|
+const GRADE_RETRY_DELAY = 2000;
|
|
|
|
|
+
|
|
|
|
|
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
|
+
|
|
|
|
|
+const saveToAiBatchCorrection = (aiBatchCorrection, workId, questionId, score, feedback) => {
|
|
|
|
|
+ const target = aiBatchCorrection.find((item) => item.id === workId);
|
|
|
|
|
+ if (!target) {
|
|
|
|
|
+ console.warn(`未找到 aiBatchCorrection 记录 workId=${workId}`);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ target.work.score[questionId] = score;
|
|
|
|
|
+ target.work.analysis[questionId] = feedback;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const callMysqlProc = (procedureName, data) => {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ const p = [_mysqlLabor[0], _mysqlLabor[1], procedureName, ...Object.values(data)];
|
|
|
|
|
+ mysql.usselect(p, (ret) => {
|
|
|
|
|
+ if (ret instanceof Error || (ret && ret.code)) {
|
|
|
|
|
+ reject(ret);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ resolve(ret);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const queryAutoScoData = () => {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ const p = [];
|
|
|
|
|
+ p.unshift(_mysqlLabor[0], _mysqlLabor[1], "getcocostudyautosco");
|
|
|
|
|
+ mysql.usselect(p, (ret) => {
|
|
|
|
|
+ if (ret instanceof Error || (ret && ret.code)) {
|
|
|
|
|
+ reject(ret);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ resolve(ret[0] || []);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const aiBatchCorrectionBtn = (item, aiBatchCorrection) => {
|
|
|
|
|
+ if (!item?.work || !item?.testJson) {
|
|
|
|
|
+ console.warn("缺少 work 或 testJson,跳过", item?.id);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let workParsed;
|
|
|
|
|
+ let testjson;
|
|
|
|
|
+ try {
|
|
|
|
|
+ workParsed = JSON.parse(item.work);
|
|
|
|
|
+ testjson = JSON.parse(item.testJson);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error("JSON 解析失败", item.id, err.message);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const _work = workParsed.data || {};
|
|
|
|
|
+ const work = {
|
|
|
|
|
+ score: {},
|
|
|
|
|
+ analysis: {},
|
|
|
|
|
+ data: _work,
|
|
|
|
|
+ time: workParsed.time,
|
|
|
|
|
+ };
|
|
|
|
|
+ const _test = [];
|
|
|
|
|
+
|
|
|
|
|
+ testjson.forEach((w) => {
|
|
|
|
|
+ if (w.tool === "choice") {
|
|
|
|
|
+ work.score[w.id] = 0;
|
|
|
|
|
+ if (w.answer && _work[w.id]) {
|
|
|
|
|
+ const correctAnswer = JSON.stringify([...w.answer].sort());
|
|
|
|
|
+ const userAnswer = JSON.stringify([...(_work[w.id] || [])].sort());
|
|
|
|
|
+ work.score[w.id] = correctAnswer === userAnswer ? w.score : 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ _test.push(w);
|
|
|
|
|
+ work.score[w.id] = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ aiBatchCorrection.push({
|
|
|
|
|
+ id: item.id,
|
|
|
|
|
+ name: item.name,
|
|
|
|
|
+ subject: item.subject,
|
|
|
|
|
+ testId: item.testId,
|
|
|
|
|
+ userId: item.userId,
|
|
|
|
|
+ work,
|
|
|
|
|
+ test: _test,
|
|
|
|
|
+ });
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const gradeQuestionWithRetry = async (aiBatchCorrection, params, workId, questionId) => {
|
|
|
|
|
+ let lastErr;
|
|
|
|
|
+ for (let attempt = 1; attempt <= GRADE_RETRY_TIMES; attempt++) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await axios.post(testUrl + "/llm-extract/analyze/grade-question", params, {
|
|
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
|
|
+ });
|
|
|
|
|
+ const _result = res.data?.result || res.data;
|
|
|
|
|
+ const _score = _result?.score_awarded ?? 0;
|
|
|
|
|
+ const _feedback = _result?.overall_feedback ?? "";
|
|
|
|
|
+ saveToAiBatchCorrection(aiBatchCorrection, workId, questionId, _score, _feedback);
|
|
|
|
|
+ if (attempt > 1) {
|
|
|
|
|
+ console.log(`批改重试成功 workId=${workId} questionId=${questionId} 第${attempt}次`);
|
|
|
|
|
+ }
|
|
|
|
|
+ return _result;
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ lastErr = err;
|
|
|
|
|
+ console.warn(
|
|
|
|
|
+ `批改失败 workId=${workId} questionId=${questionId} 第${attempt}/${GRADE_RETRY_TIMES}次`,
|
|
|
|
|
+ err.response?.data || err.message
|
|
|
|
|
+ );
|
|
|
|
|
+ if (attempt < GRADE_RETRY_TIMES) {
|
|
|
|
|
+ await sleep(GRADE_RETRY_DELAY);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ throw lastErr;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const aiGradingBatchCorrection = async (aiBatchCorrection, val) => {
|
|
|
|
|
+ const promises = [];
|
|
|
|
|
+ for (let i = 0; i < val.test.length; i++) {
|
|
|
|
|
+ const _testIndex = i;
|
|
|
|
|
+ const _userAnswer = val.work.data[val.test[i].id];
|
|
|
|
|
+ const _sub_questions = [];
|
|
|
|
|
+ const _imgSrc = [];
|
|
|
|
|
+
|
|
|
|
|
+ if (val.test[i].subQuestions) {
|
|
|
|
|
+ val.test[i].subQuestions.forEach((sub, index) => {
|
|
|
|
|
+ _sub_questions.push({
|
|
|
|
|
+ number: `${index + 1}`,
|
|
|
|
|
+ stem: sub.title,
|
|
|
|
|
+ knowledge_points: sub.knowledgePoint,
|
|
|
|
|
+ standard_answer: sub.answer,
|
|
|
|
|
+ scoring_criteria: sub.scoringCriteria,
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const params = {
|
|
|
|
|
+ question: {
|
|
|
|
|
+ number: `${_testIndex}`,
|
|
|
|
|
+ type: val.test[i].tool,
|
|
|
|
|
+ is_subjective: true,
|
|
|
|
|
+ score: val.test[i].score,
|
|
|
|
|
+ stem: val.test[i].title,
|
|
|
|
|
+ options: {},
|
|
|
|
|
+ sub_questions: _sub_questions,
|
|
|
|
|
+ knowledge_points: val.test[i].knowledgePoint ? val.test[i].knowledgePoint : [],
|
|
|
|
|
+ standard_answer: val.test[i].answer,
|
|
|
|
|
+ scoring_criteria: val.test[i].answer,
|
|
|
|
|
+ figure_description: "",
|
|
|
|
|
+ figure_bbox: {
|
|
|
|
|
+ page: 1,
|
|
|
|
|
+ x_min: 0,
|
|
|
|
|
+ y_min: 0,
|
|
|
|
|
+ x_max: 0,
|
|
|
|
|
+ y_max: 0,
|
|
|
|
|
+ },
|
|
|
|
|
+ figure_image_urls: _imgSrc,
|
|
|
|
|
+ },
|
|
|
|
|
+ student_file_urls: Array.isArray(_userAnswer) ? _userAnswer.map((u) => u.url) : [],
|
|
|
|
|
+ student_answer_text: ["fill", "qa"].includes(val.test[i].tool) ? _userAnswer : "",
|
|
|
|
|
+ model: "gpt-5.4",
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const questionId = val.test[i].id;
|
|
|
|
|
+ promises.push(gradeQuestionWithRetry(aiBatchCorrection, params, val.id, questionId));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const results = await Promise.allSettled(promises);
|
|
|
|
|
+ const failed = results.filter((r) => r.status === "rejected").length;
|
|
|
|
|
+ if (failed > 0) {
|
|
|
|
|
+ console.warn(`workId=${val.id} 共 ${failed}/${results.length} 道题批改失败,继续保存已有分数`);
|
|
|
|
|
+ }
|
|
|
|
|
+ return results;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const saveStudent = async (e) => {
|
|
|
|
|
+ const _json = {
|
|
|
|
|
+ fullScore: 0,
|
|
|
|
|
+ errorId: [],
|
|
|
|
|
+ correct: [],
|
|
|
|
|
+ total: e.test.length,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ e.test.forEach((i) => {
|
|
|
|
|
+ if (e.work.score[i.id] == i.score) {
|
|
|
|
|
+ _json.fullScore += 1;
|
|
|
|
|
+ _json.correct.push(i.id);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ _json.errorId.push(i.id);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ for (const i of _json.errorId) {
|
|
|
|
|
+ let test = e.test.find((t) => t.id == i);
|
|
|
|
|
+ if (test) {
|
|
|
|
|
+ test = JSON.parse(JSON.stringify(test));
|
|
|
|
|
+ test.userAnswer = e.work.data[i];
|
|
|
|
|
+ const p = {
|
|
|
|
|
+ uid: e.userId,
|
|
|
|
|
+ sid: e.subject,
|
|
|
|
|
+ tit: test.title,
|
|
|
|
|
+ kno: test.knowledge || "",
|
|
|
|
|
+ tid: i,
|
|
|
|
|
+ json: JSON.stringify(test),
|
|
|
|
|
+ clist: _json.correct.join(","),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await callMysqlProc("addcocostudyminbook", p);
|
|
|
|
|
+ console.log("addcocostudyminbook res", res);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.log("addcocostudyminbook err", err.message || err);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const params = {
|
|
|
|
|
+ id: e.id,
|
|
|
|
|
+ status: 2,
|
|
|
|
|
+ test: JSON.stringify(e.work),
|
|
|
|
|
+ json: JSON.stringify(_json),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const res = await callMysqlProc("updateCocostudysco", params);
|
|
|
|
|
+ console.log("updateCocostudysco res", res);
|
|
|
|
|
+ if (res === 1) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const spaceRes = await callMysqlProc("addcocostudySpacetea", {
|
|
|
|
|
+ uid: e.userId,
|
|
|
|
|
+ rid: e.testId,
|
|
|
|
|
+ tit: e.name,
|
|
|
|
|
+ });
|
|
|
|
|
+ console.log("addcocostudySpacetea res", spaceRes);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.log("addcocostudySpacetea err", err.message || err);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const runAutoSco = async () => {
|
|
|
|
|
+ console.log("[cocostudy] 自动批改开始", new Date().toISOString());
|
|
|
|
|
+ const aiBatchCorrection = [];
|
|
|
|
|
+ const data = await queryAutoScoData();
|
|
|
|
|
+
|
|
|
|
|
+ if (!data.length) {
|
|
|
|
|
+ console.log("[cocostudy] 没有待批改数据");
|
|
|
|
|
+ return { count: 0 };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ data.forEach((item) => aiBatchCorrectionBtn(item, aiBatchCorrection));
|
|
|
|
|
+
|
|
|
|
|
+ const gradeResults = await Promise.allSettled(
|
|
|
|
|
+ aiBatchCorrection.map((e) => aiGradingBatchCorrection(aiBatchCorrection, e))
|
|
|
|
|
+ );
|
|
|
|
|
+ gradeResults.forEach((result, index) => {
|
|
|
|
|
+ const item = aiBatchCorrection[index];
|
|
|
|
|
+ if (result.status === "rejected") {
|
|
|
|
|
+ console.error(`学生批改异常 workId=${item.id}`, result.reason?.message || result.reason);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ for (const e of aiBatchCorrection) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await saveStudent(e);
|
|
|
|
|
+ console.log(`保存成功 workId=${e.id}`);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error(`保存失败 workId=${e.id}`, err.message || err);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ console.log("[cocostudy] 全部处理完成", new Date().toISOString());
|
|
|
|
|
+ return { count: aiBatchCorrection.length };
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+let isRunning = false;
|
|
|
|
|
+
|
|
|
|
|
+const runAutoScoSafe = async () => {
|
|
|
|
|
+ if (isRunning) {
|
|
|
|
|
+ console.warn("[cocostudy] 上一次自动批改仍在执行,跳过本次");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ isRunning = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ return await runAutoSco();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error("[cocostudy] 自动批改异常", err.message || err);
|
|
|
|
|
+ throw err;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ isRunning = false;
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 定时任务:每天16点06分触发
|
|
|
|
|
+schedule.scheduleJob("6 16 * * *", async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await runAutoScoSafe();
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error("[cocostudy] 定时任务异常", error.message || error);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 手动触发:GET/POST /api/cocostudy/autosco
|
|
|
|
|
+router.all("/autosco", async (req, res) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await runAutoScoSafe();
|
|
|
|
|
+ res.json({ code: 200, msg: "全部处理完成", data: result });
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ res.status(500).json({ code: 500, msg: err.message || "自动批改失败" });
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+module.exports = router;
|