cocostudy.js 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  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. throw lastErr;
  126. };
  127. const aiGradingBatchCorrection = async (aiBatchCorrection, val) => {
  128. const promises = [];
  129. for (let i = 0; i < val.test.length; i++) {
  130. const _testIndex = i;
  131. const _userAnswer = val.work.data[val.test[i].id];
  132. const _sub_questions = [];
  133. const _imgSrc = [];
  134. if (val.test[i].subQuestions) {
  135. val.test[i].subQuestions.forEach((sub, index) => {
  136. _sub_questions.push({
  137. number: `${index + 1}`,
  138. stem: sub.title,
  139. knowledge_points: sub.knowledgePoint,
  140. standard_answer: sub.answer,
  141. scoring_criteria: sub.scoringCriteria,
  142. });
  143. });
  144. }
  145. const params = {
  146. question: {
  147. number: `${_testIndex}`,
  148. type: val.test[i].tool,
  149. is_subjective: true,
  150. score: val.test[i].score,
  151. stem: val.test[i].title,
  152. options: {},
  153. sub_questions: _sub_questions,
  154. knowledge_points: val.test[i].knowledgePoint ? val.test[i].knowledgePoint : [],
  155. standard_answer: val.test[i].answer,
  156. scoring_criteria: val.test[i].answer,
  157. figure_description: "",
  158. figure_bbox: {
  159. page: 1,
  160. x_min: 0,
  161. y_min: 0,
  162. x_max: 0,
  163. y_max: 0,
  164. },
  165. figure_image_urls: _imgSrc,
  166. },
  167. student_file_urls: Array.isArray(_userAnswer) ? _userAnswer.map((u) => u.url) : [],
  168. student_answer_text: ["fill", "qa"].includes(val.test[i].tool) ? _userAnswer : "",
  169. model: "gpt-5.4",
  170. };
  171. const questionId = val.test[i].id;
  172. promises.push(gradeQuestionWithRetry(aiBatchCorrection, params, val.id, questionId));
  173. }
  174. const results = await Promise.allSettled(promises);
  175. const failed = results.filter((r) => r.status === "rejected").length;
  176. if (failed > 0) {
  177. console.warn(`workId=${val.id} 共 ${failed}/${results.length} 道题批改失败,继续保存已有分数`);
  178. }
  179. return results;
  180. };
  181. const saveStudent = async (e) => {
  182. const _json = {
  183. fullScore: 0,
  184. errorId: [],
  185. correct: [],
  186. total: e.test.length,
  187. };
  188. e.test.forEach((i) => {
  189. if (e.work.score[i.id] == i.score) {
  190. _json.fullScore += 1;
  191. _json.correct.push(i.id);
  192. } else {
  193. _json.errorId.push(i.id);
  194. }
  195. });
  196. for (const i of _json.errorId) {
  197. let test = e.test.find((t) => t.id == i);
  198. if (test) {
  199. test = JSON.parse(JSON.stringify(test));
  200. test.userAnswer = e.work.data[i];
  201. const p = {
  202. uid: e.userId,
  203. sid: e.subject,
  204. tit: test.title,
  205. kno: test.knowledge || "",
  206. tid: i,
  207. json: JSON.stringify(test),
  208. clist: _json.correct.join(","),
  209. };
  210. try {
  211. const res = await callMysqlProc("addcocostudyminbook", p);
  212. console.log("addcocostudyminbook res", res);
  213. } catch (err) {
  214. console.log("addcocostudyminbook err", err.message || err);
  215. }
  216. }
  217. }
  218. const params = {
  219. id: e.id,
  220. status: 2,
  221. test: JSON.stringify(e.work),
  222. json: JSON.stringify(_json),
  223. };
  224. const res = await callMysqlProc("updateCocostudysco", params);
  225. console.log("updateCocostudysco res", res);
  226. if (res === 1) {
  227. try {
  228. const spaceRes = await callMysqlProc("addcocostudySpacetea", {
  229. uid: e.userId,
  230. rid: e.testId,
  231. tit: e.name,
  232. });
  233. console.log("addcocostudySpacetea res", spaceRes);
  234. } catch (err) {
  235. console.log("addcocostudySpacetea err", err.message || err);
  236. }
  237. }
  238. };
  239. const runAutoSco = async () => {
  240. console.log("[cocostudy] 自动批改开始", new Date().toISOString());
  241. const aiBatchCorrection = [];
  242. const data = await queryAutoScoData();
  243. if (!data.length) {
  244. console.log("[cocostudy] 没有待批改数据");
  245. return { count: 0 };
  246. }
  247. data.forEach((item) => aiBatchCorrectionBtn(item, aiBatchCorrection));
  248. const gradeResults = await Promise.allSettled(
  249. aiBatchCorrection.map((e) => aiGradingBatchCorrection(aiBatchCorrection, e))
  250. );
  251. gradeResults.forEach((result, index) => {
  252. const item = aiBatchCorrection[index];
  253. if (result.status === "rejected") {
  254. console.error(`学生批改异常 workId=${item.id}`, result.reason?.message || result.reason);
  255. }
  256. });
  257. for (const e of aiBatchCorrection) {
  258. try {
  259. await saveStudent(e);
  260. console.log(`保存成功 workId=${e.id}`);
  261. } catch (err) {
  262. console.error(`保存失败 workId=${e.id}`, err.message || err);
  263. }
  264. }
  265. console.log("[cocostudy] 全部处理完成", new Date().toISOString());
  266. return { count: aiBatchCorrection.length };
  267. };
  268. let isRunning = false;
  269. const runAutoScoSafe = async () => {
  270. if (isRunning) {
  271. console.warn("[cocostudy] 上一次自动批改仍在执行,跳过本次");
  272. return;
  273. }
  274. isRunning = true;
  275. try {
  276. return await runAutoSco();
  277. } catch (err) {
  278. console.error("[cocostudy] 自动批改异常", err.message || err);
  279. throw err;
  280. } finally {
  281. isRunning = false;
  282. }
  283. };
  284. // 定时任务:每10分钟触发一次
  285. schedule.scheduleJob("*/10 * * * *", async () => {
  286. try {
  287. await runAutoScoSafe();
  288. } catch (error) {
  289. console.error("[cocostudy] 定时任务异常", error.message || error);
  290. }
  291. });
  292. // 手动触发:GET/POST /api/cocostudy/autosco
  293. router.all("/autosco", async (req, res) => {
  294. try {
  295. const result = await runAutoScoSafe();
  296. res.json({ code: 200, msg: "全部处理完成", data: result });
  297. } catch (err) {
  298. res.status(500).json({ code: 500, msg: err.message || "自动批改失败" });
  299. }
  300. });
  301. module.exports = router;