cocostudy.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. const express = require("express");
  2. const axios = require("axios");
  3. const schedule = require("node-schedule");
  4. const mysql = require("./mysql");
  5. const router = express.Router();
  6. // 本地
  7. // const _mysqlLabor = ["183.36.26.8", "pbl"];
  8. // const _mysqluser = ["183.36.26.8", "cocorobouser"];
  9. // const _getmysqlLabor2 = ["183.36.26.8", "pbl"];
  10. // const _getmysqlLabor = ["183.36.26.8", "pbl"];
  11. // 线上
  12. const _mysqlLabor = ["172.16.12.5", "pbl"];
  13. // const _mysqluser = ["172.16.12.5", "cocorobouser"];
  14. // const _getmysqlLabor2 = ["172.16.12.7", "pbl"];
  15. // const _getmysqlLabor = ["172.16.12.7", "pbl"];
  16. const testUrl = "https://test-paper-analyzer.cocorobo.cn";
  17. const GRADE_RETRY_TIMES = 3;
  18. const GRADE_RETRY_DELAY = 2000;
  19. const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  20. const saveToAiBatchCorrection = (aiBatchCorrection, workId, questionId, score, feedback) => {
  21. const target = aiBatchCorrection.find((item) => item.id === workId);
  22. if (!target) {
  23. console.warn(`未找到 aiBatchCorrection 记录 workId=${workId}`);
  24. return;
  25. }
  26. target.work.score[questionId] = score;
  27. target.work.analysis[questionId] = feedback;
  28. };
  29. const callMysqlProc = (procedureName, data) => {
  30. return new Promise((resolve, reject) => {
  31. const p = [_mysqlLabor[0], _mysqlLabor[1], procedureName, ...Object.values(data)];
  32. mysql.usselect(p, (ret) => {
  33. if (ret instanceof Error || (ret && ret.code)) {
  34. reject(ret);
  35. return;
  36. }
  37. resolve(ret);
  38. });
  39. });
  40. };
  41. const queryAutoScoData = () => {
  42. return new Promise((resolve, reject) => {
  43. const p = [];
  44. p.unshift(_mysqlLabor[0], _mysqlLabor[1], "getcocostudyautosco");
  45. mysql.usselect(p, (ret) => {
  46. if (ret instanceof Error || (ret && ret.code)) {
  47. reject(ret);
  48. return;
  49. }
  50. resolve(ret[0] || []);
  51. });
  52. });
  53. };
  54. const aiBatchCorrectionBtn = (item, aiBatchCorrection) => {
  55. if (!item?.work || !item?.testJson) {
  56. console.warn("缺少 work 或 testJson,跳过", item?.id);
  57. return;
  58. }
  59. let workParsed;
  60. let testjson;
  61. try {
  62. workParsed = JSON.parse(item.work);
  63. testjson = JSON.parse(item.testJson);
  64. } catch (err) {
  65. console.error("JSON 解析失败", item.id, err.message);
  66. return;
  67. }
  68. const _work = workParsed.data || {};
  69. const work = {
  70. score: {},
  71. analysis: {},
  72. data: _work,
  73. time: workParsed.time,
  74. };
  75. const _test = [];
  76. testjson.forEach((w) => {
  77. if (w.tool === "choice") {
  78. work.score[w.id] = 0;
  79. if (w.answer && _work[w.id]) {
  80. const correctAnswer = JSON.stringify([...w.answer].sort());
  81. const userAnswer = JSON.stringify([...(_work[w.id] || [])].sort());
  82. work.score[w.id] = correctAnswer === userAnswer ? w.score : 0;
  83. }
  84. } else {
  85. _test.push(w);
  86. work.score[w.id] = 0;
  87. }
  88. });
  89. aiBatchCorrection.push({
  90. id: item.id,
  91. name: item.name,
  92. subject: item.subject,
  93. testId: item.testId,
  94. userId: item.userId,
  95. work,
  96. test: _test,
  97. });
  98. };
  99. const gradeQuestionWithRetry = async (aiBatchCorrection, params, workId, questionId) => {
  100. let lastErr;
  101. for (let attempt = 1; attempt <= GRADE_RETRY_TIMES; attempt++) {
  102. try {
  103. const res = await axios.post(testUrl + "/llm-extract/analyze/grade-question", params, {
  104. headers: { "Content-Type": "application/json" },
  105. });
  106. const _result = res.data?.result || res.data;
  107. const _score = _result?.score_awarded ?? 0;
  108. const _feedback = _result?.overall_feedback ?? "";
  109. saveToAiBatchCorrection(aiBatchCorrection, workId, questionId, _score, _feedback);
  110. if (attempt > 1) {
  111. console.log(`批改重试成功 workId=${workId} questionId=${questionId} 第${attempt}次`);
  112. }
  113. return _result;
  114. } catch (err) {
  115. lastErr = err;
  116. console.warn(
  117. `批改失败 workId=${workId} questionId=${questionId} 第${attempt}/${GRADE_RETRY_TIMES}次`,
  118. err.response?.data || err.message
  119. );
  120. if (attempt < GRADE_RETRY_TIMES) {
  121. await sleep(GRADE_RETRY_DELAY);
  122. }
  123. }
  124. }
  125. saveToAiBatchCorrection(aiBatchCorrection, workId, questionId, 0, "批改失败");
  126. throw lastErr;
  127. };
  128. const aiGradingBatchCorrection = async (aiBatchCorrection, val) => {
  129. const promises = [];
  130. for (let i = 0; i < val.test.length; i++) {
  131. const _testIndex = i;
  132. const _userAnswer = val.work.data[val.test[i].id];
  133. const _sub_questions = [];
  134. const _imgSrc = [];
  135. if (val.test[i].subQuestions) {
  136. val.test[i].subQuestions.forEach((sub, index) => {
  137. _sub_questions.push({
  138. number: `${index + 1}`,
  139. stem: sub.title,
  140. knowledge_points: sub.knowledgePoint,
  141. standard_answer: sub.answer,
  142. scoring_criteria: sub.scoringCriteria,
  143. });
  144. });
  145. }
  146. const params = {
  147. question: {
  148. number: `${_testIndex}`,
  149. type: val.test[i].tool,
  150. is_subjective: true,
  151. score: val.test[i].score,
  152. stem: val.test[i].title,
  153. options: {},
  154. sub_questions: _sub_questions,
  155. knowledge_points: val.test[i].knowledgePoint ? val.test[i].knowledgePoint : [],
  156. standard_answer: val.test[i].answer,
  157. scoring_criteria: val.test[i].answer,
  158. figure_description: "",
  159. figure_bbox: {
  160. page: 1,
  161. x_min: 0,
  162. y_min: 0,
  163. x_max: 0,
  164. y_max: 0,
  165. },
  166. figure_image_urls: _imgSrc,
  167. },
  168. student_file_urls: Array.isArray(_userAnswer) ? _userAnswer.map((u) => u.url) : [],
  169. student_answer_text: ["fill", "qa"].includes(val.test[i].tool) ? _userAnswer : "",
  170. model: "gpt-5.4",
  171. };
  172. const questionId = val.test[i].id;
  173. promises.push(gradeQuestionWithRetry(aiBatchCorrection, params, val.id, questionId));
  174. }
  175. const results = await Promise.allSettled(promises);
  176. const failed = results.filter((r) => r.status === "rejected").length;
  177. if (failed > 0) {
  178. console.warn(`workId=${val.id} 共 ${failed}/${results.length} 道题批改失败,继续保存已有分数`);
  179. }
  180. return results;
  181. };
  182. const saveStudent = async (e) => {
  183. const _json = {
  184. fullScore: 0,
  185. errorId: [],
  186. correct: [],
  187. total: e.test.length,
  188. };
  189. e.test.forEach((i) => {
  190. if (e.work.score[i.id] == i.score) {
  191. _json.fullScore += 1;
  192. _json.correct.push(i.id);
  193. } else {
  194. _json.errorId.push(i.id);
  195. }
  196. });
  197. for (const i of _json.errorId) {
  198. let test = e.test.find((t) => t.id == i);
  199. if (test) {
  200. test = JSON.parse(JSON.stringify(test));
  201. test.userAnswer = e.work.data[i];
  202. const p = {
  203. uid: e.userId,
  204. sid: e.subject,
  205. tit: test.title,
  206. kno: test.knowledge || "",
  207. tid: i,
  208. json: JSON.stringify(test),
  209. clist: _json.correct.join(","),
  210. };
  211. try {
  212. const res = await callMysqlProc("addcocostudyminbook", p);
  213. console.log("addcocostudyminbook res", res);
  214. } catch (err) {
  215. console.log("addcocostudyminbook err", err.message || err);
  216. }
  217. }
  218. }
  219. const params = {
  220. id: e.id,
  221. status: 2,
  222. test: JSON.stringify(e.work),
  223. json: JSON.stringify(_json),
  224. };
  225. const res = await callMysqlProc("updateCocostudysco", params);
  226. console.log("updateCocostudysco res", res);
  227. if (res === 1) {
  228. try {
  229. const spaceRes = await callMysqlProc("addcocostudySpacetea", {
  230. uid: e.userId,
  231. rid: e.testId,
  232. tit: e.name,
  233. });
  234. console.log("addcocostudySpacetea res", spaceRes);
  235. } catch (err) {
  236. console.log("addcocostudySpacetea err", err.message || err);
  237. }
  238. }
  239. };
  240. const runAutoSco = async () => {
  241. console.log("[cocostudy] 自动批改开始", new Date().toISOString());
  242. const aiBatchCorrection = [];
  243. const data = await queryAutoScoData();
  244. if (!data.length) {
  245. console.log("[cocostudy] 没有待批改数据");
  246. return { count: 0 };
  247. }
  248. data.forEach((item) => aiBatchCorrectionBtn(item, aiBatchCorrection));
  249. const gradeResults = await Promise.allSettled(
  250. aiBatchCorrection.map((e) => aiGradingBatchCorrection(aiBatchCorrection, e))
  251. );
  252. gradeResults.forEach((result, index) => {
  253. const item = aiBatchCorrection[index];
  254. if (result.status === "rejected") {
  255. console.error(`学生批改异常 workId=${item.id}`, result.reason?.message || result.reason);
  256. }
  257. });
  258. for (const e of aiBatchCorrection) {
  259. try {
  260. await saveStudent(e);
  261. console.log(`保存成功 workId=${e.id}`);
  262. } catch (err) {
  263. console.error(`保存失败 workId=${e.id}`, err.message || err);
  264. }
  265. }
  266. console.log("[cocostudy] 全部处理完成", new Date().toISOString());
  267. return { count: aiBatchCorrection.length };
  268. };
  269. let isRunning = false;
  270. const runAutoScoSafe = async () => {
  271. if (isRunning) {
  272. console.warn("[cocostudy] 上一次自动批改仍在执行,跳过本次");
  273. return;
  274. }
  275. isRunning = true;
  276. try {
  277. return await runAutoSco();
  278. } catch (err) {
  279. console.error("[cocostudy] 自动批改异常", err.message || err);
  280. throw err;
  281. } finally {
  282. isRunning = false;
  283. }
  284. };
  285. // 定时任务:每10分钟触发一次
  286. schedule.scheduleJob("*/10 * * * *", async () => {
  287. try {
  288. await runAutoScoSafe();
  289. } catch (error) {
  290. console.error("[cocostudy] 定时任务异常", error.message || error);
  291. }
  292. });
  293. // 手动触发:GET/POST /api/cocostudy/autosco
  294. router.all("/autosco", async (req, res) => {
  295. try {
  296. const result = await runAutoScoSafe();
  297. res.json({ code: 200, msg: "全部处理完成", data: result });
  298. } catch (err) {
  299. res.status(500).json({ code: 500, msg: err.message || "自动批改失败" });
  300. }
  301. });
  302. module.exports = router;