211 lines
7.1 KiB
TypeScript
211 lines
7.1 KiB
TypeScript
// 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<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 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 };
|
||
}
|