cocostudy.js 17 KB

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