project init
This commit is contained in:
393
apps/ai-service/src/providers/openrouter.provider.ts
Normal file
393
apps/ai-service/src/providers/openrouter.provider.ts
Normal 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 = `Ты - эксперт по созданию образовательных курсов.
|
||||
Создай структуру курса на основе запроса пользователя и его ответов на уточняющие вопросы.
|
||||
|
||||
ОБЪЁМ КУРСА (соблюдай строго по ответам пользователя):
|
||||
- Если пользователь выбрал короткий курс / введение: 2–4 главы, в каждой 2–4 урока. estimatedTotalHours: 2–8.
|
||||
- Если средний курс: 4–7 глав, в каждой 3–5 уроков. estimatedTotalHours: 8–20.
|
||||
- Если длинный / полный курс: 6–12 глав, в каждой 4–8 уроков. estimatedTotalHours: 15–40.
|
||||
- Если объём не указан — предложи средний (4–6 глав по 3–5 уроков).
|
||||
|
||||
Укажи примерное время на каждый урок (estimatedMinutes: 10–45). 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 нельзя выдумывать); если нужна иллюстрация — опиши в тексте или предложи место для изображения
|
||||
|
||||
ОБЪЁМ: зависит от темы. Короткий урок: 300–600 слов (3–5 блоков). Средний: 600–1200 слов. Длинный: 1200–2500 слов. Структура: заголовок, введение, 2–4 секции с подзаголовками, примеры/код/списки, резюме.
|
||||
|
||||
Уровень: ${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 || '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user