import "server-only" import { env } from "@/env.mjs" import { createAiChatCompletion, getAiErrorMessage } from "@/shared/lib/ai" import { GRADING_ASSIST_SYSTEM_PROMPT, LESSON_CONTENT_SYSTEM_PROMPT, QUESTION_VARIANT_SYSTEM_PROMPT, SIMILAR_QUESTION_SYSTEM_PROMPT, WEAKNESS_ANALYSIS_SYSTEM_PROMPT, CHILD_SUMMARY_SYSTEM_PROMPT, STUDY_PATH_SYSTEM_PROMPT, } from "./prompt-templates" import { withAiTracking } from "./usage-tracker" import { GradingSuggestionSchema, LessonContentResultSchema, QuestionVariantResultSchema, SimilarQuestionListSchema, WeaknessAnalysisResultSchema, ChildSummaryResultSchema, StudyPathResultSchema, } from "../schema" import type { AiChatMessage, AiChatOptions, AiChatResult, AiService, GradingInput, GradingSuggestion, LessonContentInput, LessonContentResult, QuestionVariantInput, QuestionVariantResult, SimilarQuestionInput, SimilarQuestionResult, WeaknessAnalysisInput, WeaknessAnalysisResult, ChildSummaryInput, ChildSummaryResult, StudyPathInput, StudyPathResult, } from "../types" // --------------------------------------------------------------------------- // JSON 提取工具(从 AI 返回文本中提取 JSON) // --------------------------------------------------------------------------- const extractBalancedJsonSegment = (value: string): string | null => { const startBrace = value.indexOf("{") const startBracket = value.indexOf("[") const start = startBrace === -1 ? startBracket : startBracket === -1 ? startBrace : Math.min(startBrace, startBracket) if (start === -1) return null const opening = value[start] const closing = opening === "{" ? "}" : "]" let depth = 0 let inString = false let escaped = false for (let i = start; i < value.length; i += 1) { const char = value[i] if (inString) { if (escaped) { escaped = false } else if (char === "\\") { escaped = true } else if (char === '"') { inString = false } continue } if (char === '"') { inString = true continue } if (char === opening) { depth += 1 continue } if (char === closing) { depth -= 1 if (depth === 0) { return value.slice(start, i + 1) } } } return null } const tryParseJson = (value: string): unknown | null => { try { return JSON.parse(value) } catch { return null } } const extractJson = (raw: string): unknown => { const trimmed = raw.trim() const candidates: string[] = [] const fencedMatches = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)] if (fencedMatches.length > 0) { candidates.push(...fencedMatches.map((match) => (match[1] ?? "").trim())) } candidates.push(trimmed) for (const candidate of candidates) { const direct = tryParseJson(candidate) if (direct !== null) return direct const segment = extractBalancedJsonSegment(candidate) if (!segment) continue const parsed = tryParseJson(segment) if (parsed !== null) return parsed } throw new Error("Invalid AI response: cannot parse JSON") } // --------------------------------------------------------------------------- // AiService 实现 // --------------------------------------------------------------------------- const DEFAULT_MODEL = () => String(env.AI_MODEL ?? "gpt-4o-mini") const buildChatMessages = ( systemPrompt: string, userContent: string ): AiChatMessage[] => [ { role: "system", content: systemPrompt }, { role: "user", content: userContent }, ] const callAi = async ( messages: AiChatMessage[], options?: AiChatOptions ): Promise<{ content: string; model?: string; tokenUsage?: number }> => { const result = await createAiChatCompletion({ messages, model: options?.model ?? DEFAULT_MODEL(), temperature: options?.temperature ?? 0.3, ...(typeof options?.maxTokens === "number" ? { maxTokens: options.maxTokens } : {}), ...(options?.providerId ? { providerId: options.providerId } : {}), }) const tokenUsage = result.usage && typeof result.usage === "object" && "total_tokens" in result.usage ? Number((result.usage as unknown as Record).total_tokens ?? 0) : undefined return { content: result.content, tokenUsage } } /** * 默认 AI 服务实现 * * 封装 shared/lib/ai 的底层 SDK 调用,提供业务语义化接口。 * 所有业务模块通过此服务调用 AI,不直接 import shared/lib/ai。 */ export class DefaultAiService implements AiService { constructor(private readonly userId: string) {} async chat( messages: AiChatMessage[], options?: AiChatOptions ): Promise { return withAiTracking(this.userId, "chat", options?.providerId, async () => { const { content, tokenUsage } = await callAi(messages, { ...options, temperature: options?.temperature ?? 0.7, }) return { result: { content, usage: null }, tokenUsage } }) } async suggestSimilarQuestions( input: SimilarQuestionInput ): Promise { return withAiTracking(this.userId, "similar_question", undefined, async () => { const count = input.count ?? 3 const userLines = [ `Question Type: ${input.questionType}`, input.subject ? `Subject: ${input.subject}` : "", input.knowledgePointIds?.length ? `Knowledge Points: ${input.knowledgePointIds.join(", ")}` : "", `Generate ${count} similar questions.`, `Original Question:\n${input.questionText}`, ].filter((line) => line.length > 0) const { content } = await callAi( buildChatMessages(SIMILAR_QUESTION_SYSTEM_PROMPT, userLines.join("\n\n")), { temperature: 0.5, maxTokens: 3000 } ) const parsed = extractJson(content) const list = parsed && typeof parsed === "object" && "questions" in parsed ? (parsed as Record).questions : parsed const validated = SimilarQuestionListSchema.safeParse(list) if (!validated.success) return { result: [] } return { result: validated.data } }) } async suggestGrading(input: GradingInput): Promise { return withAiTracking(this.userId, "grading_assist", undefined, async () => { const userLines = [ `Question Type: ${input.questionType}`, `Max Score: ${input.maxScore}`, input.subject ? `Subject: ${input.subject}` : "", `Question:\n${input.questionText}`, `Student Answer:\n${input.studentAnswer}`, input.correctAnswer ? `Correct Answer:\n${input.correctAnswer}` : "", ].filter((line) => line.length > 0) const { content } = await callAi( buildChatMessages(GRADING_ASSIST_SYSTEM_PROMPT, userLines.join("\n\n")), { temperature: 0.2, maxTokens: 1000 } ) const parsed = extractJson(content) const validated = GradingSuggestionSchema.safeParse(parsed) if (!validated.success) { return { result: { suggestedScore: 0, confidence: 0, feedback: "AI grading unavailable", reasoning: "AI response format invalid", }, } } const data = validated.data return { result: { suggestedScore: Math.min(Math.max(data.suggestedScore, 0), input.maxScore), confidence: data.confidence, feedback: data.feedback, reasoning: data.reasoning, }, } }) } async generateLessonContent( input: LessonContentInput ): Promise { return withAiTracking(this.userId, "lesson_content", undefined, async () => { const userLines = [ `Topic: ${input.topic}`, `Content Type: ${input.contentType}`, input.subject ? `Subject: ${input.subject}` : "", input.grade ? `Grade: ${input.grade}` : "", input.additionalContext ? `Additional Context:\n${input.additionalContext}` : "", ].filter((line) => line.length > 0) const { content } = await callAi( buildChatMessages(LESSON_CONTENT_SYSTEM_PROMPT, userLines.join("\n\n")), { temperature: 0.7, maxTokens: 4000 } ) const parsed = extractJson(content) const validated = LessonContentResultSchema.safeParse(parsed) if (!validated.success) { return { result: { title: input.topic, content: content, }, } } return { result: validated.data } }) } async generateQuestionVariant( input: QuestionVariantInput ): Promise { return withAiTracking(this.userId, "question_variant", undefined, async () => { const userLines = [ `Variant Type: ${input.variantType}`, input.subject ? `Subject: ${input.subject}` : "", `Original Question:\n${JSON.stringify(input.originalQuestion, null, 2)}`, ].filter((line) => line.length > 0) const { content } = await callAi( buildChatMessages(QUESTION_VARIANT_SYSTEM_PROMPT, userLines.join("\n\n")), { temperature: 0.6, maxTokens: 2000 } ) const parsed = extractJson(content) const validated = QuestionVariantResultSchema.safeParse(parsed) if (!validated.success) { throw new Error("AI question variant format invalid") } return { result: validated.data } }) } async analyzeWeakness( input: WeaknessAnalysisInput ): Promise { return withAiTracking(this.userId, "weakness_analysis", undefined, async () => { const userLines = [ `Student ID: ${input.studentId}`, input.subjectId ? `Subject ID: ${input.subjectId}` : "", `Error Items (${input.errorItems.length}):`, JSON.stringify( input.errorItems.map((item) => ({ questionText: item.questionText, questionType: item.questionType, errorCount: item.errorCount, masteryLevel: item.masteryLevel, })), null, 2 ), ].filter((line) => line.length > 0) const { content } = await callAi( buildChatMessages(WEAKNESS_ANALYSIS_SYSTEM_PROMPT, userLines.join("\n\n")), { temperature: 0.3, maxTokens: 2000 } ) const parsed = extractJson(content) const validated = WeaknessAnalysisResultSchema.safeParse(parsed) if (!validated.success) { return { result: { weakAreas: [], studyPlan: "Analysis unavailable", recommendedResources: [], }, } } return { result: validated.data } }) } async generateChildSummary(input: ChildSummaryInput): Promise { return withAiTracking(this.userId, "child_summary", undefined, async () => { // PII 最小化:不传学生真实姓名,用 ID 替代(COPPA/FERPA 合规) const userLines = [ `Student ID: ${input.studentId}`, input.grade ? `Grade: ${input.grade}` : "", input.recentGrades && input.recentGrades.length > 0 ? `Recent Grades:\n${JSON.stringify(input.recentGrades, null, 2)}` : "", input.attendanceRate !== undefined ? `Attendance Rate: ${(input.attendanceRate * 100).toFixed(1)}%` : "", input.errorBookSummary ? `Error Book Summary:\n${JSON.stringify(input.errorBookSummary, null, 2)}` : "", input.homeworkCompletionRate !== undefined ? `Homework Completion Rate: ${(input.homeworkCompletionRate * 100).toFixed(1)}%` : "", ].filter((line) => line.length > 0) const { content } = await callAi( buildChatMessages(CHILD_SUMMARY_SYSTEM_PROMPT, userLines.join("\n\n")), { temperature: 0.4, maxTokens: 2000 } ) const parsed = extractJson(content) const validated = ChildSummaryResultSchema.safeParse(parsed) if (!validated.success) { return { result: { overallAssessment: "Unable to generate summary at this time.", strengths: [], areasForImprovement: [], familyTutoringSuggestions: [], nextSteps: [], }, } } return { result: validated.data } }) } async recommendStudyPath(input: StudyPathInput): Promise { return withAiTracking(this.userId, "study_path", undefined, async () => { const userLines = [ `Student ID: ${input.studentId}`, input.subject ? `Subject: ${input.subject}` : "", input.currentMastery && input.currentMastery.length > 0 ? `Current Mastery:\n${JSON.stringify(input.currentMastery, null, 2)}` : "", input.learningGoal ? `Learning Goal: ${input.learningGoal}` : "", ].filter((line) => line.length > 0) // 知识图谱上下文注入(V3:对标 Squirrel AI 纳米级知识图谱) if (input.knowledgeGraph && input.knowledgeGraph.nodes.length > 0) { const graphLines = [ "Knowledge Graph:", "Nodes (id | name | level | mastery 0-100):", ...input.knowledgeGraph.nodes.map( (n) => ` ${n.id} | ${n.name} | L${n.level} | ${n.masteryLevel ?? "unassessed"}` ), "Prerequisite edges (from -> to, meaning 'from' must be mastered before 'to'):", ...input.knowledgeGraph.edges.map((e) => ` ${e.from} -> ${e.to}`), ] userLines.push(graphLines.join("\n")) } const { content } = await callAi( buildChatMessages(STUDY_PATH_SYSTEM_PROMPT, userLines.join("\n\n")), { temperature: 0.5, maxTokens: 2000 } ) const parsed = extractJson(content) const validated = StudyPathResultSchema.safeParse(parsed) if (!validated.success) { return { result: { currentLevel: "Analysis unavailable", learningPath: [], summary: "Unable to generate learning path at this time.", motivation: "Keep learning!", }, } } return { result: validated.data } }) } } /** * 创建 AI 服务实例 * * 在 Server Action 中调用,传入当前用户 ID。 * 测试时可替换为 mock 实现。 */ export const createAiService = (userId: string): AiService => new DefaultAiService(userId) /** * 安全执行 AI 调用,捕获异常并返回错误消息 */ export const safeAiCall = async ( fn: () => Promise ): Promise<{ ok: true; data: T } | { ok: false; message: string }> => { try { const data = await fn() return { ok: true, data } } catch (error) { return { ok: false, message: getAiErrorMessage(error) } } }