Repository navigation
Expand file tree
/
Copy pathschema.ts
More file actions
207 lines (187 loc) · 7.99 KB
/
Copy pathschema.ts
File metadata and controls
207 lines (187 loc) · 7.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
import { z } from 'zod';
import { sanitizeFigureSvg } from '../figure-svg';
import { parseRecognizedGrade } from '@/lib/question/question-grade';
import { normalizeQuestionType } from '@/lib/question/question-type';
import type { QuestionType } from '@/lib/question/question-type';
/**
* Zod schema for validating AI-parsed questions
* Ensures type safety and business rule compliance
*/
export const ParsedQuestionSchema = z.object({
questionText: z.string().min(1, "题目文本不能为空"),
answerText: z.string().min(1, "答案不能为空"),
analysis: z.string().min(1, "解析不能为空"),
wrongAnswerText: z.string().optional().default(""),
mistakeAnalysis: z.string().optional().default(""),
mistakeStatus: z.enum(["not_attempted", "wrong_attempt", "unknown"]).optional().default("unknown"),
subject: z.enum([
"数学", "科学", "物理", "化学", "生物",
"英语", "语文", "历史", "地理",
"政治", "其他"
]),
knowledgePoints: z.array(z.string()).max(5, "知识点最多 5 个"),
requiresImage: z.boolean().optional().default(false), // 题目是否依赖图片(如几何题)
difficultyStars: z.number().int().min(1).max(5).nullable().optional(), // 难度星级 1~5,AI 未给出时为空
questionType: z.string().nullable().optional(), // 题型 key,AI 未给出或无法识别时为空(保存时经 normalizeQuestionType 规范化)
gradeSemester: z.string().max(100).nullable().optional(), // 题目年级学期,AI 无法判断时为空
hasFigure: z.boolean().optional(), // 题目是否带配图
figureSvg: z.string().nullable().optional(), // AI 重绘的 SVG 配图(已净化)
source: z.string().trim().max(200).nullish(), // 用户填写的错题来源(如「期中试卷」),AI 不输出
});
/**
* Type inference from Zod schema
* Use this type instead of manually defining ParsedQuestion
*/
export type ParsedQuestionFromSchema = z.infer<typeof ParsedQuestionSchema>;
/**
* Validates and parses AI response JSON
* @param data - Raw JSON data from AI
* @returns Validated ParsedQuestion object
* @throws ZodError if validation fails
*/
export function validateParsedQuestion(data: unknown): ParsedQuestionFromSchema {
return ParsedQuestionSchema.parse(data);
}
/**
* Safe validation that returns success/error object
* @param data - Raw JSON data from AI
*/
export function safeParseParsedQuestion(data: unknown) {
return ParsedQuestionSchema.safeParse(data);
}
/**
* AI 掌握概率评估结果:probability 为 0~100 的百分数
*/
export const MasteryEvaluationSchema = z.object({
probability: z.coerce.number().min(0).max(100),
reason: z.string().trim().min(1).max(500),
});
/**
* 过滤推理模型在回复文本中输出的 <think> / <thinking> 思考过程块
*/
export function stripThinkingTags(text: string): string {
return text.replace(/<(think|thinking)\b[^>]*>[\s\S]*?<\/\1>/gi, '').trim();
}
/**
* 从 AI 文本回复中提取 JSON 对象(兼容 markdown 代码块和前后多余文本)
*/
export function extractJsonObject(text: string): unknown {
let jsonStr = stripThinkingTags(text);
const blockMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
if (blockMatch) {
jsonStr = blockMatch[1].trim();
}
const start = jsonStr.indexOf('{');
const end = jsonStr.lastIndexOf('}');
if (start !== -1 && end > start) {
jsonStr = jsonStr.substring(start, end + 1);
}
return JSON.parse(jsonStr);
}
/** 识别失败保留空年级,格式错误仍按 AI 返回异常处理。 */
export function parseGradeRecognition(text: string): string | null {
const parsed = z.object({ gradeSemester: z.string().nullable() }).parse(extractJsonObject(text));
return parseRecognizedGrade(parsed.gradeSemester);
}
export const AnswerGradingSchema = z.object({
correct: z.union([z.boolean(), z.enum(['true', 'false']).transform((value) => value === 'true')]),
reason: z.string().trim().min(1).max(500),
});
/**
* 解析 AI 判题回复
*/
export function parseAnswerGrading(text: string): { correct: boolean; reason: string } {
return AnswerGradingSchema.parse(extractJsonObject(text));
}
/**
* 解析 AI 掌握概率评估回复,返回 0~1 的概率
*/
export function parseMasteryEvaluation(text: string): { probability: number; reason: string } {
const parsed = MasteryEvaluationSchema.parse(extractJsonObject(text));
return {
probability: Math.round(parsed.probability) / 100,
reason: parsed.reason,
};
}
/**
* 规范化 AI 输出的学科名,不在学科列表内时返回 undefined
*/
export function normalizeSubject(value: string | null | undefined): ParsedQuestionFromSchema['subject'] | undefined {
const parsed = ParsedQuestionSchema.shape.subject.safeParse(value?.trim());
return parsed.success ? parsed.data : undefined;
}
/** 每题最多选取的知识点数量 */
export const MAX_KNOWLEDGE_TAGS = 5;
/** 难度星级范围 */
export const MIN_DIFFICULTY_STARS = 1;
export const MAX_DIFFICULTY_STARS = 5;
/**
* 解析 AI 给出的题型:接受标准 key 或中英文别名,无法识别时返回 null。
* 绝不因此字段导致整体解析失败。
*/
export function parseQuestionType(value: unknown): QuestionType | null {
return normalizeQuestionType(value);
}
/**
* 解析 AI 给出的难度星级(数字、"3"、"3 星"、"★★★" 均可),无法识别或越界时返回 null
*/
export function parseDifficultyStars(value: unknown): number | null {
let stars: number;
if (typeof value === 'number') {
stars = value;
} else if (typeof value === 'string') {
const text = value.trim();
const starCount = (text.match(/[★⭐]/gu) || []).length;
const digits = text.match(/\d+(\.\d+)?/);
stars = starCount > 0 && !digits ? starCount : digits ? Number(digits[0]) : NaN;
} else {
return null;
}
if (!Number.isFinite(stars)) return null;
const rounded = Math.round(stars);
return rounded >= MIN_DIFFICULTY_STARS && rounded <= MAX_DIFFICULTY_STARS ? rounded : null;
}
export interface TagSuggestionResult {
tags: string[];
difficultyStars: number | null;
/** AI 判断的题型 key,未给出或无法识别时为空 */
questionType: QuestionType | null;
}
/**
* 解析 AI 选取的知识点(默认只保留可选清单中存在的标签;allowCustom 时保留 AI 自行命名的名称,
* 清单内名称优先排在前面)、难度星级和题型;去重且最多 5 个
*/
export function parseTagSuggestion(text: string, allowedTags: string[], allowCustom = false): TagSuggestionResult {
const parsed = z.object({
tags: z.array(z.string()),
difficultyStars: z.unknown().optional(),
questionType: z.unknown().optional(),
}).parse(extractJsonObject(text));
const allowed = new Set(allowedTags);
const known: string[] = [];
const created: string[] = [];
for (const tag of parsed.tags.map((value) => value.trim())) {
if (!tag || known.includes(tag) || created.includes(tag)) continue;
if (allowed.has(tag)) known.push(tag);
else if (allowCustom) created.push(tag);
}
// 复用已有标签优先,让同一错题本的知识点名称逐步收敛
const result = allowCustom ? [...known, ...created] : known;
return {
tags: result.slice(0, MAX_KNOWLEDGE_TAGS),
difficultyStars: parseDifficultyStars(parsed.difficultyStars),
questionType: parseQuestionType(parsed.questionType),
};
}
export interface FigureResult {
hasFigure: boolean;
figureSvg: string | null;
}
/**
* 解析 AI 输出的 <has_figure> 与 <figure_svg>:SVG 经净化,无效时为空;
* 画出了有效配图时视为有配图
*/
export function parseFigure(hasFigureRaw: string | null | undefined, figureSvgRaw: string | null | undefined): FigureResult {
const figureSvg = sanitizeFigureSvg(figureSvgRaw);
return { hasFigure: hasFigureRaw?.trim().toLowerCase() === 'true' || figureSvg !== null, figureSvg };
}