Files
NextEdu/src/modules/exams/ai-pipeline/index.ts
SpecialX 682d385ee2 fix(dashboard): v3 审计修复 — 数据完整性、i18n、类型安全、死代码清理
P0 修复(严重):
- admin ContentRow 标签与值错配(stats.users→textbooks 等 6 处)
- admin/error.tsx 硬编码中文替换为 useTranslations
- UserGrowthChart 空数据时渲染 EmptyState(userGrowth/homeworkTrend 永远为空数组)

P1 修复(高):
- 新增 admin/dashboard 和 student/dashboard 的 loading.tsx + error.tsx
- 抽取 DashboardLoadingSkeleton 和 DashboardErrorFallback 共享组件,消除 5 套重复文件
- formatDate/formatLongDate 传入用户 locale(admin/teacher/student 共 6 个组件)
- 移除死代码:getCachedAdminDashboard、AvatarImage src={undefined}、TeacherStats isLoading prop
- filterTodaySchedule 改为泛型函数,消除 as 类型断言
- 辅助函数 getStatus/getDueUrgency 新增显式返回类型
- UserGrowthChart 新增 labelKey prop 区分用户增长/作业提交趋势标签

P2 修复(中):
- 4 个组件从客户端转为服务端组件(DashboardGreetingHeader、TeacherQuickActions、TeacherDashboardHeader、StudentDashboardHeader)
- Student dashboard 空状态新增 CTA(viewSchedule、viewAll)
- TeacherHomeworkCard 图标按钮新增 aria-label
- TeacherTodoCard 排序逻辑重写为可读的 if/return 模式

同步更新:
- docs/architecture/005_architecture_data.json 新增 DashboardLoadingSkeleton、DashboardErrorFallback 条目
- 新增 docs/architecture/audit/dashboard-audit-report-v3.md 审计报告
- dashboard.json 新增 6 个 i18n 键(textbooks/chapters/questions/exams/totalAssignments/totalSubmissions)
2026-06-22 18:36:46 +08:00

173 lines
4.9 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.
/**
* AI 试卷生成管线(入口模块)
*
* 本目录由三个子模块组成:
* - parse.ts: Zod schema、JSON 解析、纯转换函数、提示词
* - request.ts: AI 请求构造与发送
* - structure.ts: 结构生成与预览/草稿转换
*
* 本文件负责:
* - 重新导出公共 APIschema、类型、函数
* - 编排高层流程generateAiPreviewData / generateAiCreateDraftFromSource
*/
import { z } from "zod"
import {
AiExamResponseSchema,
AiQuestionSchema,
} from "./parse"
import {
parseQuestionDetail,
requestAiExamDraft,
requestAiExamStructureDraft,
validateExamSourceText,
type SplitQuestionItem,
} from "./request"
import {
buildPreviewPayload,
mapWithConcurrency,
previewToDraft,
splitStructureItems,
} from "./structure"
// ---------------------------------------------------------------------------
// Re-export public schemas & types
// ---------------------------------------------------------------------------
export {
AiGeneratedStructureSchema,
AiGeneratedStructureNodeSchema,
AiInsertQuestionSchema,
AiQuestionSchema,
} from "./parse"
export type {
AiGeneratedQuestion,
AiGeneratedStructureNode,
AiPreviewData,
AiPreviewQuestion,
AiRewriteQuestionData,
QuestionContentResult,
} from "./parse"
// ---------------------------------------------------------------------------
// Re-export public functions
// ---------------------------------------------------------------------------
export { regenerateAiQuestionByInstruction } from "./request"
// ---------------------------------------------------------------------------
// High-level orchestration
// ---------------------------------------------------------------------------
export async function generateAiPreviewData(input: {
title: string
subject?: string
grade?: string
difficulty: number
totalScore: number
durationMin: number
questionCount?: number
sourceText: string
aiProviderId?: string
}) {
const sourceValidation = await validateExamSourceText({
sourceText: input.sourceText,
aiProviderId: input.aiProviderId,
})
if (!sourceValidation.ok) {
return { ok: false as const, message: sourceValidation.message }
}
const structureDraft = await requestAiExamStructureDraft(input)
if (!structureDraft.ok) return structureDraft
const splitItems = splitStructureItems(structureDraft.data)
const limitedItems = typeof input.questionCount === "number" && input.questionCount > 0
? splitItems.slice(0, input.questionCount)
: splitItems
if (limitedItems.length === 0) {
return { ok: false as const, message: "AI returned no questions" }
}
const detailedQuestions = await mapWithConcurrency(limitedItems, 6, (item) => parseQuestionDetail({
item,
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
aiProviderId: input.aiProviderId,
}))
const hasSectionStructure = limitedItems.some((item: SplitQuestionItem) => item.sectionIndex !== null)
const aiParsed: z.infer<typeof AiExamResponseSchema> = hasSectionStructure
? {
title: structureDraft.data.title ?? input.title,
sections: (() => {
const sectionMap = new Map<number, { title: string; questions: z.infer<typeof AiQuestionSchema>[] }>()
limitedItems.forEach((item, index) => {
if (item.sectionIndex === null) return
const existed = sectionMap.get(item.sectionIndex)
const question = detailedQuestions[index]
if (existed) {
existed.questions.push(question)
return
}
sectionMap.set(item.sectionIndex, {
title: item.sectionTitle || `Section ${item.sectionIndex + 1}`,
questions: [question],
})
})
return Array.from(sectionMap.entries())
.sort((a, b) => a[0] - b[0])
.map(([, section]) => section)
})(),
questions: undefined,
}
: {
title: structureDraft.data.title ?? input.title,
questions: detailedQuestions,
sections: undefined,
}
const payload = buildPreviewPayload(aiParsed, input)
return {
ok: true as const,
data: payload,
rawOutput: structureDraft.rawOutput,
}
}
export async function generateAiCreateDraftFromSource(input: {
title: string
subject?: string
grade?: string
difficulty: number
totalScore: number
durationMin: number
questionCount?: number
sourceText: string
aiProviderId?: string
}) {
const preview = await generateAiPreviewData(input)
if (!preview.ok) {
return preview
}
const draft = previewToDraft(preview.data)
return {
ok: true as const,
generated: draft.generated,
structure: draft.structure,
rawOutput: preview.rawOutput,
}
}
export async function generateAiExamDraft(input: {
title?: string
subject?: string
grade?: string
difficulty?: number
totalScore?: number
durationMin?: number
questionCount?: number
sourceText: string
aiProviderId?: string
}) {
return requestAiExamDraft(input)
}