cocostudy.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  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. // 线上
  10. const _mysqlLabor = ["172.16.12.5", "pbl"];
  11. const testUrl = "https://test-paper-analyzer.cocorobo.cn";
  12. const GRADE_RETRY_TIMES = 3;
  13. const GRADE_RETRY_DELAY = 2000;
  14. const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  15. const saveToAiBatchCorrection = (aiBatchCorrection, workId, questionId, score, feedback) => {
  16. const target = aiBatchCorrection.find((item) => item.id === workId);
  17. if (!target) {
  18. console.warn(`未找到 aiBatchCorrection 记录 workId=${workId}`);
  19. return;
  20. }
  21. target.work.score[questionId] = score;
  22. target.work.analysis[questionId] = feedback;
  23. };
  24. const callMysqlProc = (procedureName, data) => {
  25. return new Promise((resolve, reject) => {
  26. const p = [_mysqlLabor[0], _mysqlLabor[1], procedureName, ...Object.values(data)];
  27. mysql.usselect(p, (ret) => {
  28. if (ret instanceof Error || (ret && ret.code)) {
  29. reject(ret);
  30. return;
  31. }
  32. resolve(ret);
  33. });
  34. });
  35. };
  36. let isRunning = false;
  37. let agent1a = null;
  38. let agent1b = null;
  39. let agent2a = null;
  40. let agent1c = null;
  41. const AGENT_IDS = {
  42. // 1a
  43. errorAnalysis: "f100cb78-8053-4f5b-9589-cb3a74d1b4da",
  44. // 1b
  45. lectureOutline: "08028b8b-b067-43c3-b8f5-b3a874f0a502",
  46. // 1c
  47. oneCExplanation: "cc43167c-e767-4f81-8a73-b0295de49472",
  48. // 2a
  49. detailed: "a000e7e9-d8f0-47d0-98e6-f5b1c6171934",
  50. };
  51. const tryParseJson = (text) => {
  52. try {
  53. return JSON.parse(text);
  54. } catch (_) {
  55. return undefined;
  56. }
  57. };
  58. const normalizeParsedAgentMessage = (parsed) => {
  59. if (Array.isArray(parsed)) return parsed;
  60. if (parsed && typeof parsed === "object") return [parsed];
  61. return parsed;
  62. };
  63. const collectJsonCandidates = (text) => {
  64. const candidates = [];
  65. const seen = new Set();
  66. const push = (value) => {
  67. const next = (value || "").trim();
  68. if (!next || seen.has(next)) return;
  69. seen.add(next);
  70. candidates.push(next);
  71. };
  72. const fenceRe = /```(?:json)?\s*([\s\S]*?)\s*```/gi;
  73. let match;
  74. while ((match = fenceRe.exec(text)) !== null) {
  75. push(match[1]);
  76. }
  77. push(text);
  78. for (const startChar of ["{", "["]) {
  79. let idx = text.indexOf(startChar);
  80. while (idx !== -1) {
  81. push(text.slice(idx));
  82. idx = text.indexOf(startChar, idx + 1);
  83. if (candidates.length > 30) break;
  84. }
  85. }
  86. return candidates;
  87. };
  88. const parseAgentJsonMessage = (raw) => {
  89. if (Array.isArray(raw)) return raw;
  90. if (raw && typeof raw === "object") return [raw];
  91. if (typeof raw !== "string") return [];
  92. const text = raw.trim();
  93. if (!text) return [];
  94. for (const candidate of collectJsonCandidates(text)) {
  95. const parsed = tryParseJson(candidate);
  96. if (parsed !== undefined) {
  97. return normalizeParsedAgentMessage(parsed);
  98. }
  99. }
  100. console.warn(
  101. "[cocostudy] Agent 返回非 JSON,原样传递下游:",
  102. text.slice(0, 120).replace(/\s+/g, " ")
  103. );
  104. return text;
  105. };
  106. const loadAgents = async () => {
  107. if (agent1a && agent1b && agent2a && agent1c) return;
  108. const [detailedRes, errorRes, outlineRes, oneCExplanationRes] = await Promise.all([
  109. axios.get(`https://appapi.cocorobo.cn/api/agents/agent/${AGENT_IDS.detailed}`),
  110. axios.get(`https://appapi.cocorobo.cn/api/agents/agent/${AGENT_IDS.errorAnalysis}`),
  111. axios.get(`https://appapi.cocorobo.cn/api/agents/agent/${AGENT_IDS.lectureOutline}`),
  112. axios.get(`https://appapi.cocorobo.cn/api/agents/agent/${AGENT_IDS.oneCExplanation}`),
  113. ]);
  114. agent2a = detailedRes.data;
  115. agent1a = errorRes.data;
  116. agent1b = outlineRes.data;
  117. agent1c = oneCExplanationRes.data;
  118. };
  119. const updateSpaceAnalyzeStatus = async (spaceId, isl) => {
  120. try {
  121. await callMysqlProc("updatespaceisanalyze", {
  122. sid: spaceId,
  123. isl,
  124. });
  125. console.log("[cocostudy] updatespaceisanalyze", { spaceId, isl });
  126. } catch (err) {
  127. console.error("[cocostudy] updatespaceisanalyze err", err.message || err);
  128. }
  129. };
  130. const callAgentChat = async (agent, message, userId, stepName = "Agent", spaceId) => {
  131. let lastErr;
  132. for (let attempt = 1; attempt <= GRADE_RETRY_TIMES; attempt++) {
  133. try {
  134. const res = await axios.post("https://appapi.cocorobo.cn/api/agentchats/ai_agent_chat", {
  135. id: agent.id,
  136. message,
  137. userId,
  138. model: agent.modelType,
  139. file_ids: [],
  140. sound_url: "",
  141. temperature: 0.1,
  142. top_p: 1,
  143. max_completion_tokens: 4096000,
  144. stream: false,
  145. uid: crypto.randomUUID(),
  146. session_name: agent.id === AGENT_IDS.detailed ? spaceId : crypto.randomUUID(),
  147. });
  148. const reply = res.data?.message;
  149. if (reply == null || reply === "") {
  150. throw new Error("AI 返回内容为空");
  151. }
  152. if (attempt > 1) {
  153. console.log(`[cocostudy] ${stepName} 重试成功 第${attempt}次`);
  154. }
  155. return reply;
  156. } catch (err) {
  157. lastErr = err;
  158. console.warn(
  159. `[cocostudy] ${stepName} 调用失败 第${attempt}/${GRADE_RETRY_TIMES}次`,
  160. err.response?.data || err.message
  161. );
  162. if (attempt < GRADE_RETRY_TIMES) {
  163. await sleep(GRADE_RETRY_DELAY);
  164. }
  165. }
  166. }
  167. if (spaceId) {
  168. await updateSpaceAnalyzeStatus(spaceId, 2);
  169. }
  170. throw lastErr;
  171. };
  172. const normalizeQuestionData = (questions, workData) => {
  173. return questions.map((item) => {
  174. const next = { ...item };
  175. next.userAnswer = workData.data?.[item.id] ?? "";
  176. next.userScore = workData.score?.[item.id] ?? 0;
  177. return next;
  178. });
  179. };
  180. const buildQuizSummary = (testRow, workRow, questions) => {
  181. const totalScore = questions.reduce((pre, cur) => pre + (cur.score || 0), 0);
  182. const userScore = Object.values(workRow.score || {}).reduce(
  183. (pre, cur) => pre + Number(cur || 0),
  184. 0
  185. );
  186. const totalQuestions = questions.length || 1;
  187. const wrongQuestions = questions.filter((item) => item.userScore != item.score).length;
  188. const correctRate = `${(((totalQuestions - wrongQuestions) / totalQuestions) * 100).toFixed(0)}%`;
  189. return {
  190. 试卷名称: testRow.name,
  191. 学科: testRow.subname,
  192. 年级: testRow.graname,
  193. 章节: testRow.chapters,
  194. 试卷总分: totalScore,
  195. 试卷得分: userScore,
  196. 试卷正确率: correctRate,
  197. 试卷总题目数: totalQuestions,
  198. 错题数量: wrongQuestions,
  199. 错题列表: questions,
  200. };
  201. };
  202. // 生成错题分析
  203. async function getRidData(userId, testId, spaceId) {
  204. await loadAgents();
  205. const res = await callMysqlProc("getCocostudyTestData", {
  206. uid: userId,
  207. tid: testId,
  208. });
  209. const workRow = res?.[0]?.[0];
  210. const testRow = res?.[1]?.[0];
  211. if (!workRow?.work || !testRow?.testJson) {
  212. throw new Error(`getCocostudyTestData 数据不完整 testId=${testId}`);
  213. }
  214. const workData = JSON.parse(workRow.work);
  215. const questions = normalizeQuestionData(JSON.parse(testRow.testJson), workData);
  216. const quizSummary = buildQuizSummary(testRow, workData, questions);
  217. console.log("[cocostudy] 开始错题分析", { userId, testId, spaceId });
  218. await generate1a(quizSummary, userId, spaceId);
  219. }
  220. async function generate1a(quizSummary, userId, spaceId) {
  221. const raw = await callAgentChat(
  222. agent1a,
  223. `题目数据:\n${JSON.stringify(quizSummary)}`,
  224. userId,
  225. "错因分析",
  226. spaceId
  227. );
  228. const errorAnalysis = parseAgentJsonMessage(raw);
  229. await generate1b(quizSummary, errorAnalysis, userId, spaceId);
  230. }
  231. async function generate1b(quizSummary, errorAnalysis, userId, spaceId) {
  232. const lectureResponse = await callAgentChat(
  233. agent1b,
  234. `==\n## 统计性信息如下:\n${JSON.stringify(quizSummary)}\n\n==\n\n## 错题深度分析如下:\n${JSON.stringify(errorAnalysis)}`,
  235. userId,
  236. "讲义大纲",
  237. spaceId
  238. );
  239. const lectureOutline = parseAgentJsonMessage(lectureResponse);
  240. await generate1c(quizSummary, errorAnalysis, lectureOutline, userId, spaceId);
  241. }
  242. async function generate1c(quizSummary, errorAnalysis, lectureOutline, userId, spaceId) {
  243. const oneCExplanation = await callAgentChat(
  244. agent1c,
  245. `==\n## 统计性信息如下:\n${JSON.stringify(quizSummary)}\n\n==\n\n## 错题深度分析如下:\n${JSON.stringify(errorAnalysis)}\n\n==\n\n## 讲义大纲如下:\n${JSON.stringify(lectureOutline)}`,
  246. userId,
  247. "讲解生成",
  248. spaceId
  249. );
  250. await generatedetailedExplanation(quizSummary, errorAnalysis, lectureOutline, oneCExplanation, userId, spaceId);
  251. }
  252. async function generatedetailedExplanation(quizSummary, errorAnalysis, lectureOutline, oneCExplanation, userId, spaceId) {
  253. const quizSummaryString = JSON.stringify(quizSummary).replace(/\\(?![\\"/])/g, "\\\\");
  254. const errorAnalysisString = JSON.stringify(errorAnalysis).replace(/\\(?![\\"/])/g, "\\\\");
  255. const lectureOutlineString = JSON.stringify(lectureOutline).replace(/\\(?![\\"/])/g, "\\\\");
  256. const oneCExplanationString = String(oneCExplanation ?? "");
  257. const displayContent = `==\n## 该学生本次进行的试卷的统计性信息为:\n${quizSummaryString}\n\n==\n\n## 针对该试卷中,学生错题的深度分析如下:\n${errorAnalysisString}\n\n==\n\n## 推荐你的讲解顺序如下:\n${lectureOutlineString}\n\n==\n\n现在,请按照讲解顺序,开始为学生进行讲解。` ;
  258. const answer = await callAgentChat(agent2a, displayContent, userId, "详细讲解", spaceId);
  259. await insertChat(answer, spaceId, userId);
  260. let json = {
  261. quizSummary: quizSummary,
  262. errorAnalysis: errorAnalysis,
  263. lectureOutline: lectureOutline,
  264. detailedExplanation: oneCExplanation,
  265. }
  266. try {
  267. await callMysqlProc("updatecocostudySpacejson", {
  268. id: spaceId,
  269. json: encodeURIComponent(JSON.stringify(json)),
  270. });
  271. } catch (err) {
  272. console.error("[cocostudy] updatecocostudySpacejson err", err.message || err);
  273. }
  274. await updateSpaceAnalyzeStatus(spaceId, 1);
  275. console.log("[cocostudy] 错题分析完成", { userId, spaceId });
  276. }
  277. async function insertChat(answer, spaceId, userId) {
  278. const params = {
  279. userId,
  280. userName: "系统",
  281. groupId: spaceId,
  282. answer: encodeURIComponent(answer),
  283. problem: encodeURIComponent(""),
  284. file_id: "",
  285. session_name: spaceId,
  286. alltext: answer,
  287. type: "chat",
  288. reasoning_content: "",
  289. jsonData: "{}",
  290. };
  291. const res = await axios.post("https://gpt4.cocorobo.cn/insert_chat", params);
  292. console.log("[cocostudy] insert_chat res", res.data);
  293. return res;
  294. }
  295. // 定时任务:每10分钟触发一次
  296. // schedule.scheduleJob("*/10 * * * *", async () => {
  297. // try {
  298. // await runAutoScoSafe();
  299. // } catch (error) {
  300. // console.error("[cocostudy] 定时任务异常", error.message || error);
  301. // }
  302. // });
  303. // 手动触发:GET/POST /api/cocostudy/autosco(暂不开放)
  304. // router.all("/autosco", async (req, res) => {
  305. // try {
  306. // const result = await runAutoScoSafe();
  307. // res.json({ code: 200, msg: "全部处理完成", data: result });
  308. // } catch (err) {
  309. // res.status(500).json({ code: 500, msg: err.message || "自动批改失败" });
  310. // }
  311. // });
  312. module.exports = router;
  313. module.exports.getRidData = getRidData;