dvc890 commited on
Commit
78c5cf9
·
verified ·
1 Parent(s): 6f8a187

Upload 63 files

Browse files
Files changed (2) hide show
  1. ai-context.js +235 -0
  2. ai-routes.js +14 -8
ai-context.js ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ const {
3
+ User, Student, Score, AttendanceModel, ClassModel,
4
+ LeaveRequestModel, TodoModel, School
5
+ } = require('./models');
6
+
7
+ /**
8
+ * 格式化当前日期
9
+ */
10
+ const getCurrentDateInfo = () => {
11
+ const now = new Date();
12
+ const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
13
+ return `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日 ${days[now.getDay()]}`;
14
+ };
15
+
16
+ /**
17
+ * 构建学生画像上下文 (学生视角)
18
+ */
19
+ async function buildStudentContext(username, schoolId) {
20
+ const student = await Student.findOne({
21
+ $or: [{ studentNo: username }, { name: username }],
22
+ schoolId
23
+ });
24
+
25
+ if (!student) return "无法找到该学生的详细档案。";
26
+
27
+ // 1. 获取近期成绩 (最近10条,让AI掌握更多趋势)
28
+ const recentScores = await Score.find({
29
+ studentNo: student.studentNo,
30
+ schoolId
31
+ }).sort({ _id: -1 }).limit(10);
32
+
33
+ // 2. 获取考勤概况
34
+ const attendanceStats = await AttendanceModel.aggregate([
35
+ { $match: { studentId: student._id.toString() } },
36
+ { $group: { _id: "$status", count: { $sum: 1 } } }
37
+ ]);
38
+ const absentCount = attendanceStats.find(a => a._id === 'Absent')?.count || 0;
39
+ const leaveCount = attendanceStats.find(a => a._id === 'Leave')?.count || 0;
40
+
41
+ // 3. 获取待办事项
42
+ const user = await User.findOne({ username, schoolId });
43
+ const todos = user ? await TodoModel.find({ userId: user._id, isCompleted: false }).limit(5) : [];
44
+
45
+ let prompt = `
46
+ ### 当前用户身份:学生 (个人视图)
47
+ - **姓名**: ${student.name}
48
+ - **班级**: ${student.className}
49
+ - **学号**: ${student.studentNo}
50
+ - **积分(小红花)**: ${student.flowerBalance} 🌺
51
+
52
+ ### 个人学习数据
53
+ `;
54
+
55
+ if (recentScores.length > 0) {
56
+ prompt += `- **近期成绩历史**: ${recentScores.map(s => `${s.courseName}: ${s.score} (${s.type || '考试'})`).join('; ')}\n`;
57
+ // 计算简单平均分
58
+ const avg = (recentScores.reduce((acc, s) => acc + s.score, 0) / recentScores.length).toFixed(1);
59
+ prompt += `- **近期平均分**: ${avg}\n`;
60
+ } else {
61
+ prompt += `- **近期成绩**: 暂无记录\n`;
62
+ }
63
+
64
+ if (absentCount > 0 || leaveCount > 0) {
65
+ prompt += `- **考勤异常**: 本学期缺勤 ${absentCount} 次,请假 ${leaveCount} 次。\n`;
66
+ } else {
67
+ prompt += `- **考勤状况**: 全勤,表现极佳。\n`;
68
+ }
69
+
70
+ if (todos.length > 0) {
71
+ prompt += `- **未完成待办**: ${todos.map(t => t.content).join('; ')}\n`;
72
+ }
73
+
74
+ return prompt;
75
+ }
76
+
77
+ /**
78
+ * 构建教师画像上下文 (增强版 - 全班详情)
79
+ */
80
+ async function buildTeacherContext(username, schoolId) {
81
+ const user = await User.findOne({ username, schoolId });
82
+ if (!user) return "无法找到该教师档案。";
83
+
84
+ const className = user.homeroomClass;
85
+ let prompt = `
86
+ ### 当前用户身份:教师
87
+ - **姓名**: ${user.trueName || username}
88
+ - **任教科目**: ${user.teachingSubject || '未设置'}
89
+ `;
90
+
91
+ if (className) {
92
+ // 1. 获取全班学生列表
93
+ const students = await Student.find({ className, schoolId });
94
+ const studentNos = students.map(s => s.studentNo);
95
+ const studentIds = students.map(s => s._id.toString());
96
+
97
+ // 2. 获取全班考勤统计 (Group by Student)
98
+ const attendanceRaw = await AttendanceModel.aggregate([
99
+ { $match: { studentId: { $in: studentIds }, status: { $in: ['Absent', 'Leave'] } } },
100
+ { $group: { _id: "$studentId", absent: { $sum: { $cond: [{ $eq: ["$status", "Absent"] }, 1, 0] } }, leave: { $sum: { $cond: [{ $eq: ["$status", "Leave"] }, 1, 0] } } } }
101
+ ]);
102
+ const attendanceMap = {};
103
+ attendanceRaw.forEach(a => attendanceMap[a._id] = a);
104
+
105
+ // 3. 获取全班近期成绩 (为了Token效率,我们只取每人最近3次考试或全班最近的100条成绩记录)
106
+ const recentScores = await Score.find({ schoolId, studentNo: { $in: studentNos } }).sort({ _id: -1 }).limit(200);
107
+ const scoreMap = {}; // studentNo -> [Score Obj]
108
+ recentScores.forEach(s => {
109
+ if (!scoreMap[s.studentNo]) scoreMap[s.studentNo] = [];
110
+ scoreMap[s.studentNo].push(s);
111
+ });
112
+
113
+ // 4. 构建“全息花名册”字符串
114
+ let rosterStr = "";
115
+ const failingStudents = [];
116
+
117
+ students.forEach(s => {
118
+ const att = attendanceMap[s._id.toString()] || { absent: 0, leave: 0 };
119
+ const myScores = scoreMap[s.studentNo] || [];
120
+
121
+ // 计算个人概况
122
+ let scoreStr = "暂无成绩";
123
+ let avgScore = 0;
124
+ if (myScores.length > 0) {
125
+ // 只取最近3门
126
+ const latest3 = myScores.slice(0, 3);
127
+ scoreStr = latest3.map(sc => `${sc.courseName}:${sc.score}`).join(', ');
128
+ avgScore = latest3.reduce((a,b)=>a+b.score,0) / latest3.length;
129
+
130
+ // 检查不及格
131
+ if (latest3.some(sc => sc.score < 60)) {
132
+ failingStudents.push(s.name);
133
+ }
134
+ }
135
+
136
+ // 格式: [姓名](座号): 考勤[缺x, 假y], 成绩[科目:分, ...], 积分: z
137
+ rosterStr += `- **${s.name}** (座号:${s.seatNo || '-'}): 考勤[缺${att.absent}/假${att.leave}], 小红花:${s.flowerBalance}, 近期成绩[${scoreStr}]\n`;
138
+ });
139
+
140
+ // 5. 待办审批
141
+ const pendingLeaves = await LeaveRequestModel.find({ className, schoolId, status: 'Pending' }).limit(5);
142
+
143
+ prompt += `- **班级**: ${className} (共${students.length}人)\n`;
144
+
145
+ if (pendingLeaves.length > 0) {
146
+ prompt += `\n### 🔴 紧急待办\n`;
147
+ prompt += `你有 ${pendingLeaves.length} 条请假申请待审批 (申请人: ${pendingLeaves.map(l => l.studentName).join(', ')})。\n`;
148
+ }
149
+
150
+ if (failingStudents.length > 0) {
151
+ prompt += `\n### ⚠️ 学情预警\n`;
152
+ prompt += `以下学生近期有不及格记录,请重点关注: ${failingStudents.join(', ')}\n`;
153
+ }
154
+
155
+ prompt += `\n### 📋 班级学生全息档案 (Roster)\n`;
156
+ prompt += `(这是你班级所有学生的详细数据,当用户询问具体学生时,请在此处检索)\n`;
157
+ prompt += rosterStr;
158
+
159
+ } else {
160
+ prompt += `- **班级**: 目前没有担任班主任,暂无详细学生数据。\n`;
161
+ }
162
+
163
+ return prompt;
164
+ }
165
+
166
+ /**
167
+ * 构建管理员/校长画像上下文
168
+ */
169
+ async function buildAdminContext(role, schoolId) {
170
+ let prompt = `### 当前用户身份:${role === 'PRINCIPAL' ? '校长' : '超级管理员'}\n`;
171
+
172
+ if (role === 'PRINCIPAL' && schoolId) {
173
+ const school = await School.findById(schoolId);
174
+ const totalStudents = await Student.countDocuments({ schoolId });
175
+ const totalTeachers = await User.countDocuments({ schoolId, role: 'TEACHER' });
176
+
177
+ // 今日缺勤详细名单
178
+ const today = new Date().toISOString().split('T')[0];
179
+ const absences = await AttendanceModel.find({ schoolId, date: today, status: { $in: ['Absent', 'Leave'] } });
180
+ const absentNames = absences.map(a => `${a.studentName}(${a.className})`).join(', ');
181
+
182
+ // 全校均分
183
+ const recentScores = await Score.find({ schoolId }).sort({_id:-1}).limit(100);
184
+ let avgScore = 0;
185
+ if (recentScores.length) avgScore = (recentScores.reduce((a,b)=>a+b.score,0)/recentScores.length).toFixed(1);
186
+
187
+ prompt += `- **学校**: ${school ? school.name : '未知'}\n`;
188
+ prompt += `- **宏观数据**: 教师 ${totalTeachers} 人,学生 ${totalStudents} 人,近期全校抽样平均分 ${avgScore}。\n`;
189
+ prompt += `- **今日出勤**: 缺勤/请假 ${absences.length} 人。名单: ${absentNames || '无'}。\n`;
190
+ }
191
+
192
+ return prompt;
193
+ }
194
+
195
+ /**
196
+ * 主入口:构建用户上下文 Prompt
197
+ * @param {string} username - 请求头中的用户名
198
+ * @param {string} role - 请求头中的角色
199
+ * @param {string} schoolId - 请求头中的学校ID
200
+ */
201
+ async function buildUserContext(username, role, schoolId) {
202
+ try {
203
+ const dateStr = getCurrentDateInfo();
204
+ let roleContext = "";
205
+
206
+ if (role === 'STUDENT') {
207
+ roleContext = await buildStudentContext(username, schoolId);
208
+ } else if (role === 'TEACHER') {
209
+ roleContext = await buildTeacherContext(username, schoolId);
210
+ } else if (role === 'ADMIN' || role === 'PRINCIPAL') {
211
+ roleContext = await buildAdminContext(role, schoolId);
212
+ }
213
+
214
+ // 组装最终 System Instruction 片段
215
+ return `
216
+ ---
217
+ 【上下文注入信息 (Context Injection) - 绝密】
218
+ 当前系统时间: ${dateStr}
219
+ 以下是当前用户的核心数据和其管辖范围内的详细档案。
220
+ ${roleContext}
221
+
222
+ 【AI 行为准则】
223
+ 1. 你拥有上述所有数据的“上帝视角”。当老师问“张三的情况”时,请直接从【班级学生全息档案】中提取张三的成绩、考勤和小红花数据进行回答,不要说“我不知道”。
224
+ 2. 回答要具体。例如:不要说“他成绩一般”,要说“他最近数学考了60分,英语考了85分,属于偏科现象”。
225
+ 3. 如果数据中显示学生有缺勤或不及格,请在回答末尾给出具体的教学干预建议。
226
+ 4. 保持语气专业、辅助性强。
227
+ ---
228
+ `;
229
+ } catch (e) {
230
+ console.error("Context build failed:", e);
231
+ return ""; // 失败时降级为空,不影响主流程
232
+ }
233
+ }
234
+
235
+ module.exports = { buildUserContext };
ai-routes.js CHANGED
@@ -3,6 +3,7 @@ const express = require('express');
3
  const router = express.Router();
4
  const OpenAI = require('openai');
5
  const { ConfigModel, User, AIUsageModel } = require('./models');
 
6
 
7
  // ... (Key Management, Usage Tracking, Helpers, Provider Management functions remain same as before)
8
  // Fetch keys from DB + merge with ENV variables
@@ -165,7 +166,6 @@ async function streamOpenRouter(baseParams, res) {
165
 
166
  try {
167
  console.log(`[AI] 🚀 Attempting ${providerLabel} Model: ${modelName} (URL: ${baseURL})`);
168
- // console.log(`[AI] Payload Messages:`, JSON.stringify(messages).substring(0, 200) + "..."); // Debug log
169
 
170
  const stream = await client.chat.completions.create({ model: modelName, messages, stream: true });
171
 
@@ -188,7 +188,6 @@ async function streamOpenRouter(baseParams, res) {
188
  console.log(`[AI] 🔄 Rate limit/Quota for ${modelName}, switching...`);
189
  break; // Switch to next provider/model logic if implemented in loop, here break to next model in loop
190
  }
191
- // For OpenAI client, some errors might be specific
192
  }
193
  }
194
  }
@@ -292,9 +291,6 @@ async function streamContentWithSmartFallback(baseParams, res) {
292
  deprioritizeProvider(provider);
293
  continue;
294
  }
295
- // If it's a hard error (e.g. network), we might also want to switch,
296
- // but strict quota error check usually suffices for fallback logic.
297
- // For robustness, let's allow fallback on most errors in the loop:
298
  continue;
299
  }
300
  }
@@ -320,8 +316,6 @@ router.get('/live-access', checkAIAccess, async (req, res) => {
320
  try {
321
  const keys = await getKeyPool('gemini');
322
  if (keys.length === 0) return res.status(503).json({ error: 'No API keys available' });
323
- // Return the first available key. In a real prod environment, you might issue a short-lived proxy token.
324
- // For this architecture, we return the key to allow direct WebSocket connection.
325
  res.json({ key: keys[0] });
326
  } catch (e) { res.status(500).json({ error: e.message }); }
327
  });
@@ -352,6 +346,12 @@ router.post('/reset-pool', checkAIAccess, (req, res) => {
352
 
353
  router.post('/chat', checkAIAccess, async (req, res) => {
354
  const { text, audio, history } = req.body;
 
 
 
 
 
 
355
  res.setHeader('Content-Type', 'text/event-stream');
356
  res.setHeader('Cache-Control', 'no-cache');
357
  res.setHeader('Connection', 'keep-alive');
@@ -367,9 +367,15 @@ router.post('/chat', checkAIAccess, async (req, res) => {
367
  if (currentParts.length === 0) currentParts.push({ text: "Hello" });
368
  fullContents.push({ role: 'user', parts: currentParts });
369
 
 
 
 
 
 
 
370
  const answerText = await streamContentWithSmartFallback({
371
  contents: fullContents,
372
- config: { systemInstruction: "你是一位友善、耐心且知识渊博的中小学AI助教。请用简洁、鼓励性的语言回答学生的问题。回复支持 Markdown 格式。" }
373
  }, res);
374
 
375
  if (answerText) {
 
3
  const router = express.Router();
4
  const OpenAI = require('openai');
5
  const { ConfigModel, User, AIUsageModel } = require('./models');
6
+ const { buildUserContext } = require('./ai-context');
7
 
8
  // ... (Key Management, Usage Tracking, Helpers, Provider Management functions remain same as before)
9
  // Fetch keys from DB + merge with ENV variables
 
166
 
167
  try {
168
  console.log(`[AI] 🚀 Attempting ${providerLabel} Model: ${modelName} (URL: ${baseURL})`);
 
169
 
170
  const stream = await client.chat.completions.create({ model: modelName, messages, stream: true });
171
 
 
188
  console.log(`[AI] 🔄 Rate limit/Quota for ${modelName}, switching...`);
189
  break; // Switch to next provider/model logic if implemented in loop, here break to next model in loop
190
  }
 
191
  }
192
  }
193
  }
 
291
  deprioritizeProvider(provider);
292
  continue;
293
  }
 
 
 
294
  continue;
295
  }
296
  }
 
316
  try {
317
  const keys = await getKeyPool('gemini');
318
  if (keys.length === 0) return res.status(503).json({ error: 'No API keys available' });
 
 
319
  res.json({ key: keys[0] });
320
  } catch (e) { res.status(500).json({ error: e.message }); }
321
  });
 
346
 
347
  router.post('/chat', checkAIAccess, async (req, res) => {
348
  const { text, audio, history } = req.body;
349
+
350
+ // Extract headers for context building
351
+ const username = req.headers['x-user-username'];
352
+ const userRole = req.headers['x-user-role'];
353
+ const schoolId = req.headers['x-school-id'];
354
+
355
  res.setHeader('Content-Type', 'text/event-stream');
356
  res.setHeader('Cache-Control', 'no-cache');
357
  res.setHeader('Connection', 'keep-alive');
 
367
  if (currentParts.length === 0) currentParts.push({ text: "Hello" });
368
  fullContents.push({ role: 'user', parts: currentParts });
369
 
370
+ // --- NEW: Inject Context ---
371
+ const contextPrompt = await buildUserContext(username, userRole, schoolId);
372
+ const baseSystemInstruction = "你是一位友善、耐心且知识渊博的中小学AI助教。请用简洁、鼓励性的语言回答学生的问题。回复支持 Markdown 格式。";
373
+ const combinedSystemInstruction = `${baseSystemInstruction}\n${contextPrompt}`;
374
+ // ---------------------------
375
+
376
  const answerText = await streamContentWithSmartFallback({
377
  contents: fullContents,
378
+ config: { systemInstruction: combinedSystemInstruction }
379
  }, res);
380
 
381
  if (answerText) {