cocostudy.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  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. const crypto = require("crypto");
  7. // 本地
  8. const _mysqlLabor = ["183.36.26.8", "pbl"];
  9. // const _mysqluser = ["183.36.26.8", "cocorobouser"];
  10. // const _getmysqlLabor2 = ["183.36.26.8", "pbl"];
  11. // const _getmysqlLabor = ["183.36.26.8", "pbl"];
  12. // 线上
  13. // const _mysqlLabor = ["172.16.12.5", "pbl"];
  14. // const _mysqluser = ["172.16.12.5", "cocorobouser"];
  15. // const _getmysqlLabor2 = ["172.16.12.7", "pbl"];
  16. // const _getmysqlLabor = ["172.16.12.7", "pbl"];
  17. const testUrl = "https://test-paper-analyzer.cocorobo.cn";
  18. const GRADE_RETRY_TIMES = 3;
  19. const GRADE_RETRY_DELAY = 2000;
  20. const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  21. const saveToAiBatchCorrection = (aiBatchCorrection, workId, questionId, score, feedback) => {
  22. const target = aiBatchCorrection.find((item) => item.id === workId);
  23. if (!target) {
  24. console.warn(`未找到 aiBatchCorrection 记录 workId=${workId}`);
  25. return;
  26. }
  27. target.work.score[questionId] = score;
  28. target.work.analysis[questionId] = feedback;
  29. };
  30. const callMysqlProc = (procedureName, data) => {
  31. return new Promise((resolve, reject) => {
  32. const p = [_mysqlLabor[0], _mysqlLabor[1], procedureName, ...Object.values(data)];
  33. mysql.usselect(p, (ret) => {
  34. if (ret instanceof Error || (ret && ret.code)) {
  35. reject(ret);
  36. return;
  37. }
  38. resolve(ret);
  39. });
  40. });
  41. };
  42. const queryAutoScoData = () => {
  43. return new Promise((resolve, reject) => {
  44. const p = [];
  45. p.unshift(_mysqlLabor[0], _mysqlLabor[1], "getcocostudyautosco");
  46. mysql.usselect(p, (ret) => {
  47. if (ret instanceof Error || (ret && ret.code)) {
  48. reject(ret);
  49. return;
  50. }
  51. resolve(ret[0] || []);
  52. });
  53. });
  54. };
  55. const aiBatchCorrectionBtn = (item, aiBatchCorrection) => {
  56. if (!item?.work || !item?.testJson) {
  57. console.warn("缺少 work 或 testJson,跳过", item?.id);
  58. return;
  59. }
  60. let workParsed;
  61. let testjson;
  62. try {
  63. workParsed = JSON.parse(item.work);
  64. testjson = JSON.parse(item.testJson);
  65. } catch (err) {
  66. console.error("JSON 解析失败", item.id, err.message);
  67. return;
  68. }
  69. const _work = workParsed.data || {};
  70. const work = {
  71. score: {},
  72. analysis: {},
  73. data: _work,
  74. time: workParsed.time,
  75. };
  76. const _test = [];
  77. testjson.forEach((w) => {
  78. _test.push(w);
  79. work.score[w.id] = 0;
  80. });
  81. aiBatchCorrection.push({
  82. id: item.id,
  83. name: item.name,
  84. subject: item.subject,
  85. testId: item.testId,
  86. userId: item.userId,
  87. work,
  88. test: _test,
  89. });
  90. };
  91. const gradeQuestionWithRetry = async (aiBatchCorrection, params, workId, questionId) => {
  92. let lastErr;
  93. for (let attempt = 1; attempt <= GRADE_RETRY_TIMES; attempt++) {
  94. try {
  95. const res = await axios.post(testUrl + "/llm-extract/analyze/grade-question", params, {
  96. headers: { "Content-Type": "application/json" },
  97. });
  98. const _result = res.data?.result || res.data;
  99. const _score = _result?.score_awarded ?? 0;
  100. const _feedback = _result?.overall_feedback ?? "";
  101. saveToAiBatchCorrection(aiBatchCorrection, workId, questionId, _score, _feedback);
  102. if (attempt > 1) {
  103. console.log(`批改重试成功 workId=${workId} questionId=${questionId} 第${attempt}次`);
  104. }
  105. return _result;
  106. } catch (err) {
  107. lastErr = err;
  108. console.warn(
  109. `批改失败 workId=${workId} questionId=${questionId} 第${attempt}/${GRADE_RETRY_TIMES}次`,
  110. err.response?.data || err.message
  111. );
  112. if (attempt < GRADE_RETRY_TIMES) {
  113. await sleep(GRADE_RETRY_DELAY);
  114. }
  115. }
  116. }
  117. saveToAiBatchCorrection(aiBatchCorrection, workId, questionId, 0, "批改失败");
  118. throw lastErr;
  119. };
  120. const aiGradingBatchCorrection = async (aiBatchCorrection, val) => {
  121. const promises = [];
  122. for (let i = 0; i < val.test.length; i++) {
  123. const _testIndex = i;
  124. const _userAnswer = val.work.data[val.test[i].id];
  125. const _sub_questions = [];
  126. const _imgSrc = [];
  127. if (val.test[i].subQuestions) {
  128. val.test[i].subQuestions.forEach((sub, index) => {
  129. _sub_questions.push({
  130. number: `${index + 1}`,
  131. stem: sub.title,
  132. knowledge_points: sub.knowledgePoint,
  133. standard_answer: sub.answer,
  134. scoring_criteria: sub.scoringCriteria,
  135. });
  136. });
  137. }
  138. const params = {
  139. question: {
  140. number: `${_testIndex}`,
  141. type: val.test[i].tool,
  142. is_subjective: true,
  143. score: val.test[i].score,
  144. stem: val.test[i].title,
  145. options: {},
  146. sub_questions: _sub_questions,
  147. knowledge_points: val.test[i].knowledgePoint ? val.test[i].knowledgePoint : [],
  148. standard_answer: val.test[i].answer,
  149. scoring_criteria: val.test[i].answer,
  150. figure_description: "",
  151. figure_bbox: {
  152. page: 1,
  153. x_min: 0,
  154. y_min: 0,
  155. x_max: 0,
  156. y_max: 0,
  157. },
  158. figure_image_urls: _imgSrc,
  159. },
  160. student_file_urls: Array.isArray(_userAnswer) ? _userAnswer.map((u) => u.url) : [],
  161. student_answer_text: ["fill", "qa", "choice"].includes(val.test[i].tool) ? _userAnswer : "",
  162. model: "gpt-5.4",
  163. };
  164. const questionId = val.test[i].id;
  165. promises.push(gradeQuestionWithRetry(aiBatchCorrection, params, val.id, questionId));
  166. }
  167. const results = await Promise.allSettled(promises);
  168. const failed = results.filter((r) => r.status === "rejected").length;
  169. if (failed > 0) {
  170. console.warn(`workId=${val.id} 共 ${failed}/${results.length} 道题批改失败,继续保存已有分数`);
  171. }
  172. return results;
  173. };
  174. const saveStudent = async (e) => {
  175. const _json = {
  176. fullScore: 0,
  177. errorId: [],
  178. correct: [],
  179. total: e.test.length,
  180. };
  181. e.test.forEach((i) => {
  182. if (e.work.score[i.id] == i.score) {
  183. _json.fullScore += 1;
  184. _json.correct.push(i.id);
  185. } else {
  186. _json.errorId.push(i.id);
  187. }
  188. });
  189. for (const i of _json.errorId) {
  190. let test = e.test.find((t) => t.id == i);
  191. if (test) {
  192. test = JSON.parse(JSON.stringify(test));
  193. test.userAnswer = e.work.data[i];
  194. const p = {
  195. uid: e.userId,
  196. sid: e.subject,
  197. tit: test.title,
  198. kno: test.knowledge || "",
  199. tid: i,
  200. json: JSON.stringify(test),
  201. clist: _json.correct.join(","),
  202. };
  203. try {
  204. const res = await callMysqlProc("addcocostudyminbook", p);
  205. console.log("addcocostudyminbook res", res);
  206. } catch (err) {
  207. console.log("addcocostudyminbook err", err.message || err);
  208. }
  209. }
  210. }
  211. const params = {
  212. id: e.id,
  213. status: 2,
  214. test: JSON.stringify(e.work),
  215. json: JSON.stringify(_json),
  216. };
  217. const res = await callMysqlProc("updateCocostudysco", params);
  218. console.log("updateCocostudysco res", res);
  219. if (res === 1) {
  220. try {
  221. const spaceRes = await callMysqlProc("addcocostudySpacetea", {
  222. uid: e.userId,
  223. rid: e.testId,
  224. tit: e.name,
  225. });
  226. const spaceId = spaceRes[0][0].id;
  227. console.log("addcocostudySpacetea res", spaceRes, "spaceId", spaceId);
  228. if (spaceId) {
  229. return getRidData(e.userId, e.testId, spaceId);
  230. }
  231. } catch (err) {
  232. console.log("addcocostudySpacetea err", err.message || err);
  233. }
  234. }
  235. return null;
  236. };
  237. const runAutoSco = async () => {
  238. console.log("[cocostudy] 自动批改开始", new Date().toISOString());
  239. const aiBatchCorrection = [];
  240. const data = await queryAutoScoData();
  241. if (!data.length) {
  242. console.log("[cocostudy] 没有待批改数据");
  243. return { count: 0 };
  244. }
  245. data.forEach((item) => aiBatchCorrectionBtn(item, aiBatchCorrection));
  246. const gradeResults = await Promise.allSettled(
  247. aiBatchCorrection.map((e) => aiGradingBatchCorrection(aiBatchCorrection, e))
  248. );
  249. gradeResults.forEach((result, index) => {
  250. const item = aiBatchCorrection[index];
  251. if (result.status === "rejected") {
  252. console.error(`学生批改异常 workId=${item.id}`, result.reason?.message || result.reason);
  253. }
  254. });
  255. for (const e of aiBatchCorrection) {
  256. try {
  257. const analysisTask = await saveStudent(e);
  258. console.log(`保存成功 workId=${e.id}`);
  259. if (analysisTask) {
  260. try {
  261. await analysisTask;
  262. console.log(`错题分析完成 workId=${e.id}`);
  263. } catch (err) {
  264. console.error(`错题分析失败 workId=${e.id}`, err.message || err);
  265. }
  266. }
  267. } catch (err) {
  268. console.error(`保存失败 workId=${e.id}`, err.message || err);
  269. }
  270. }
  271. console.log("[cocostudy] 全部处理完成", new Date().toISOString());
  272. return { count: aiBatchCorrection.length };
  273. };
  274. let isRunning = false;
  275. const runAutoScoSafe = async () => {
  276. if (isRunning) {
  277. console.warn("[cocostudy] 上一次自动批改/分析仍在执行,跳过本次");
  278. return;
  279. }
  280. isRunning = true;
  281. try {
  282. return await runAutoSco();
  283. } catch (err) {
  284. console.error("[cocostudy] 自动批改异常", err.message || err);
  285. throw err;
  286. } finally {
  287. isRunning = false;
  288. }
  289. };
  290. let agent1a = null;
  291. let agent2a = null;
  292. let agent_data2 = null;
  293. const AGENT_IDS = {
  294. detailed: "fe58652f-d32e-4aec-ba8e-fc5a3398ac96",
  295. errorAnalysis: "f100cb78-8053-4f5b-9589-cb3a74d1b4da",
  296. lectureOutline: "ccb95f55-bf4e-4132-b342-07dfe5bb759e",
  297. };
  298. const parseAgentJsonMessage = (raw) => {
  299. if (Array.isArray(raw)) return raw;
  300. if (typeof raw === "string") {
  301. const text = raw.trim();
  302. const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
  303. const jsonText = (fenced && fenced[1] ? fenced[1] : text).trim();
  304. return JSON.parse(jsonText);
  305. }
  306. if (raw && typeof raw === "object") return [raw];
  307. return [];
  308. };
  309. const loadAgents = async () => {
  310. if (agent1a && agent2a && agent_data2) return;
  311. const [detailedRes, errorRes, outlineRes] = await Promise.all([
  312. axios.get(`https://appapi.cocorobo.cn/api/agents/agent/${AGENT_IDS.detailed}`),
  313. axios.get(`https://appapi.cocorobo.cn/api/agents/agent/${AGENT_IDS.errorAnalysis}`),
  314. axios.get(`https://appapi.cocorobo.cn/api/agents/agent/${AGENT_IDS.lectureOutline}`),
  315. ]);
  316. agent_data2 = detailedRes.data;
  317. agent1a = errorRes.data;
  318. agent2a = outlineRes.data;
  319. };
  320. const updateSpaceAnalyzeStatus = async (spaceId, isl) => {
  321. try {
  322. await callMysqlProc("updatespaceisanalyze", {
  323. sid: spaceId,
  324. isl,
  325. });
  326. console.log("[cocostudy] updatespaceisanalyze", { spaceId, isl });
  327. } catch (err) {
  328. console.error("[cocostudy] updatespaceisanalyze err", err.message || err);
  329. }
  330. };
  331. const callAgentChat = async (agent, message, userId, stepName = "Agent", spaceId) => {
  332. let lastErr;
  333. for (let attempt = 1; attempt <= GRADE_RETRY_TIMES; attempt++) {
  334. try {
  335. const res = await axios.post("https://appapi.cocorobo.cn/api/agentchats/ai_agent_chat", {
  336. id: agent.id,
  337. message,
  338. userId,
  339. model: agent.modelType,
  340. file_ids: [],
  341. sound_url: "",
  342. temperature: 0.1,
  343. top_p: 1,
  344. max_completion_tokens: 4096000,
  345. stream: false,
  346. uid: crypto.randomUUID(),
  347. session_name: crypto.randomUUID(),
  348. });
  349. const reply = res.data?.message;
  350. if (reply == null || reply === "") {
  351. throw new Error("AI 返回内容为空");
  352. }
  353. if (attempt > 1) {
  354. console.log(`[cocostudy] ${stepName} 重试成功 第${attempt}次`);
  355. }
  356. return reply;
  357. } catch (err) {
  358. lastErr = err;
  359. console.warn(
  360. `[cocostudy] ${stepName} 调用失败 第${attempt}/${GRADE_RETRY_TIMES}次`,
  361. err.response?.data || err.message
  362. );
  363. if (attempt < GRADE_RETRY_TIMES) {
  364. await sleep(GRADE_RETRY_DELAY);
  365. }
  366. }
  367. }
  368. if (spaceId) {
  369. await updateSpaceAnalyzeStatus(spaceId, 2);
  370. }
  371. throw lastErr;
  372. };
  373. const normalizeQuestionData = (questions, workData) => {
  374. return questions.map((item) => {
  375. const next = { ...item };
  376. next.userAnswer = workData.data?.[item.id] ?? "";
  377. next.userScore = workData.score?.[item.id] ?? 0;
  378. return next;
  379. });
  380. };
  381. const buildQuizSummary = (testRow, workRow, questions) => {
  382. const totalScore = questions.reduce((pre, cur) => pre + (cur.score || 0), 0);
  383. const userScore = Object.values(workRow.score || {}).reduce(
  384. (pre, cur) => pre + Number(cur || 0),
  385. 0
  386. );
  387. const totalQuestions = questions.length || 1;
  388. const wrongQuestions = questions.filter((item) => item.userScore != item.score).length;
  389. const correctRate = `${(((totalQuestions - wrongQuestions) / totalQuestions) * 100).toFixed(0)}%`;
  390. return {
  391. 试卷名称: testRow.name,
  392. 学科: testRow.subname,
  393. 年级: testRow.graname,
  394. 章节: testRow.chapters,
  395. 试卷总分: totalScore,
  396. 试卷得分: userScore,
  397. 试卷正确率: correctRate,
  398. 试卷总题目数: totalQuestions,
  399. 错题数量: wrongQuestions,
  400. 错题列表: questions,
  401. };
  402. };
  403. // 生成错题分析
  404. async function getRidData(userId, testId, spaceId) {
  405. await loadAgents();
  406. const res = await callMysqlProc("getCocostudyTestData", {
  407. uid: userId,
  408. tid: testId,
  409. });
  410. const workRow = res?.[0]?.[0];
  411. const testRow = res?.[1]?.[0];
  412. if (!workRow?.work || !testRow?.testJson) {
  413. throw new Error(`getCocostudyTestData 数据不完整 testId=${testId}`);
  414. }
  415. const workData = JSON.parse(workRow.work);
  416. const questions = normalizeQuestionData(JSON.parse(testRow.testJson), workData);
  417. const quizSummary = buildQuizSummary(testRow, workData, questions);
  418. console.log("[cocostudy] 开始错题分析", { userId, testId, spaceId });
  419. await generateErrorAnalysis(quizSummary, userId, spaceId);
  420. }
  421. async function generateErrorAnalysis(quizSummary, userId, spaceId) {
  422. const raw = await callAgentChat(
  423. agent1a,
  424. `题目数据:\n${JSON.stringify(quizSummary)}`,
  425. userId,
  426. "错因分析",
  427. spaceId
  428. );
  429. const errorAnalysis = parseAgentJsonMessage(raw);
  430. await generateLectureOutline(quizSummary, errorAnalysis, userId, spaceId);
  431. }
  432. async function generateLectureOutline(quizSummary, errorAnalysis, userId, spaceId) {
  433. const lectureOutline = await callAgentChat(
  434. agent2a,
  435. `==\n## 统计性信息如下:\n${JSON.stringify(quizSummary)}\n\n==\n\n## 错题深度分析如下:\n${JSON.stringify(errorAnalysis)}`,
  436. userId,
  437. "讲义大纲",
  438. spaceId
  439. );
  440. await generatedetailedExplanation(quizSummary, errorAnalysis, lectureOutline, userId, spaceId);
  441. }
  442. async function generatedetailedExplanation(quizSummary, errorAnalysis, lectureOutline, userId, spaceId) {
  443. const quizSummaryString = JSON.stringify(quizSummary).replace(/\\(?![\\"/])/g, "\\\\");
  444. const displayContent =
  445. "统计性信息:" +
  446. quizSummaryString +
  447. "\n错题深度分析:" +
  448. JSON.stringify(errorAnalysis) +
  449. "\n生成的Agenda顺序:" +
  450. JSON.stringify(lectureOutline);
  451. const answer = await callAgentChat(agent_data2, displayContent, userId, "详细讲解", spaceId);
  452. await insertChat(answer, spaceId, userId);
  453. await updateSpaceAnalyzeStatus(spaceId, 1);
  454. console.log("[cocostudy] 错题分析完成", { userId, spaceId });
  455. }
  456. async function insertChat(answer, spaceId, userId) {
  457. const params = {
  458. userId,
  459. userName: "系统",
  460. groupId: spaceId,
  461. answer: encodeURIComponent(answer),
  462. problem: encodeURIComponent(""),
  463. file_id: "",
  464. session_name: spaceId,
  465. alltext: answer,
  466. type: "chat",
  467. reasoning_content: "",
  468. jsonData: "{}",
  469. };
  470. const res = await axios.post("https://gpt4.cocorobo.cn/insert_chat", params);
  471. console.log("[cocostudy] insert_chat res", res.data);
  472. return res;
  473. }
  474. // 定时任务:每10分钟触发一次
  475. // schedule.scheduleJob("*/10 * * * *", async () => {
  476. // try {
  477. // await runAutoScoSafe();
  478. // } catch (error) {
  479. // console.error("[cocostudy] 定时任务异常", error.message || error);
  480. // }
  481. // });
  482. // 手动触发:GET/POST /api/cocostudy/autosco
  483. router.all("/autosco", async (req, res) => {
  484. try {
  485. const result = await runAutoScoSafe();
  486. res.json({ code: 200, msg: "全部处理完成", data: result });
  487. } catch (err) {
  488. res.status(500).json({ code: 500, msg: err.message || "自动批改失败" });
  489. }
  490. });
  491. module.exports = router;
  492. module.exports.getRidData = getRidData;