Files
openmaic/OpenMAIC/lib/course-framework/generate-framework.ts
2026-08-16 14:58:47 +08:00

211 lines
7.1 KiB
TypeScript
Raw Permalink 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.

// 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 415 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<string>;
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<string, unknown>;
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<string, unknown>)
: 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<string, unknown>;
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<CourseFrameworkGenerationResult> {
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<unknown>(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 415, 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 };
}