project init

This commit is contained in:
2026-02-06 02:17:59 +03:00
commit b9d9b9ed17
129 changed files with 22835 additions and 0 deletions

View File

@ -0,0 +1,393 @@
import OpenAI from 'openai';
import { z } from 'zod';
// Logger helper
const log = {
info: (msg: string, data?: unknown) => {
console.log(`\x1b[36m[AI Provider]\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : '');
},
success: (msg: string, data?: unknown) => {
console.log(`\x1b[32m[AI Provider] ✓\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : '');
},
error: (msg: string, error?: unknown) => {
console.error(`\x1b[31m[AI Provider] ✗\x1b[0m ${msg}`, error);
},
debug: (msg: string, data?: unknown) => {
if (process.env.DEBUG === 'true') {
console.log(`\x1b[90m[AI Provider DEBUG]\x1b[0m ${msg}`, data ? JSON.stringify(data, null, 2) : '');
}
},
request: (method: string, model: string) => {
console.log(`\x1b[33m[AI Provider] →\x1b[0m Calling ${method} with model: ${model}`);
},
response: (method: string, tokens?: { prompt?: number; completion?: number }) => {
const tokenInfo = tokens ? ` (tokens: ${tokens.prompt || '?'}/${tokens.completion || '?'})` : '';
console.log(`\x1b[33m[AI Provider] ←\x1b[0m ${method} completed${tokenInfo}`);
},
};
// Course outline schema for structured output
const CourseOutlineSchema = z.object({
title: z.string(),
description: z.string(),
chapters: z.array(
z.object({
title: z.string(),
description: z.string(),
lessons: z.array(
z.object({
title: z.string(),
estimatedMinutes: z.number(),
})
),
})
),
estimatedTotalHours: z.number(),
difficulty: z.enum(['beginner', 'intermediate', 'advanced']),
tags: z.array(z.string()),
});
const ClarifyingQuestionItemSchema = z.object({
id: z.string().optional(),
question: z.string().optional(),
text: z.string().optional(),
type: z.enum(['single_choice', 'multiple_choice', 'text']).optional(),
options: z.array(z.string()).optional(),
required: z.boolean().optional(),
}).refine((q) => typeof (q.question ?? q.text) === 'string', { message: 'question or text required' });
const ClarifyingQuestionsSchema = z.object({
questions: z.array(ClarifyingQuestionItemSchema).transform((items) =>
items.map((q, i) => ({
id: q.id ?? `q_${i}`,
question: (q.question ?? q.text ?? '').trim() || `Вопрос ${i + 1}`,
type: (q.type ?? 'text') as 'single_choice' | 'multiple_choice' | 'text',
options: q.options,
required: q.required ?? true,
}))
),
});
const LessonContentSchema = z.object({
content: z.object({
type: z.literal('doc'),
content: z.array(z.any()),
}),
});
export type CourseOutline = z.infer<typeof CourseOutlineSchema>;
export type ClarifyingQuestions = z.infer<typeof ClarifyingQuestionsSchema>;
export type LessonContent = z.infer<typeof LessonContentSchema>;
export class OpenRouterProvider {
private client: OpenAI;
private maxRetries = 3;
private retryDelay = 2000;
constructor() {
const apiKey = process.env.OPENROUTER_API_KEY;
const baseURL = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
log.info('Initializing OpenRouter provider...');
log.info(`Base URL: ${baseURL}`);
log.info(`API Key: ${apiKey ? apiKey.substring(0, 15) + '...' : 'NOT SET'}`);
if (!apiKey) {
log.error('OPENROUTER_API_KEY environment variable is required');
throw new Error('OPENROUTER_API_KEY environment variable is required');
}
this.client = new OpenAI({
baseURL,
apiKey,
timeout: 120000, // 2 minute timeout
maxRetries: 3,
defaultHeaders: {
'HTTP-Referer': process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
'X-Title': 'CourseCraft',
},
});
log.success('OpenRouter provider initialized');
}
private async withRetry<T>(fn: () => Promise<T>, operation: string): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
return await fn();
} catch (error: any) {
lastError = error;
log.error(`${operation} attempt ${attempt}/${this.maxRetries} failed: ${error.message}`);
if (attempt < this.maxRetries) {
const delay = this.retryDelay * attempt;
log.info(`Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
async generateClarifyingQuestions(
prompt: string,
model: string
): Promise<ClarifyingQuestions> {
log.request('generateClarifyingQuestions', model);
log.info(`User prompt: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
const systemPrompt = `Ты - эксперт по созданию образовательных курсов.
Пользователь хочет создать курс. Твоя задача - задать уточняющие вопросы,
чтобы лучше понять его потребности и создать максимально релевантный курс.
Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса:
- Короткий (2-4 главы, введение в тему)
- Средний (4-7 глав, хорошее покрытие)
- Длинный / полный (6-12 глав, глубокое погружение)
Остальные вопросы: целевая аудитория, глубина материала, специфические темы.
Ответь в формате JSON.`;
return this.withRetry(async () => {
const response = await this.client.chat.completions.create({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `Запрос пользователя: "${prompt}"` },
],
response_format: { type: 'json_object' },
temperature: 0.7,
});
log.response('generateClarifyingQuestions', {
prompt: response.usage?.prompt_tokens,
completion: response.usage?.completion_tokens,
});
const content = response.choices[0].message.content;
log.debug('Raw AI response:', content);
if (!content) {
log.error('Empty response from AI');
throw new Error('Empty response from AI');
}
const parsed = JSON.parse(content);
const validated = ClarifyingQuestionsSchema.parse(parsed);
log.success(`Generated ${validated.questions.length} clarifying questions`);
log.info('Questions:', validated.questions.map(q => q.question));
return validated;
}, 'generateClarifyingQuestions');
}
async generateCourseOutline(
prompt: string,
answers: Record<string, string | string[]>,
model: string
): Promise<CourseOutline> {
log.request('generateCourseOutline', model);
log.info(`Prompt: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
log.info('User answers:', answers);
const systemPrompt = `Ты - эксперт по созданию образовательных курсов.
Создай структуру курса на основе запроса пользователя и его ответов на уточняющие вопросы.
ОБЪЁМ КУРСА (соблюдай строго по ответам пользователя):
- Если пользователь выбрал короткий курс / введение: 24 главы, в каждой 24 урока. estimatedTotalHours: 28.
- Если средний курс: 47 глав, в каждой 35 уроков. estimatedTotalHours: 820.
- Если длинный / полный курс: 612 глав, в каждой 48 уроков. estimatedTotalHours: 1540.
- Если объём не указан — предложи средний (46 глав по 35 уроков).
Укажи примерное время на каждый урок (estimatedMinutes: 1045). estimatedTotalHours = сумма уроков.
Определи сложность (beginner|intermediate|advanced). Добавь релевантные теги.
Ответь в формате JSON со структурой:
{
"title": "Название курса",
"description": "Подробное описание курса",
"chapters": [
{
"title": "Название главы",
"description": "Описание главы",
"lessons": [
{ "title": "Название урока", "estimatedMinutes": 25 }
]
}
],
"estimatedTotalHours": 20,
"difficulty": "beginner|intermediate|advanced",
"tags": ["тег1", "тег2"]
}`;
const userMessage = `Запрос: "${prompt}"
Ответы пользователя на уточняющие вопросы:
${Object.entries(answers)
.map(([key, value]) => `- ${key}: ${Array.isArray(value) ? value.join(', ') : value}`)
.join('\n')}`;
return this.withRetry(async () => {
const response = await this.client.chat.completions.create({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
response_format: { type: 'json_object' },
temperature: 0.7,
});
log.response('generateCourseOutline', {
prompt: response.usage?.prompt_tokens,
completion: response.usage?.completion_tokens,
});
const content = response.choices[0].message.content;
log.debug('Raw AI response:', content);
if (!content) {
log.error('Empty response from AI');
throw new Error('Empty response from AI');
}
const parsed = JSON.parse(content);
const validated = CourseOutlineSchema.parse(parsed);
const totalLessons = validated.chapters.reduce((acc, ch) => acc + ch.lessons.length, 0);
log.success(`Generated course outline: "${validated.title}"`);
log.info(`Structure: ${validated.chapters.length} chapters, ${totalLessons} lessons`);
log.info(`Difficulty: ${validated.difficulty}, Est. hours: ${validated.estimatedTotalHours}`);
return validated;
}, 'generateCourseOutline');
}
async generateLessonContent(
courseTitle: string,
chapterTitle: string,
lessonTitle: string,
context: {
difficulty: string;
targetAudience?: string;
},
model: string
): Promise<LessonContent> {
log.request('generateLessonContent', model);
log.info(`Generating content for: "${lessonTitle}" (Chapter: "${chapterTitle}")`);
const systemPrompt = `Ты - эксперт по созданию образовательного контента.
Пиши содержание урока СРАЗУ в форматированном виде — в формате TipTap JSON. Каждый блок контента должен быть правильным узлом TipTap (heading, paragraph, bulletList, orderedList, blockquote, codeBlock, image).
ФОРМАТИРОВАНИЕ (используй обязательно):
- Заголовки: { "type": "heading", "attrs": { "level": 1|2|3 }, "content": [{ "type": "text", "text": "..." }] }
- Параграфы: { "type": "paragraph", "content": [{ "type": "text", "text": "..." }] } — для выделения используй "marks": [{ "type": "bold" }] или [{ "type": "italic" }]
- Списки: bulletList > listItem > paragraph; orderedList > listItem > paragraph
- Цитаты: { "type": "blockquote", "content": [{ "type": "paragraph", "content": [...] }] }
- Код: { "type": "codeBlock", "attrs": { "language": "javascript"|"python"|"text" }, "content": [{ "type": "text", "text": "код" }] }
- Mermaid-диаграммы: { "type": "codeBlock", "attrs": { "language": "mermaid" }, "content": [{ "type": "text", "text": "graph LR\\n A --> B" }] } — вставляй где уместно (схемы, процессы, связи)
- Картинки не генерируй (src нельзя выдумывать); если нужна иллюстрация — опиши в тексте или предложи место для изображения
ОБЪЁМ: зависит от темы. Короткий урок: 300600 слов (35 блоков). Средний: 6001200 слов. Длинный: 12002500 слов. Структура: заголовок, введение, 24 секции с подзаголовками, примеры/код/списки, резюме.
Уровень: ${context.difficulty}. ${context.targetAudience ? `Аудитория: ${context.targetAudience}` : ''}
Ответь только валидным JSON:
{
"content": {
"type": "doc",
"content": [
{ "type": "heading", "attrs": { "level": 1 }, "content": [{ "type": "text", "text": "Заголовок" }] },
{ "type": "paragraph", "content": [{ "type": "text", "text": "Текст..." }] }
]
}
}`;
return this.withRetry(async () => {
const response = await this.client.chat.completions.create({
model,
messages: [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: `Курс: "${courseTitle}"
Глава: "${chapterTitle}"
Урок: "${lessonTitle}"
Создай содержание урока сразу в TipTap JSON: заголовки (heading), параграфы (paragraph), списки (bulletList/orderedList), цитаты (blockquote), блоки кода (codeBlock) и при необходимости Mermaid-диаграммы (codeBlock с "language": "mermaid"). Объём — по теме урока (короткий/средний/длинный).`,
},
],
response_format: { type: 'json_object' },
temperature: 0.7,
max_tokens: 8000,
});
log.response('generateLessonContent', {
prompt: response.usage?.prompt_tokens,
completion: response.usage?.completion_tokens,
});
const content = response.choices[0].message.content;
log.debug('Raw AI response:', content?.substring(0, 200) + '...');
if (!content) {
log.error('Empty response from AI');
throw new Error('Empty response from AI');
}
const parsed = JSON.parse(content);
const validated = LessonContentSchema.parse(parsed);
log.success(`Generated content for lesson: "${lessonTitle}"`);
return validated;
}, `generateLessonContent: ${lessonTitle}`);
}
async rewriteText(
text: string,
instruction: string,
model: string
): Promise<string> {
const response = await this.client.chat.completions.create({
model,
messages: [
{
role: 'system',
content:
'Ты - редактор образовательного контента. Переработай текст согласно инструкции пользователя.',
},
{
role: 'user',
content: `Исходный текст:
"${text}"
Инструкция: ${instruction}
Верни только переработанный текст без дополнительных пояснений.`,
},
],
temperature: 0.7,
});
return response.choices[0].message.content || text;
}
async chat(
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
model: string
): Promise<string> {
const response = await this.client.chat.completions.create({
model,
messages,
temperature: 0.7,
});
return response.choices[0].message.content || '';
}
}