From c809d049feb92623d83b4293945b9f34b518cd47 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 6 Feb 2026 11:32:58 +0000 Subject: [PATCH] feat: certificate page for print, API certificate data, AI prompts for full lessons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Certificate: page /certificate/[courseId] with print-friendly design, GET /certificates/:courseId/data - Buttons 'Получить сертификат' open certificate page instead of data-URL - AI: prompts for longer courses (more chapters/lessons), full detailed lesson content with examples (1000–1500+ words) Co-authored-by: Cursor --- .../src/providers/openrouter.provider.ts | 59 ++++----- .../certificates/certificates.controller.ts | 6 + .../src/certificates/certificates.service.ts | 30 +++-- .../certificate/[courseId]/page.tsx | 117 ++++++++++++++++++ apps/web/src/app/(certificate)/layout.tsx | 7 ++ .../dashboard/courses/[id]/page.tsx | 19 +-- .../(dashboard)/dashboard/learning/page.tsx | 9 +- apps/web/src/lib/api.ts | 6 + 8 files changed, 192 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/app/(certificate)/certificate/[courseId]/page.tsx create mode 100644 apps/web/src/app/(certificate)/layout.tsx diff --git a/apps/ai-service/src/providers/openrouter.provider.ts b/apps/ai-service/src/providers/openrouter.provider.ts index a67e03b..2ccec7a 100644 --- a/apps/ai-service/src/providers/openrouter.provider.ts +++ b/apps/ai-service/src/providers/openrouter.provider.ts @@ -143,10 +143,10 @@ export class OpenRouterProvider { Пользователь хочет создать курс. Твоя задача - задать уточняющие вопросы, чтобы лучше понять его потребности и создать максимально релевантный курс. -Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса: -- Короткий (2-4 главы, введение в тему) -- Средний (4-7 глав, хорошее покрытие) -- Длинный / полный (6-12 глав, глубокое погружение) +Сгенерируй 3-5 вопросов. Обязательно включи вопрос про объём курса (важно для длины): +- Короткий (3-4 главы, по 2-4 урока — только введение в тему) +- Средний (5-7 глав, по 4-6 уроков — полноценное покрытие) +- Длинный / полный (7-12 глав, по 5-8 уроков — глубокое погружение, рекомендуемый вариант для серьёзного обучения) Остальные вопросы: целевая аудитория, глубина материала, специфические темы. @@ -198,13 +198,13 @@ export class OpenRouterProvider { 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 уроков). +ОБЪЁМ КУРСА (соблюдай по ответам; при сомнении выбирай более полный вариант): +- Короткий / введение: не менее 3 глав, в каждой по 3–4 урока. estimatedTotalHours: 4–10. +- Средний: 5–7 глав, в каждой по 4–6 уроков. estimatedTotalHours: 10–25. +- Длинный / полный: 7–12 глав, в каждой по 5–8 уроков. estimatedTotalHours: 20–45. +- Если объём не указан — делай средний или длинный: 5–7 глав, по 4–6 уроков в главе (не менее 25 уроков в курсе). -Укажи примерное время на каждый урок (estimatedMinutes: 10–45). estimatedTotalHours = сумма уроков. +Укажи примерное время на каждый урок (estimatedMinutes: 15–45, чаще 20–35). estimatedTotalHours = сумма уроков. Определи сложность (beginner|intermediate|advanced). Добавь релевантные теги. Ответь в формате JSON со структурой: @@ -281,32 +281,27 @@ ${Object.entries(answers) log.request('generateLessonContent', model); log.info(`Generating content for: "${lessonTitle}" (Chapter: "${chapterTitle}")`); - const systemPrompt = `Ты - эксперт по созданию образовательного контента. -Пиши содержание урока СРАЗУ в форматированном виде — в формате TipTap JSON. Каждый блок контента должен быть правильным узлом TipTap (heading, paragraph, bulletList, orderedList, blockquote, codeBlock, image). + const systemPrompt = `Ты - эксперт по созданию образовательного контента. Пиши ПОЛНЫЙ, ПОДРОБНЫЙ материал урока — не поверхностный обзор, а глубокое раскрытие темы с объяснениями и примерами. -ФОРМАТИРОВАНИЕ (используй обязательно): -- Заголовки: { "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 секции с подзаголовками, примеры/код/списки, резюме. +СТРУКТУРА УРОКА (соблюдай): +- Заголовок (h1), краткое введение в тему (2–3 абзаца). +- 4–7 смысловых секций с подзаголовками (h2/h3). В каждой секции: развёрнутый текст, при необходимости списки, примеры, блоки кода. +- Примеры и код: минимум 1–2 рабочих примера на урок (codeBlock с пояснением до/после). Для технических тем — больше. +- Резюме или выводы в конце (что важно запомнить, как применить). + +ФОРМАТ — TipTap JSON (heading, paragraph, bulletList, orderedList, blockquote, codeBlock с language). Mermaid — где уместно (схемы, процессы). Картинки не выдумывай. + +ОБЪЁМ: не менее 1000–1500 слов на урок. Сложные темы — 1800–3000 слов. Короткие абзацы из 1–2 предложений без примеров запрещены. Уровень: ${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": "Текст..." }] } - ] - } -}`; +Ответь только валидным JSON: { "content": { "type": "doc", "content": [ ... ] } }`; return this.withRetry(async () => { const response = await this.client.chat.completions.create({ @@ -319,7 +314,7 @@ ${Object.entries(answers) Глава: "${chapterTitle}" Урок: "${lessonTitle}" -Создай содержание урока сразу в TipTap JSON: заголовки (heading), параграфы (paragraph), списки (bulletList/orderedList), цитаты (blockquote), блоки кода (codeBlock) и при необходимости Mermaid-диаграммы (codeBlock с "language": "mermaid"). Объём — по теме урока (короткий/средний/длинный).`, +Создай полный и подробный урок в TipTap JSON. Обязательно: развёрнутые объяснения, минимум 1–2 примера или блока кода с пояснениями, описание нюансов и практические советы. Не пиши поверхностно — материал должен быть глубоким и пригодным для самостоятельного изучения. Объём не менее 1000–1500 слов.`, }, ], response_format: { type: 'json_object' }, diff --git a/apps/api/src/certificates/certificates.controller.ts b/apps/api/src/certificates/certificates.controller.ts index 3b3734b..39ba0ff 100644 --- a/apps/api/src/certificates/certificates.controller.ts +++ b/apps/api/src/certificates/certificates.controller.ts @@ -10,6 +10,12 @@ import { User } from '@coursecraft/database'; export class CertificatesController { constructor(private certificatesService: CertificatesService) {} + @Get(':courseId/data') + @ApiOperation({ summary: 'Get certificate data for display/print page' }) + async getCertificateData(@Param('courseId') courseId: string, @CurrentUser() user: User) { + return this.certificatesService.getCertificateData(user.id, courseId); + } + @Get(':courseId') @ApiOperation({ summary: 'Generate certificate for completed course' }) async getCertificate(@Param('courseId') courseId: string, @CurrentUser() user: User): Promise { diff --git a/apps/api/src/certificates/certificates.service.ts b/apps/api/src/certificates/certificates.service.ts index e91360f..34c4c0f 100644 --- a/apps/api/src/certificates/certificates.service.ts +++ b/apps/api/src/certificates/certificates.service.ts @@ -5,7 +5,11 @@ import { PrismaService } from '../common/prisma/prisma.service'; export class CertificatesService { constructor(private prisma: PrismaService) {} - async generateCertificate(userId: string, courseId: string): Promise { + async getCertificateData(userId: string, courseId: string): Promise<{ + userName: string; + courseTitle: string; + completedAt: string; + }> { const enrollment = await this.prisma.enrollment.findUnique({ where: { userId_courseId: { userId, courseId } }, include: { course: true, user: true }, @@ -14,19 +18,27 @@ export class CertificatesService { if (!enrollment) throw new NotFoundException('Not enrolled'); if (!enrollment.completedAt) throw new Error('Course not completed yet'); - // Generate certificate HTML (in production, render to PDF using puppeteer or similar) + return { + userName: enrollment.user.name || enrollment.user.email || 'Слушатель', + courseTitle: enrollment.course.title, + completedAt: new Date(enrollment.completedAt).toISOString(), + }; + } + + async generateCertificate(userId: string, courseId: string): Promise { + const data = await this.getCertificateData(userId, courseId); + const completionDate = new Date(data.completedAt); + const certificateHtml = this.renderCertificateHTML( - enrollment.user.name || enrollment.user.email, - enrollment.course.title, - new Date(enrollment.completedAt) + data.userName, + data.courseTitle, + completionDate ); - // In production: save to S3/R2 and return URL - // For now, return inline HTML const certificateUrl = `data:text/html;base64,${Buffer.from(certificateHtml).toString('base64')}`; await this.prisma.enrollment.update({ - where: { id: enrollment.id }, + where: { userId_courseId: { userId, courseId } }, data: { certificateUrl }, }); @@ -48,7 +60,7 @@ export class CertificatesService { min-height: 100vh; display: flex; align-items: center; - justify-center; + justify-content: center; } .certificate { background: white; diff --git a/apps/web/src/app/(certificate)/certificate/[courseId]/page.tsx b/apps/web/src/app/(certificate)/certificate/[courseId]/page.tsx new file mode 100644 index 0000000..b99f02a --- /dev/null +++ b/apps/web/src/app/(certificate)/certificate/[courseId]/page.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { Printer, Loader2, AlertCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { api } from '@/lib/api'; +import { useAuth } from '@/contexts/auth-context'; + +interface CertificateData { + userName: string; + courseTitle: string; + completedAt: string; +} + +export default function CertificatePage() { + const params = useParams(); + const courseId = params?.courseId as string | undefined; + const { loading: authLoading, user } = useAuth(); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (authLoading || !user || !courseId) { + setLoading(false); + return; + } + (async () => { + try { + const res = await api.getCertificateData(courseId); + setData(res); + } catch { + setError('Сертификат недоступен. Завершите курс и попробуйте снова.'); + } finally { + setLoading(false); + } + })(); + }, [authLoading, user, courseId]); + + const handlePrint = () => { + window.print(); + }; + + const dateStr = data?.completedAt + ? new Date(data.completedAt).toLocaleDateString('ru-RU', { + day: '2-digit', + month: 'long', + year: 'numeric', + }) + : ''; + + if (authLoading || loading) { + return ( +
+ +
+ ); + } + + if (error || !data) { + return ( +
+
+ +

{error || 'Данные не найдены'}

+
+
+ ); + } + + return ( + <> +