// Large-course mode — Layer 1: course framework generation agent. // // Takes a single requirement (optionally with PDF text and web-search research // context) and produces a CourseFramework: course title, teaching-language // directive, summary, and 4–15 modules. Each module must be a self-contained // teaching unit that the Layer-2 single-courseware agent will turn into a full // courseware. Prompt lives in lib/prompts/templates/course-framework/. import { buildPrompt, PROMPT_IDS } from '@/lib/prompts'; import { parseJsonResponse } from '@openmaic/generation'; import { createLogger } from '@/lib/logger'; import type { CourseFramework } from './types'; const log = createLogger('CourseFramework'); export interface CourseFrameworkInput { requirement: string; pdfText?: string; researchContext?: string; } export interface CourseFrameworkGenerationResult { success: boolean; data?: CourseFramework; error?: string; } export type FrameworkAICallFn = (systemPrompt: string, userPrompt: string) => Promise; export const MIN_COURSE_MODULES = 4; export const MAX_COURSE_MODULES = 15; function readRequiredString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } function readStringArray(value: unknown): string[] | null { if (!Array.isArray(value)) return null; const items = value.map((item) => readRequiredString(item)); return items.some((item) => !item) ? null : items; } function buildUserPrompt(input: CourseFrameworkInput): string { const pdfSection = input.pdfText ? `\n\n## 参考文档(PDF 提取文本)\n\n${input.pdfText.slice(0, 6000)}` : ''; const researchSection = input.researchContext ? `\n\n## 联网研究资料(供参考)\n\n${input.researchContext.slice(0, 8000)}` : ''; return ( buildPrompt(PROMPT_IDS.COURSE_FRAMEWORK, { requirement: input.requirement, pdfSection, researchSection, })?.user ?? '' ); } /** Validate a raw parsed framework; returns a normalized framework or a reason. */ export function validateCourseFramework(raw: unknown): CourseFramework | null { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; const obj = raw as Record; const courseTitle = readRequiredString(obj.courseTitle); const languageDirective = readRequiredString(obj.languageDirective); const targetAudience = readRequiredString(obj.targetAudience); const summary = readRequiredString(obj.summary); const courseGoals = readStringArray(obj.courseGoals); const continuity = obj.continuityContract && typeof obj.continuityContract === 'object' && !Array.isArray(obj.continuityContract) ? (obj.continuityContract as Record) : null; const terminology = readStringArray(continuity?.terminology); const teachingStyle = readRequiredString(continuity?.teachingStyle); const difficultyProgression = readRequiredString(continuity?.difficultyProgression); const assessmentStrategy = readRequiredString(continuity?.assessmentStrategy); if ( !courseTitle || !languageDirective || !targetAudience || !summary || !courseGoals?.length || !terminology?.length || !teachingStyle || !difficultyProgression || !assessmentStrategy ) { return null; } if (courseTitle.length > 60) return null; if (!Array.isArray(obj.modules) || obj.modules.length === 0) return null; if (obj.modules.length < MIN_COURSE_MODULES || obj.modules.length > MAX_COURSE_MODULES) { return null; } const modules = obj.modules.map((m, i) => { if (!m || typeof m !== 'object' || Array.isArray(m)) return null; const mod = m as Record; const title = readRequiredString(mod.title); const description = readRequiredString(mod.description); const objectives = readStringArray(mod.learningObjectives); const generationPrompt = readRequiredString(mod.generationPrompt); const incomingKnowledge = readStringArray(mod.incomingKnowledge); const outgoingKnowledge = readStringArray(mod.outgoingKnowledge); const excludedTopics = readStringArray(mod.excludedTopics); if ( !title || title.length > 60 || !description || !objectives?.length || !generationPrompt || !incomingKnowledge || !outgoingKnowledge?.length || !excludedTopics ) { return null; } const prerequisites = typeof mod.prerequisites === 'string' && mod.prerequisites.trim() ? mod.prerequisites.trim() : undefined; if (typeof mod.estimatedMinutes !== 'number' || !Number.isFinite(mod.estimatedMinutes)) { return null; } const estimatedMinutes = Math.min(Math.max(Math.round(mod.estimatedMinutes), 5), 60); return { index: i + 1, title, description, learningObjectives: objectives, generationPrompt, ...(prerequisites ? { prerequisites } : {}), incomingKnowledge, outgoingKnowledge, excludedTopics, estimatedMinutes, }; }); if (modules.some((m) => m === null)) return null; return { courseTitle, languageDirective, targetAudience, summary, courseGoals, continuityContract: { terminology, teachingStyle, difficultyProgression, assessmentStrategy, }, modules: modules as NonNullable<(typeof modules)[number]>[], }; } /** * Run the Layer-1 framework generation with one retry on parse/validation * failure. The retry re-sends the model's own raw output as a correction hint * so the second call can fix its JSON. */ export async function generateCourseFramework( input: CourseFrameworkInput, aiCall: FrameworkAICallFn, options: { maxAttempts?: number } = {}, ): Promise { const maxAttempts = options.maxAttempts ?? 2; const system = buildPrompt(PROMPT_IDS.COURSE_FRAMEWORK, { requirement: input.requirement, pdfSection: '', researchSection: '', })?.system ?? ''; const userBase = buildUserPrompt(input); let lastError = 'unknown error'; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const user = attempt === 1 ? userBase : `${userBase}\n\n上一次输出无法解析:${lastError}\n请只输出符合要求的 JSON。`; const response = await aiCall(system, user); if (!response.trim()) { lastError = 'empty model response'; continue; } const parsed = parseJsonResponse(response); if (!parsed) { lastError = 'output is not valid JSON'; log.warn(`Course framework attempt ${attempt}/${maxAttempts}: invalid JSON`); continue; } const framework = validateCourseFramework(parsed); if (!framework) { lastError = 'JSON failed validation (module count outside 4–15, or missing course goals, continuity contract, module generation prompt, knowledge boundaries, or duration)'; log.warn(`Course framework attempt ${attempt}/${maxAttempts}: validation failed`); continue; } log.info( `Course framework generated: "${framework.courseTitle}" with ${framework.modules.length} modules`, ); return { success: true, data: framework }; } return { success: false, error: lastError }; }