Files
ClassHelper/packages/server/src/modules/ai/ai.service.ts
T
LoveChengke 66be3a63b1 feat(authz): 重写权限模板分配与班级授权边界
拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。
补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。
重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。
增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。

验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
2026-10-31 09:40:23 +08:00

421 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PermissionKey } from '@classhelper/shared';
import { assertAdmin, assertPermission, classIdsFor } from '../../lib/authz/index.js';
import { activeStudentWhere } from '../../lib/students.js';
import {
dayKeyLocal,
type AiActionKind,
type AiAuditDto,
type AiInterpretResult,
type AiSettingsDto,
} from '@classhelper/shared';
import { z } from 'zod';
import { getAccessibleClassIds, assertClassAccess } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { decryptSecret, encryptSecret } from '../../lib/secrets.js';
import { SETTING_KEYS, getSetting, getSettings, setSetting } from '../../lib/settings.js';
import { createHomeworkSchema } from '../homeworks/homeworks.schemas.js';
import { createNotificationSchema } from '../notifications/notifications.schemas.js';
import type { AiInterpretInput, UpdateAiSettingsInput } from './ai.schemas.js';
/**
* 大模型助手:**只解析、不落库**。
*
* 设计边界(照 approved 计划):
* 1. 模型只输出结构化意图(`{action, params, confidence}`),服务端用**与手动入口同一份 zod**
* 二次校验后返回预览;真正落库由前端确认后调既有端点(`POST /homeworks` 等),
* 于是权限断言、上课时段紧急通知保护全部自动复用(AGENTS.md 的现行工程约定);
* 2. **密钥只进不出**:`AppSetting` 里存的是 `encryptSecret()` 的密文,任何 GET 都不回传;
* 3. `provider: 'mock'` 用确定性规则离线解析 —— 自动化验收**绝不访问外网**(§5 第 52 条),
* 真实模型走 `provider: 'openai'` 时的 `{baseUrl}/chat/completions`。
*/
const DEFAULT_TIMEOUT_MS = 20_000;
/** 与手动入口同源的参数校验(助手不得放宽任何字段约束) */
const homeworkParamsSchema = createHomeworkSchema.pick({
classId: true,
courseId: true,
title: true,
content: true,
assignDate: true,
});
const notificationParamsSchema = createNotificationSchema.pick({
classId: true,
title: true,
content: true,
priority: true,
});
const callParamsSchema = z.object({
classId: z.string().min(1),
studentId: z.string().min(1).optional(),
studentName: z.string().trim().max(40).optional(),
message: z.string().trim().max(200).optional(),
});
const modelReplySchema = z.object({
action: z.enum(['create_homework', 'create_notification', 'call_student', 'unknown']),
params: z.record(z.string(), z.unknown()).default({}),
confidence: z.number().min(0).max(1).optional(),
reason: z.string().max(200).optional(),
});
export async function getAiSettings(): Promise<AiSettingsDto> {
const values = await getSettings([
SETTING_KEYS.aiEnabled,
SETTING_KEYS.aiProvider,
SETTING_KEYS.aiBaseUrl,
SETTING_KEYS.aiModel,
SETTING_KEYS.aiApiKey,
SETTING_KEYS.aiSystemPromptExtra,
]);
const apiKey = decryptSecret(values[SETTING_KEYS.aiApiKey] ?? '');
return {
enabled: values[SETTING_KEYS.aiEnabled] === 'true',
provider: values[SETTING_KEYS.aiProvider] === 'mock' ? 'mock' : 'openai',
baseUrl: values[SETTING_KEYS.aiBaseUrl] ?? '',
model: values[SETTING_KEYS.aiModel] ?? '',
hasApiKey: apiKey.length > 0,
keyHint: apiKey.length >= 4 ? apiKey.slice(-4) : '',
systemPromptExtra: values[SETTING_KEYS.aiSystemPromptExtra] ?? '',
};
}
export async function updateAiSettings(
user: TokenPayload,
input: UpdateAiSettingsInput,
): Promise<AiSettingsDto> {
assertAdmin(user);
if (input.enabled !== undefined) await setSetting(SETTING_KEYS.aiEnabled, input.enabled ? 'true' : 'false');
if (input.provider !== undefined) await setSetting(SETTING_KEYS.aiProvider, input.provider);
if (input.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim());
if (input.model !== undefined) await setSetting(SETTING_KEYS.aiModel, input.model.trim());
if (input.systemPromptExtra !== undefined) {
await setSetting(SETTING_KEYS.aiSystemPromptExtra, input.systemPromptExtra.trim());
}
if (input.apiKey !== undefined) {
// 传 null = 清除;传字符串 = 换成新密钥(空串按清除处理)
await setSetting(SETTING_KEYS.aiApiKey, input.apiKey ? encryptSecret(input.apiKey.trim()) : '');
}
logger.info(`大模型设置已更新(操作人=${user.username},密钥${input.apiKey ? '已更新' : '未改'})`);
return getAiSettings();
}
interface AssistantContext {
classes: { id: string; name: string }[];
courses: { id: string; name: string }[];
}
async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> {
if (classId) await assertClassAccess(user, classId, 'ai:use');
await assertPermission(user, 'ai:use');
const candidates = new Set(
(
await Promise.all(
['notification:publish', 'homework:write', 'call:create'].map((key) =>
classIdsFor(user, key as PermissionKey),
),
)
).flat(),
);
const classIds = (await getAccessibleClassIds(user, 'ai:use')).filter((id) => candidates.has(id));
const classes = await prisma.class.findMany({
where: {
id: { in: classIds },
...(user.role !== 'ADMIN'
? { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] }
: {}),
},
select: { id: true, name: true },
orderBy: { name: 'asc' },
take: 80,
});
const courses =
classId && classIds.includes(classId)
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
: [];
return { classes, courses };
}
function buildSystemPrompt(context: AssistantContext, extra: string): string {
return [
'你是班级管理系统的指令解析器。把老师的一句中文转成严格 JSON,不要输出任何解释。',
'可选动作(action):create_homework(布置作业)、create_notification(发通知)、call_student(叫学生来)、unknown(看不懂)。',
'输出格式:{"action":"…","params":{…},"confidence":0~1}。',
'create_homework 的 params:{classId, courseId?, title, content, assignDate?(YYYY-MM-DD)}。',
'create_notification 的 params:{classId, title, content, priority?(LOW|NORMAL|HIGH|URGENT)}。',
'call_student 的 params:{classId, studentName, message?}。',
`今天日期:${dayKeyLocal(new Date())}。`,
`可用班级:${context.classes.map((item) => `${item.name}(${item.id})`).join('、') || '无'}。`,
context.courses.length
? `候选班级的科目:${context.courses.map((item) => `${item.name}(${item.id})`).join('、')}。`
: '未指定班级时 courseId 留空。',
'信息不足时把 confidence 压低并在 reason 里说明缺什么;不要编造不存在于上面的班级/科目 id。',
extra ? `学校额外要求:${extra}` : '',
]
.filter(Boolean)
.join('\n');
}
/** 离线确定性解析(provider=mock):只做关键词判断,供自动化与"没配模型时试跑"用 */
function mockInterpret(text: string, context: AssistantContext): z.infer<typeof modelReplySchema> {
// 优先用句子里**真正提到的**班级(「给2026级1班布置作业」);都没提到才退回按名称升序的第一个。
// (曾经只取第一个,结果本机取到了已归档的 2023级1班,与句子里说的班级无关。)
const mentioned = context.classes.find((item) => text.includes(item.name));
const classId = mentioned?.id ?? context.classes[0]?.id ?? '';
if (!classId) return { action: 'unknown', params: {}, confidence: 0.1, reason: '当前账号没有可发布的班级' };
const course = context.courses.find((item) => text.includes(item.name));
if (/作业|布置|卷子|练习|预习|背诵/.test(text)) {
return {
action: 'create_homework',
params: {
classId,
courseId: course?.id ?? null,
title: text.length <= 40 ? text : `${text.slice(0, 38)}…`,
content: text,
assignDate: dayKeyLocal(new Date()),
},
confidence: 0.55,
};
}
if (/通知|告知|提醒|传达/.test(text)) {
return {
action: 'create_notification',
params: {
classId,
title: text.length <= 40 ? text : `${text.slice(0, 38)}…`,
content: text,
priority: /紧急|立即|马上/.test(text) ? 'URGENT' : 'NORMAL',
},
confidence: 0.55,
};
}
if (/叫|找|来一趟|办公室/.test(text)) {
const matched = /\S{2,4}(?=同学|来|到)/.exec(text);
return {
action: 'call_student',
params: { classId, studentName: matched?.[0] ?? '', message: text },
confidence: 0.4,
reason: '离线示例解析只按关键词猜测学生姓名',
};
}
return { action: 'unknown', params: {}, confidence: 0.2, reason: '没听懂要做什么' };
}
async function callOpenAi(
input: AiInterpretInput,
extension: { baseUrl: string; model: string; apiKey: string; prompt: string },
): Promise<z.infer<typeof modelReplySchema>> {
let response: Response;
try {
response = await fetch(`${extension.baseUrl.replace(/\/+$/, '')}/chat/completions`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${extension.apiKey}`,
},
body: JSON.stringify({
model: extension.model || 'gpt-4o-mini',
temperature: 0.2,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: extension.prompt },
{ role: 'user', content: input.text },
],
}),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
} catch (error) {
throw new ApiError(
504,
'AI_TIMEOUT',
`无法连接大模型服务:${error instanceof Error ? error.message : String(error)}`,
);
}
if (!response.ok) {
throw ApiError.badRequest(`大模型服务返回 HTTP ${response.status}(请检查服务地址、模型名与密钥)`);
}
const body = (await response.json()) as { choices?: { message?: { content?: string } }[] };
const content = body.choices?.[0]?.message?.content ?? '';
try {
return modelReplySchema.parse(JSON.parse(content));
} catch {
logger.warn(`大模型返回无法解析:${content.slice(0, 200)}`);
throw new ApiError(502, 'AI_INVALID_OUTPUT', '大模型返回的内容不是可用的指令,请换一种说法');
}
}
/** 把校验通过的参数解析成"给人看的预览"(班级/科目/学生名都换成中文) */
async function buildPreview(
action: AiActionKind,
params: Record<string, unknown>,
): Promise<AiInterpretResult['preview']> {
const warnings: string[] = [];
const classId = typeof params.classId === 'string' ? params.classId : '';
const classRecord = classId
? await prisma.class.findUnique({ where: { id: classId }, select: { name: true } })
: null;
if (action === 'create_homework') {
const courseId = typeof params.courseId === 'string' ? params.courseId : '';
const course = courseId
? await prisma.course.findUnique({ where: { id: courseId }, select: { name: true } })
: null;
if (!course) warnings.push('没有指定科目:只有班主任/管理员能发不指定科目的作业');
return {
summary: `给${classRecord?.name ?? '未知班级'}布置作业:${String(params.title ?? '')}`,
className: classRecord?.name ?? null,
courseName: course?.name ?? null,
warnings,
};
}
if (action === 'create_notification') {
return {
summary: `给${classRecord?.name ?? '未知班级'}发通知:${String(params.title ?? '')}`,
className: classRecord?.name ?? null,
warnings,
};
}
if (action === 'call_student') {
const studentName = typeof params.studentName === 'string' ? params.studentName : '';
const student =
studentName && classId
? await prisma.student.findFirst({
where: { classId, name: studentName, ...activeStudentWhere() },
select: { id: true, name: true },
})
: null;
if (!student) warnings.push(studentName ? `没找到学生「${studentName}」` : '没识别出学生姓名');
return {
summary: `叫${student?.name ?? (studentName || '某位同学')}到老师这里`,
className: classRecord?.name ?? null,
studentName: student?.name ?? (studentName || null),
warnings,
};
}
return { summary: '没听懂要做什么,请换一种说法', warnings: ['助手没有解析出可用动作'] };
}
/**
* POST /api/ai/interpret - 解析一句中文,返回**待确认**的结构化动作。
* 绝不写业务表;只写一条 `AiAudit`(谁在什么时候让助手做了什么)。
*/
export async function interpret(user: TokenPayload, input: AiInterpretInput): Promise<AiInterpretResult> {
const settings = await getAiSettings();
if (!settings.enabled) {
throw new ApiError(400, 'AI_NOT_CONFIGURED', '助手功能还没开启,请管理员在「大模型设置」里打开');
}
if (settings.provider === 'openai' && (!settings.hasApiKey || !settings.baseUrl)) {
throw new ApiError(
400,
'AI_NOT_CONFIGURED',
'还没配置大模型服务地址或密钥,请管理员在「大模型设置」里填写',
);
}
const context = await resolveContext(user, input.classId);
const reply =
settings.provider === 'mock'
? mockInterpret(input.text, context)
: await callOpenAi(input, {
baseUrl: settings.baseUrl,
model: settings.model,
apiKey: decryptSecret(await getSetting(SETTING_KEYS.aiApiKey)),
prompt: buildSystemPrompt(context, settings.systemPromptExtra),
});
// 用与手动入口同一份 zod 再校验一次:模型给的东西**不可信**
let action: AiActionKind = 'unknown';
let params: Record<string, unknown> = {};
const warnings: string[] = [];
try {
if (reply.action === 'create_homework') {
const parsed = homeworkParamsSchema.parse({
...reply.params,
classId: reply.params.classId ?? input.classId,
});
action = 'create_homework';
params = parsed as unknown as Record<string, unknown>;
} else if (reply.action === 'create_notification') {
const parsed = notificationParamsSchema.parse({
...reply.params,
classId: reply.params.classId ?? input.classId,
});
action = 'create_notification';
params = parsed as unknown as Record<string, unknown>;
} else if (reply.action === 'call_student') {
const parsed = callParamsSchema.parse({
...reply.params,
classId: reply.params.classId ?? input.classId,
});
action = 'call_student';
params = parsed as unknown as Record<string, unknown>;
}
} catch (error) {
warnings.push('助手给出的参数不完整,请手动补全后再发布');
logger.warn(`助手参数校验失败:${error instanceof Error ? error.message : String(error)}`);
}
if (typeof params.classId === 'string') {
await assertClassAccess(user, params.classId, 'ai:use');
if (typeof params.courseId === 'string') {
const course = await prisma.course.findFirst({
where: { id: params.courseId, classId: params.classId },
select: { id: true },
});
if (!course) throw ApiError.badRequest('科目不属于所选班级');
}
}
const preview = await buildPreview(action, params);
preview.warnings = [...preview.warnings, ...warnings];
if (settings.provider === 'mock') preview.warnings.push('当前是离线示例解析(未配置真实模型),请仔细核对');
await prisma.aiAudit.create({
data: {
userId: user.sub,
prompt: input.text,
intent: action,
// 审计只记录解析,不代表前端确认后的业务操作已执行。
status: action === 'unknown' ? 'unrecognized' : 'interpreted',
message: preview.summary,
rawReply: JSON.stringify(reply).slice(0, 2000),
},
});
return {
action,
params,
preview,
confidence: reply.confidence ?? 0.5,
needConfirm: true,
rawReply: null,
};
}
/** GET /api/ai/audit - 助手使用审计(最近 N 条) */
export async function listAudit(limit = 50): Promise<AiAuditDto[]> {
const rows = await prisma.aiAudit.findMany({ orderBy: { createdAt: 'desc' }, take: limit });
// `AiAudit.userId` 是普通字符串列(schema 里没有建 User 关系),所以姓名另外查一次
const userIds = [...new Set(rows.map((row) => row.userId))];
const users = userIds.length
? await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })
: [];
return rows.map((row) => ({
id: row.id,
userId: row.userId,
userName: users.find((user) => user.id === row.userId)?.name ?? '',
prompt: row.prompt,
intent: row.intent,
status: row.status,
message: row.message,
createdAt: row.createdAt.toISOString(),
}));
}