cocostudy.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  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) => { 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 ? _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. const spaceId = spaceRes[0][0].id;
  235. console.log("addcocostudySpacetea res", spaceRes, "spaceId", spaceId);
  236. if (spaceId) {
  237. return getRidData(e.userId, e.testId, spaceId);
  238. }
  239. } catch (err) {
  240. console.log("addcocostudySpacetea err", err.message || err);
  241. }
  242. }
  243. return null;
  244. };
  245. const runAutoSco = async () => {
  246. console.log("[cocostudy] 自动批改开始", new Date().toISOString());
  247. const aiBatchCorrection = [];
  248. const data = await queryAutoScoData();
  249. if (!data.length) {
  250. console.log("[cocostudy] 没有待批改数据");
  251. return { count: 0 };
  252. }
  253. data.forEach((item) => aiBatchCorrectionBtn(item, aiBatchCorrection));
  254. const gradeResults = await Promise.allSettled(
  255. aiBatchCorrection.map((e) => aiGradingBatchCorrection(aiBatchCorrection, e))
  256. );
  257. gradeResults.forEach((result, index) => {
  258. const item = aiBatchCorrection[index];
  259. if (result.status === "rejected") {
  260. console.error(`学生批改异常 workId=${item.id}`, result.reason?.message || result.reason);
  261. }
  262. });
  263. for (const e of aiBatchCorrection) {
  264. try {
  265. const analysisTask = await saveStudent(e);
  266. console.log(`保存成功 workId=${e.id}`);
  267. if (analysisTask) {
  268. try {
  269. await analysisTask;
  270. console.log(`错题分析完成 workId=${e.id}`);
  271. } catch (err) {
  272. console.error(`错题分析失败 workId=${e.id}`, err.message || err);
  273. }
  274. }
  275. } catch (err) {
  276. console.error(`保存失败 workId=${e.id}`, err.message || err);
  277. }
  278. }
  279. console.log("[cocostudy] 全部处理完成", new Date().toISOString());
  280. return { count: aiBatchCorrection.length };
  281. };
  282. let isRunning = false;
  283. const runAutoScoSafe = async () => {
  284. if (isRunning) {
  285. console.warn("[cocostudy] 上一次自动批改/分析仍在执行,跳过本次");
  286. return;
  287. }
  288. isRunning = true;
  289. try {
  290. return await runAutoSco();
  291. } catch (err) {
  292. console.error("[cocostudy] 自动批改异常", err.message || err);
  293. throw err;
  294. } finally {
  295. isRunning = false;
  296. }
  297. };
  298. let agent1a = null;
  299. let agent2a = null;
  300. let agent_data2 = null;
  301. const AGENT_IDS = {
  302. detailed: "fe58652f-d32e-4aec-ba8e-fc5a3398ac96",
  303. errorAnalysis: "f100cb78-8053-4f5b-9589-cb3a74d1b4da",
  304. lectureOutline: "ccb95f55-bf4e-4132-b342-07dfe5bb759e",
  305. };
  306. const parseAgentJsonMessage = (raw) => {
  307. if (Array.isArray(raw)) return raw;
  308. if (typeof raw === "string") {
  309. const text = raw.trim();
  310. const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
  311. const jsonText = (fenced && fenced[1] ? fenced[1] : text).trim();
  312. return JSON.parse(jsonText);
  313. }
  314. if (raw && typeof raw === "object") return [raw];
  315. return [];
  316. };
  317. const loadAgents = async () => {
  318. if (agent1a && agent2a && agent_data2) return;
  319. const [detailedRes, errorRes, outlineRes] = await Promise.all([
  320. axios.get(`https://appapi.cocorobo.cn/api/agents/agent/${AGENT_IDS.detailed}`),
  321. axios.get(`https://appapi.cocorobo.cn/api/agents/agent/${AGENT_IDS.errorAnalysis}`),
  322. axios.get(`https://appapi.cocorobo.cn/api/agents/agent/${AGENT_IDS.lectureOutline}`),
  323. ]);
  324. agent_data2 = detailedRes.data;
  325. agent1a = errorRes.data;
  326. agent2a = outlineRes.data;
  327. };
  328. const updateSpaceAnalyzeStatus = async (spaceId, isl) => {
  329. try {
  330. await callMysqlProc("updatespaceisanalyze", {
  331. sid: spaceId,
  332. isl,
  333. });
  334. console.log("[cocostudy] updatespaceisanalyze", { spaceId, isl });
  335. } catch (err) {
  336. console.error("[cocostudy] updatespaceisanalyze err", err.message || err);
  337. }
  338. };
  339. const callAgentChat = async (agent, message, userId, stepName = "Agent", spaceId) => {
  340. let lastErr;
  341. for (let attempt = 1; attempt <= GRADE_RETRY_TIMES; attempt++) {
  342. try {
  343. const res = await axios.post("https://appapi.cocorobo.cn/api/agentchats/ai_agent_chat", {
  344. id: agent.id,
  345. message,
  346. userId,
  347. model: agent.modelType,
  348. file_ids: [],
  349. sound_url: "",
  350. temperature: 0.1,
  351. top_p: 1,
  352. max_completion_tokens: 4096000,
  353. stream: false,
  354. uid: crypto.randomUUID(),
  355. session_name: crypto.randomUUID(),
  356. });
  357. const reply = res.data?.message;
  358. if (reply == null || reply === "") {
  359. throw new Error("AI 返回内容为空");
  360. }
  361. if (attempt > 1) {
  362. console.log(`[cocostudy] ${stepName} 重试成功 第${attempt}次`);
  363. }
  364. return reply;
  365. } catch (err) {
  366. lastErr = err;
  367. console.warn(
  368. `[cocostudy] ${stepName} 调用失败 第${attempt}/${GRADE_RETRY_TIMES}次`,
  369. err.response?.data || err.message
  370. );
  371. if (attempt < GRADE_RETRY_TIMES) {
  372. await sleep(GRADE_RETRY_DELAY);
  373. }
  374. }
  375. }
  376. if (spaceId) {
  377. await updateSpaceAnalyzeStatus(spaceId, 2);
  378. }
  379. throw lastErr;
  380. };
  381. const normalizeQuestionData = (questions, workData) => {
  382. return questions.map((item) => {
  383. const next = { ...item };
  384. next.userAnswer = workData.data?.[item.id] ?? "";
  385. next.userScore = workData.score?.[item.id] ?? 0;
  386. return next;
  387. });
  388. };
  389. const buildQuizSummary = (testRow, workRow, questions) => {
  390. const totalScore = questions.reduce((pre, cur) => pre + (cur.score || 0), 0);
  391. const userScore = Object.values(workRow.score || {}).reduce(
  392. (pre, cur) => pre + Number(cur || 0),
  393. 0
  394. );
  395. const totalQuestions = questions.length || 1;
  396. const wrongQuestions = questions.filter((item) => item.userScore != item.score).length;
  397. const correctRate = `${(((totalQuestions - wrongQuestions) / totalQuestions) * 100).toFixed(0)}%`;
  398. return {
  399. 试卷名称: testRow.name,
  400. 学科: testRow.subname,
  401. 年级: testRow.graname,
  402. 章节: testRow.chapters,
  403. 试卷总分: totalScore,
  404. 试卷得分: userScore,
  405. 试卷正确率: correctRate,
  406. 试卷总题目数: totalQuestions,
  407. 错题数量: wrongQuestions,
  408. 错题列表: questions,
  409. };
  410. };
  411. // 生成错题分析
  412. async function getRidData(userId, testId, spaceId) {
  413. await loadAgents();
  414. const res = await callMysqlProc("getCocostudyTestData", {
  415. uid: userId,
  416. tid: testId,
  417. });
  418. const workRow = res?.[0]?.[0];
  419. const testRow = res?.[1]?.[0];
  420. if (!workRow?.work || !testRow?.testJson) {
  421. throw new Error(`getCocostudyTestData 数据不完整 testId=${testId}`);
  422. }
  423. const workData = JSON.parse(workRow.work);
  424. const questions = normalizeQuestionData(JSON.parse(testRow.testJson), workData);
  425. const quizSummary = buildQuizSummary(testRow, workData, questions);
  426. console.log("[cocostudy] 开始错题分析", { userId, testId, spaceId });
  427. await generateErrorAnalysis(quizSummary, userId, spaceId);
  428. }
  429. async function generateErrorAnalysis(quizSummary, userId, spaceId) {
  430. const raw = await callAgentChat(
  431. agent1a,
  432. `题目数据:\n${JSON.stringify(quizSummary)}`,
  433. userId,
  434. "错因分析",
  435. spaceId
  436. );
  437. const errorAnalysis = parseAgentJsonMessage(raw);
  438. await generateLectureOutline(quizSummary, errorAnalysis, userId, spaceId);
  439. }
  440. async function generateLectureOutline(quizSummary, errorAnalysis, userId, spaceId) {
  441. const lectureOutline = await callAgentChat(
  442. agent2a,
  443. `==\n## 统计性信息如下:\n${JSON.stringify(quizSummary)}\n\n==\n\n## 错题深度分析如下:\n${JSON.stringify(errorAnalysis)}`,
  444. userId,
  445. "讲义大纲",
  446. spaceId
  447. );
  448. await generatedetailedExplanation(quizSummary, errorAnalysis, lectureOutline, userId, spaceId);
  449. }
  450. async function generatedetailedExplanation(quizSummary, errorAnalysis, lectureOutline, userId, spaceId) {
  451. const quizSummaryString = JSON.stringify(quizSummary).replace(/\\(?![\\"/])/g, "\\\\");
  452. const displayContent =
  453. "统计性信息:" +
  454. quizSummaryString +
  455. "\n错题深度分析:" +
  456. JSON.stringify(errorAnalysis) +
  457. "\n生成的Agenda顺序:" +
  458. JSON.stringify(lectureOutline);
  459. const answer = await callAgentChat(agent_data2, displayContent, userId, "详细讲解", spaceId);
  460. await insertChat(answer, spaceId, userId);
  461. await updateSpaceAnalyzeStatus(spaceId, 1);
  462. console.log("[cocostudy] 错题分析完成", { userId, spaceId });
  463. }
  464. async function insertChat(answer, spaceId, userId) {
  465. const params = {
  466. userId,
  467. userName: "系统",
  468. groupId: spaceId,
  469. answer: encodeURIComponent(answer),
  470. problem: encodeURIComponent(""),
  471. file_id: "",
  472. session_name: spaceId,
  473. alltext: answer,
  474. type: "chat",
  475. reasoning_content: "",
  476. jsonData: "{}",
  477. };
  478. const res = await axios.post("https://gpt4.cocorobo.cn/insert_chat", params);
  479. console.log("[cocostudy] insert_chat res", res.data);
  480. return res;
  481. }
  482. // 定时任务:每10分钟触发一次
  483. schedule.scheduleJob("*/10 * * * *", async () => {
  484. try {
  485. await runAutoScoSafe();
  486. } catch (error) {
  487. console.error("[cocostudy] 定时任务异常", error.message || error);
  488. }
  489. });
  490. // 手动触发:GET/POST /api/cocostudy/autosco
  491. router.all("/autosco", async (req, res) => {
  492. try {
  493. const result = await runAutoScoSafe();
  494. res.json({ code: 200, msg: "全部处理完成", data: result });
  495. } catch (err) {
  496. res.status(500).json({ code: 500, msg: err.message || "自动批改失败" });
  497. }
  498. });
  499. module.exports = router;
  500. module.exports.getRidData = getRidData;