feat(ai): 新增 AI 模块并集成至备课/错题集/试卷/改题四大业务场景
- 新增 src/modules/ai 独立模块,遵循三层架构(actions → services → shared/lib/ai) - 通过 AiClientProvider + useAiClient 实现 React Context 依赖注入,业务组件零直接 import - 6 个 Server Actions 均调用 requirePermission() 权限校验,返回 ActionState<T> - withAiTracking 统一埋点,覆盖 chat/similar_question/grading_assist/lesson_content/question_variant/weakness_analysis - 集成场景:作业批改 AiGradingAssist、错题集 AiErrorBookAnalysis、备课 AiLessonContentGenerator、试卷 AiQuestionVariantGenerator - 全量 i18n(en/zh-CN ai.json),Error Boundary + Skeleton 边界处理 - 同步架构图 004/005,新增审计报告 ai-module-audit-report.md
This commit is contained in:
346
src/modules/ai/services/ai-service.ts
Normal file
346
src/modules/ai/services/ai-service.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
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,
|
||||
} from "./prompt-templates"
|
||||
import { withAiTracking } from "./usage-tracker"
|
||||
import {
|
||||
GradingSuggestionSchema,
|
||||
LessonContentResultSchema,
|
||||
QuestionVariantResultSchema,
|
||||
SimilarQuestionListSchema,
|
||||
WeaknessAnalysisResultSchema,
|
||||
} from "../schema"
|
||||
import type {
|
||||
AiChatMessage,
|
||||
AiChatOptions,
|
||||
AiChatResult,
|
||||
AiService,
|
||||
GradingInput,
|
||||
GradingSuggestion,
|
||||
LessonContentInput,
|
||||
LessonContentResult,
|
||||
QuestionVariantInput,
|
||||
QuestionVariantResult,
|
||||
SimilarQuestionInput,
|
||||
SimilarQuestionResult,
|
||||
WeaknessAnalysisInput,
|
||||
WeaknessAnalysisResult,
|
||||
} 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<string, unknown>).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<AiChatResult> {
|
||||
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<SimilarQuestionResult[]> {
|
||||
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<string, unknown>).questions
|
||||
: parsed
|
||||
const validated = SimilarQuestionListSchema.safeParse(list)
|
||||
if (!validated.success) return { result: [] }
|
||||
return { result: validated.data }
|
||||
})
|
||||
}
|
||||
|
||||
async suggestGrading(input: GradingInput): Promise<GradingSuggestion> {
|
||||
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<LessonContentResult> {
|
||||
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<QuestionVariantResult> {
|
||||
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<WeaknessAnalysisResult> {
|
||||
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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 AI 服务实例
|
||||
*
|
||||
* 在 Server Action 中调用,传入当前用户 ID。
|
||||
* 测试时可替换为 mock 实现。
|
||||
*/
|
||||
export const createAiService = (userId: string): AiService =>
|
||||
new DefaultAiService(userId)
|
||||
|
||||
/**
|
||||
* 安全执行 AI 调用,捕获异常并返回错误消息
|
||||
*/
|
||||
export const safeAiCall = async <T>(
|
||||
fn: () => Promise<T>
|
||||
): 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) }
|
||||
}
|
||||
}
|
||||
154
src/modules/ai/services/prompt-templates.ts
Normal file
154
src/modules/ai/services/prompt-templates.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* AI Prompt 模板
|
||||
*
|
||||
* 集中管理所有业务场景的 Prompt,便于版本管理与调优。
|
||||
* 所有 Prompt 使用英文以获得最佳模型兼容性,业务文本通过 user message 注入。
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 相似题推荐
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SIMILAR_QUESTION_SYSTEM_PROMPT = [
|
||||
"You are an expert K12 education question generator.",
|
||||
"Given a question, generate similar practice questions that test the same knowledge points.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "questions": [',
|
||||
" {",
|
||||
' "text": "question text",',
|
||||
' "type": "single_choice | multiple_choice | judgment | text",',
|
||||
' "difficulty": 3,',
|
||||
' "options": [{ "id": "A", "text": "option text" }],',
|
||||
' "answer": "correct answer",',
|
||||
' "explanation": "brief explanation"',
|
||||
" }",
|
||||
" ]",
|
||||
"}",
|
||||
"Rules:",
|
||||
"- Generate 1-5 similar questions based on the count parameter.",
|
||||
"- Keep the same knowledge points but vary the context and numbers.",
|
||||
"- For choice questions, always include 4 options.",
|
||||
"- For text questions, omit options and include the answer.",
|
||||
"- Difficulty should be 1-5, matching the original.",
|
||||
"Never output placeholders like ..., [...], or {...}.",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI 辅助批改
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const GRADING_ASSIST_SYSTEM_PROMPT = [
|
||||
"You are an expert K12 teacher assistant for grading subjective questions.",
|
||||
"Given a question, the student's answer, and the correct answer (if available),",
|
||||
"evaluate the student's answer and suggest a score with feedback.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "suggestedScore": 4,',
|
||||
' "confidence": 0.85,',
|
||||
' "feedback": "constructive feedback in the student\'s language",',
|
||||
' "reasoning": "why this score was assigned"',
|
||||
"}",
|
||||
"Rules:",
|
||||
"- suggestedScore must be between 0 and maxScore.",
|
||||
"- confidence is between 0 and 1 (higher means more certain).",
|
||||
"- feedback should be encouraging and specific.",
|
||||
"- If the answer is completely wrong, suggestedScore should be 0.",
|
||||
"- If the answer is partially correct, give partial credit.",
|
||||
"- Consider alternative correct answers if the question allows.",
|
||||
"Never output placeholders.",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 备课内容生成
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const LESSON_CONTENT_SYSTEM_PROMPT = [
|
||||
"You are an expert K12 instructional designer.",
|
||||
"Generate teaching content based on the given topic and context.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "title": "content title",',
|
||||
' "content": "detailed content in markdown format",',
|
||||
' "metadata": { "duration": "15 min", "materials": ["..."] }',
|
||||
"}",
|
||||
"Rules:",
|
||||
"- Content should be age-appropriate for the specified grade.",
|
||||
"- For 'activity' type: generate an interactive classroom activity.",
|
||||
"- For 'assessment' type: generate a formative assessment.",
|
||||
"- For 'question' type: generate discussion questions.",
|
||||
"- For 'material' type: generate teaching material outline.",
|
||||
"- Content should align with the subject curriculum.",
|
||||
"Never output placeholders.",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 题目变体生成
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const QUESTION_VARIANT_SYSTEM_PROMPT = [
|
||||
"You are an expert K12 question variation generator.",
|
||||
"Given an original question, generate a variant based on the specified type.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "text": "variant question text",',
|
||||
' "type": "single_choice | multiple_choice | judgment | text",',
|
||||
' "difficulty": 3,',
|
||||
' "options": [{ "id": "A", "text": "option", "isCorrect": true }],',
|
||||
' "answer": "correct answer",',
|
||||
' "explanation": "brief explanation"',
|
||||
"}",
|
||||
"Variant types:",
|
||||
"- same_knowledge_point: test the same concept with different context.",
|
||||
"- different_difficulty: make it easier or harder.",
|
||||
"- different_format: change the question type (e.g., choice to text).",
|
||||
"Rules:",
|
||||
"- For choice questions, always include 4 options with exactly one correct.",
|
||||
"- Difficulty must be 1-5.",
|
||||
"Never output placeholders.",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 薄弱点分析
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const WEAKNESS_ANALYSIS_SYSTEM_PROMPT = [
|
||||
"You are an expert K12 learning analyst.",
|
||||
"Analyze the student's error patterns and identify weak areas.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "weakAreas": [',
|
||||
" {",
|
||||
' "area": "knowledge area name",',
|
||||
' "severity": "high | medium | low",',
|
||||
' "suggestion": "specific improvement suggestion"',
|
||||
" }",
|
||||
" ],",
|
||||
' "studyPlan": "personalized study plan summary",',
|
||||
' "recommendedResources": ["resource 1", "resource 2"]',
|
||||
"}",
|
||||
"Rules:",
|
||||
"- Identify 2-5 weak areas based on error frequency and mastery level.",
|
||||
"- severity: high = mastery < 2, medium = mastery 2-3, low = mastery 3-4.",
|
||||
"- Suggestions should be actionable and specific.",
|
||||
"- Study plan should be concise (3-5 sentences).",
|
||||
"- Recommended resources can be topic names or study strategies.",
|
||||
"Never output placeholders.",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 通用 JSON 提取提示词(用于修复 AI 返回的无效 JSON)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const JSON_REPAIR_SYSTEM_PROMPT = [
|
||||
"You are a JSON repair engine.",
|
||||
"Fix the provided invalid JSON into valid JSON only.",
|
||||
"Keep the original structure and values as much as possible.",
|
||||
"Do not use placeholders such as ... or [...].",
|
||||
"Return JSON only without markdown.",
|
||||
].join("\n")
|
||||
83
src/modules/ai/services/usage-tracker.ts
Normal file
83
src/modules/ai/services/usage-tracker.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import "server-only"
|
||||
|
||||
import { trackEvent, type EventName } from "@/shared/lib/track-event"
|
||||
|
||||
export type AiUsageEvent = {
|
||||
userId: string
|
||||
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis"
|
||||
providerId?: string
|
||||
model?: string
|
||||
success: boolean
|
||||
durationMs: number
|
||||
tokenUsage?: number
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
|
||||
chat: "ai.chat",
|
||||
similar_question: "ai.similar_question",
|
||||
grading_assist: "ai.grading_assist",
|
||||
lesson_content: "ai.lesson_content",
|
||||
question_variant: "ai.question_variant",
|
||||
weakness_analysis: "ai.weakness_analysis",
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 使用埋点
|
||||
*
|
||||
* 记录每次 AI 调用的元数据,用于监控、成本分析与异常排查。
|
||||
* 非阻塞,失败不影响主流程。
|
||||
*/
|
||||
export const trackAiUsage = (event: AiUsageEvent): void => {
|
||||
const eventName = AI_EVENT_MAP[event.capability]
|
||||
void trackEvent({
|
||||
event: eventName,
|
||||
userId: event.userId,
|
||||
targetType: event.capability,
|
||||
properties: {
|
||||
providerId: event.providerId,
|
||||
model: event.model,
|
||||
success: event.success,
|
||||
durationMs: event.durationMs,
|
||||
tokenUsage: event.tokenUsage,
|
||||
errorMessage: event.errorMessage,
|
||||
},
|
||||
}).catch(() => {
|
||||
// 静默失败:埋点不应影响业务流程
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 测量 AI 调用耗时并自动埋点
|
||||
*/
|
||||
export const withAiTracking = async <T>(
|
||||
userId: string,
|
||||
capability: AiUsageEvent["capability"],
|
||||
providerId: string | undefined,
|
||||
fn: () => Promise<{ result: T; model?: string; tokenUsage?: number }>
|
||||
): Promise<T> => {
|
||||
const start = Date.now()
|
||||
try {
|
||||
const { result, model, tokenUsage } = await fn()
|
||||
trackAiUsage({
|
||||
userId,
|
||||
capability,
|
||||
providerId,
|
||||
model,
|
||||
success: true,
|
||||
durationMs: Date.now() - start,
|
||||
tokenUsage,
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
trackAiUsage({
|
||||
userId,
|
||||
capability,
|
||||
providerId,
|
||||
success: false,
|
||||
durationMs: Date.now() - start,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user