From 46cb05fae585665f02d2b5bd5bae5a28c07ea0a0 Mon Sep 17 00:00:00 2001 From: jiankaiii Date: Fri, 10 Oct 2025 01:54:32 +0000 Subject: [PATCH 1/2] fix: dependabot vulnerability for jsondiffpatch by upgrading to AI SDK v5 (#360) --- backend/generate_pptx.py | 2516 +------------ backend/main.py | 108 +- backend/pptx_builder/__init__.py | 1 + backend/pptx_builder/builder.py | 64 + backend/pptx_builder/constants.py | 177 + backend/pptx_builder/localization.py | 13 + backend/pptx_builder/sections/__init__.py | 25 + backend/pptx_builder/sections/activities.py | 295 ++ backend/pptx_builder/sections/agenda.py | 150 + backend/pptx_builder/sections/closing.py | 72 + backend/pptx_builder/sections/content.py | 156 + backend/pptx_builder/sections/discussion.py | 216 ++ backend/pptx_builder/sections/facilitation.py | 186 + backend/pptx_builder/sections/key_terms.py | 135 + .../sections/learning_outcomes.py | 89 + backend/pptx_builder/sections/quiz.py | 281 ++ backend/pptx_builder/sections/readings.py | 157 + backend/pptx_builder/sections/title.py | 72 + backend/pptx_builder/shapes.py | 219 ++ backend/pptx_builder/slide_counter.py | 105 + backend/pptx_builder/utils.py | 110 + frontend/.env.template | 4 +- frontend/package-lock.json | 3285 +++++++---------- frontend/package.json | 46 +- .../app/(app)/workspace/assessment/page.tsx | 17 +- .../app/(app)/workspace/chat/history/page.tsx | 5 +- .../src/app/(app)/workspace/chat/page.tsx | 27 +- .../(app)/workspace/courses/create/page.tsx | 4 +- .../workspace/courses/edit/[id]/page.tsx | 4 +- frontend/src/app/(app)/workspace/faq/page.tsx | 4 +- .../(app)/workspace/overview/faculty/page.tsx | 2 +- .../overview/faculty/settings/page.tsx | 8 + .../workspace/overview/lecturer/page.tsx | 438 +-- .../overview/lecturer/settings/page.tsx | 8 + .../(app)/workspace/overview/student/page.tsx | 366 +- .../overview/student/settings/page.tsx | 8 + .../workspace/programmes/create/page.tsx | 4 +- .../workspace/programmes/edit/[id]/page.tsx | 4 +- .../(app)/workspace/quiz/generate/page.tsx | 46 +- .../src/app/(app)/workspace/settings/page.tsx | 130 +- .../src/app/(app)/workspace/summary/page.tsx | 25 +- .../app/api/assessment/download-docx/route.ts | 1542 +------- .../app/api/assessment/download-pdf/route.ts | 808 +--- frontend/src/app/api/assessment/exam/route.ts | 32 + .../src/app/api/assessment/project/route.ts | 31 + .../src/app/api/assessment/prompts/common.ts | 20 + .../src/app/api/assessment/prompts/exam.ts | 309 ++ .../src/app/api/assessment/prompts/project.ts | 157 + frontend/src/app/api/assessment/route.ts | 2014 +++++++--- frontend/src/app/api/chat/route.ts | 318 +- frontend/src/app/api/faq/route.ts | 228 +- .../src/app/api/quiz/generate-quiz/route.ts | 306 +- .../src/app/api/slide/content-generator.ts | 365 +- .../src/app/api/slide/download-pdf/route.ts | 1051 +----- frontend/src/app/api/slide/prompts.ts | 768 +++- frontend/src/app/api/slide/route.ts | 88 +- frontend/src/app/api/slide/types.ts | 3 + frontend/src/app/api/slide/utils.ts | 287 +- frontend/src/app/api/study-plan/route.ts | 159 +- frontend/src/app/api/summary/route.ts | 103 +- frontend/src/components/app-menu.tsx | 14 +- frontend/src/components/app-settings-form.tsx | 4 +- .../assessment/assessment-editor.tsx | 54 +- frontend/src/components/chat/chat-list.tsx | 4 +- frontend/src/components/chat/chat-message.tsx | 168 +- frontend/src/components/chat/chat.tsx | 102 +- .../src/components/edit-username-form.tsx | 4 +- frontend/src/components/model-downloader.tsx | 4 +- frontend/src/components/slide/ConfigView.tsx | 8 +- .../slide/CourseContentGenerator.tsx | 29 +- .../components/ui/chat/chat-message-list.tsx | 31 +- frontend/src/lib/assessment/docx/builder.ts | 165 + .../src/lib/assessment/docx/documentStyles.ts | 57 + frontend/src/lib/assessment/docx/labels.ts | 104 + frontend/src/lib/assessment/docx/prefixes.ts | 36 + .../src/lib/assessment/docx/projectSection.ts | 335 ++ .../lib/assessment/docx/questionsSection.ts | 237 ++ .../src/lib/assessment/docx/quizSection.ts | 33 + .../src/lib/assessment/docx/rubricTables.ts | 277 ++ .../src/lib/assessment/docx/rubricsSection.ts | 67 + frontend/src/lib/assessment/docx/text.ts | 29 + frontend/src/lib/assessment/docx/title.ts | 20 + .../lib/assessment/pdf/components/header.ts | 51 + frontend/src/lib/assessment/pdf/generator.ts | 73 + .../pdf/generators/projectAssessment.ts | 480 +++ .../pdf/generators/regularAssessment.ts | 201 + frontend/src/lib/assessment/pdf/index.ts | 5 + .../src/lib/assessment/pdf/types/index.ts | 75 + .../src/lib/assessment/pdf/utils/constants.ts | 46 + .../src/lib/assessment/pdf/utils/labels.ts | 143 + .../lib/assessment/pdf/utils/rubricHelpers.ts | 79 + .../assessment/pdf/utils/textProcessing.ts | 122 + .../lib/assessment/pdf/utils/typeGuards.ts | 28 + frontend/src/lib/assessment/rubric.ts | 25 + .../assessment/services/core/concurrency.ts | 0 .../src/lib/assessment/services/core/env.ts | 0 .../src/lib/assessment/services/core/json.ts | 0 .../lib/assessment/services/core/language.ts | 0 .../src/lib/assessment/services/core/text.ts | 0 .../lib/assessment/services/core/tokens.ts | 0 .../src/lib/embedding/generate-embedding.ts | 238 +- frontend/src/lib/extract-file-data.ts | 65 +- frontend/src/lib/pdf/constants.ts | 28 + frontend/src/lib/pdf/generateLecturePdf.ts | 24 + frontend/src/lib/pdf/labels.ts | 75 + frontend/src/lib/pdf/sections/activities.ts | 98 + .../lib/pdf/sections/assessments/criteria.ts | 69 + .../pdf/sections/assessments/discussion.ts | 399 ++ .../lib/pdf/sections/assessments/helpers.ts | 84 + .../src/lib/pdf/sections/assessments/index.ts | 17 + .../src/lib/pdf/sections/assessments/other.ts | 120 + .../src/lib/pdf/sections/assessments/quiz.ts | 183 + .../src/lib/pdf/sections/furtherReadings.ts | 31 + frontend/src/lib/pdf/sections/keyTerms.ts | 32 + frontend/src/lib/pdf/sections/slides.ts | 76 + .../src/lib/pdf/sections/titleMetadata.ts | 51 + frontend/src/lib/pdf/types.ts | 25 + frontend/src/lib/pdf/utils.ts | 87 + frontend/src/lib/rag/multi-pass.ts | 14 +- frontend/src/lib/reranking/rerank-chunks.ts | 8 +- .../retrieve-context-by-reranking.ts | 19 +- frontend/src/lib/store/chat-store.ts | 6 +- frontend/src/lib/store/persona-store.ts | 28 +- frontend/src/lib/tools/analyze-chunks.ts | 2 +- frontend/src/lib/tools/auto-summarize.ts | 5 +- .../src/lib/tools/retrieve-all-context.ts | 2 +- frontend/src/lib/tools/retrieve-context.ts | 2 +- .../lib/tools/retrieve-extended-context.ts | 2 +- frontend/src/lib/tools/summarize-document.ts | 4 +- frontend/src/lib/types/course-info-types.ts | 1 + frontend/src/lib/types/multi-pass-types.ts | 2 +- frontend/src/lib/utils/lang.ts | 19 + frontend/src/lib/utils/message.ts | 42 + frontend/src/payload-types.ts | 3 - frontend/tsconfig.json | 6 +- scripts/utils.mjs | 2 +- 136 files changed, 13706 insertions(+), 9444 deletions(-) create mode 100644 backend/pptx_builder/__init__.py create mode 100644 backend/pptx_builder/builder.py create mode 100644 backend/pptx_builder/constants.py create mode 100644 backend/pptx_builder/localization.py create mode 100644 backend/pptx_builder/sections/__init__.py create mode 100644 backend/pptx_builder/sections/activities.py create mode 100644 backend/pptx_builder/sections/agenda.py create mode 100644 backend/pptx_builder/sections/closing.py create mode 100644 backend/pptx_builder/sections/content.py create mode 100644 backend/pptx_builder/sections/discussion.py create mode 100644 backend/pptx_builder/sections/facilitation.py create mode 100644 backend/pptx_builder/sections/key_terms.py create mode 100644 backend/pptx_builder/sections/learning_outcomes.py create mode 100644 backend/pptx_builder/sections/quiz.py create mode 100644 backend/pptx_builder/sections/readings.py create mode 100644 backend/pptx_builder/sections/title.py create mode 100644 backend/pptx_builder/shapes.py create mode 100644 backend/pptx_builder/slide_counter.py create mode 100644 backend/pptx_builder/utils.py create mode 100644 frontend/src/app/(app)/workspace/overview/faculty/settings/page.tsx create mode 100644 frontend/src/app/(app)/workspace/overview/lecturer/settings/page.tsx create mode 100644 frontend/src/app/(app)/workspace/overview/student/settings/page.tsx create mode 100644 frontend/src/app/api/assessment/exam/route.ts create mode 100644 frontend/src/app/api/assessment/project/route.ts create mode 100644 frontend/src/app/api/assessment/prompts/common.ts create mode 100644 frontend/src/app/api/assessment/prompts/exam.ts create mode 100644 frontend/src/app/api/assessment/prompts/project.ts create mode 100644 frontend/src/lib/assessment/docx/builder.ts create mode 100644 frontend/src/lib/assessment/docx/documentStyles.ts create mode 100644 frontend/src/lib/assessment/docx/labels.ts create mode 100644 frontend/src/lib/assessment/docx/prefixes.ts create mode 100644 frontend/src/lib/assessment/docx/projectSection.ts create mode 100644 frontend/src/lib/assessment/docx/questionsSection.ts create mode 100644 frontend/src/lib/assessment/docx/quizSection.ts create mode 100644 frontend/src/lib/assessment/docx/rubricTables.ts create mode 100644 frontend/src/lib/assessment/docx/rubricsSection.ts create mode 100644 frontend/src/lib/assessment/docx/text.ts create mode 100644 frontend/src/lib/assessment/docx/title.ts create mode 100644 frontend/src/lib/assessment/pdf/components/header.ts create mode 100644 frontend/src/lib/assessment/pdf/generator.ts create mode 100644 frontend/src/lib/assessment/pdf/generators/projectAssessment.ts create mode 100644 frontend/src/lib/assessment/pdf/generators/regularAssessment.ts create mode 100644 frontend/src/lib/assessment/pdf/index.ts create mode 100644 frontend/src/lib/assessment/pdf/types/index.ts create mode 100644 frontend/src/lib/assessment/pdf/utils/constants.ts create mode 100644 frontend/src/lib/assessment/pdf/utils/labels.ts create mode 100644 frontend/src/lib/assessment/pdf/utils/rubricHelpers.ts create mode 100644 frontend/src/lib/assessment/pdf/utils/textProcessing.ts create mode 100644 frontend/src/lib/assessment/pdf/utils/typeGuards.ts create mode 100644 frontend/src/lib/assessment/rubric.ts create mode 100644 frontend/src/lib/assessment/services/core/concurrency.ts create mode 100644 frontend/src/lib/assessment/services/core/env.ts create mode 100644 frontend/src/lib/assessment/services/core/json.ts create mode 100644 frontend/src/lib/assessment/services/core/language.ts create mode 100644 frontend/src/lib/assessment/services/core/text.ts create mode 100644 frontend/src/lib/assessment/services/core/tokens.ts create mode 100644 frontend/src/lib/pdf/constants.ts create mode 100644 frontend/src/lib/pdf/generateLecturePdf.ts create mode 100644 frontend/src/lib/pdf/labels.ts create mode 100644 frontend/src/lib/pdf/sections/activities.ts create mode 100644 frontend/src/lib/pdf/sections/assessments/criteria.ts create mode 100644 frontend/src/lib/pdf/sections/assessments/discussion.ts create mode 100644 frontend/src/lib/pdf/sections/assessments/helpers.ts create mode 100644 frontend/src/lib/pdf/sections/assessments/index.ts create mode 100644 frontend/src/lib/pdf/sections/assessments/other.ts create mode 100644 frontend/src/lib/pdf/sections/assessments/quiz.ts create mode 100644 frontend/src/lib/pdf/sections/furtherReadings.ts create mode 100644 frontend/src/lib/pdf/sections/keyTerms.ts create mode 100644 frontend/src/lib/pdf/sections/slides.ts create mode 100644 frontend/src/lib/pdf/sections/titleMetadata.ts create mode 100644 frontend/src/lib/pdf/types.ts create mode 100644 frontend/src/lib/pdf/utils.ts create mode 100644 frontend/src/lib/utils/lang.ts create mode 100644 frontend/src/lib/utils/message.ts diff --git a/backend/generate_pptx.py b/backend/generate_pptx.py index 4bd7f5e..5ee8bd5 100644 --- a/backend/generate_pptx.py +++ b/backend/generate_pptx.py @@ -1,2502 +1,68 @@ +#!/usr/bin/env python3 # Copyright (C) 2025 Intel Corporation # SPDX-License-Identifier: Apache-2.0 +"""Minimal backward-compatible wrapper for the modular PPTX builder. -#!/usr/bin/env python3 -import json -import sys -import re -import math -import io -import base64 -import os -import tempfile -from pptx import Presentation -from pptx.util import Inches, Pt -from pptx.dml.color import RGBColor -from pptx.enum.text import PP_ALIGN, MSO_ANCHOR, PP_PARAGRAPH_ALIGNMENT -from pptx.enum.shapes import MSO_SHAPE -from pptx.enum.text import MSO_AUTO_SIZE -from pptx.oxml.xmlchemy import OxmlElement -from pptx.dml.fill import FillFormat - -# Enhanced color palette with complementary colors -COLORS = { - # Primary colors - "primary": RGBColor(37, 99, 235), # Blue - "primary_light": RGBColor(96, 165, 250), # Light blue - "primary_dark": RGBColor(30, 64, 175), # Dark blue - - # Secondary colors - "secondary": RGBColor(79, 70, 229), # Indigo - "secondary_light": RGBColor(139, 92, 246), # Light indigo/violet - "secondary_dark": RGBColor(67, 56, 202), # Dark indigo - - # Accent colors - "accent1": RGBColor(139, 92, 246), # Violet - "accent2": RGBColor(16, 185, 129), # Emerald - "accent3": RGBColor(245, 158, 11), # Amber - "accent4": RGBColor(239, 68, 68), # Red - - # Neutral colors - "light": RGBColor(243, 244, 246), # Light gray - "light_alt": RGBColor(249, 250, 251), # Off-white - "dark": RGBColor(31, 41, 55), # Dark gray - "dark_alt": RGBColor(17, 24, 39), # Near black - - # Text colors - "text": RGBColor(17, 24, 39), # Near black - "text_light": RGBColor(255, 255, 255), # White - "text_muted": RGBColor(107, 114, 128), # Medium gray - - # Status colors - "success": RGBColor(16, 185, 129), # Green - "warning": RGBColor(245, 158, 11), # Amber - "error": RGBColor(239, 68, 68), # Red - "info": RGBColor(59, 130, 246), # Blue - - # Background colors - "background": RGBColor(255, 255, 255), # White - "background_alt": RGBColor(249, 250, 251), # Off-white - - # Theme colors - "royal_blue": RGBColor(65, 105, 225), # Royal blue - "medium_purple": RGBColor(147, 112, 219), # Medium purple - "dark_blue": RGBColor(26, 43, 60), # Dark blue/black - "teal": RGBColor(20, 184, 166), # Teal - "emerald": RGBColor(16, 185, 129), # Emerald - "gradient_start": RGBColor(65, 105, 225), # Royal blue - "gradient_end": RGBColor(147, 112, 219), # Medium purple - - # Activity slide colors - "activity_purple": RGBColor(139, 92, 246), # Purple for activity badge - "activity_blue": RGBColor(37, 99, 235), # Blue for header - "activity_green": RGBColor(16, 185, 129), # Green for materials accent - "activity_orange": RGBColor(249, 115, 22), # Orange for corner triangle -} - -# Constants for slide layout -SLIDE_WIDTH = 10 # inches -SLIDE_HEIGHT = 5.625 # inches -FOOTER_Y = 5.3 # Y position for footer elements -CONTENT_START_Y = 1.5 # Starting Y position for content -AVAILABLE_CONTENT_HEIGHT = FOOTER_Y - CONTENT_START_Y # Available height for content -MAIN_BULLET_INDENT = 0.5 # Left indent for main bullets -SUB_BULLET_INDENT = 1.0 # Left indent for sub-bullets -SUB_SUB_BULLET_INDENT = 1.5 # Left indent for sub-sub-bullets - -# Visual theme settings -THEME = { - "use_gradients": True, - "corner_accent": True, - "slide_border": False, - "content_box_shadow": True, - "modern_bullets": True, - "footer_style": "modern", # "modern" or "classic" -} - -# Bullet point markers for detection -BULLET_MARKERS = ['•', '*', '-', '○', '◦', '▪', '▫', '◆', '◇', '►', '▻', '▶', '▷'] -SUB_BULLET_MARKERS = ['-', '○', '◦', '▪', '▫'] - -def clean_slide_title(title): - """ - Remove slide numbers, colons, and clean up the title. - This approach handles patterns like 'Slide X:', 'Slide X -', or any text before a colon. - """ - # Check for colon in the title - if ':' in title: - # Split by colon and take everything after it - title = title.split(':', 1)[1].strip() - return title - - # If no colon, check for the "Slide X" pattern - title_parts = title.split() - - # Check if the title starts with "Slide" followed by a number - if len(title_parts) > 1 and title_parts[0].lower() == "slide" and title_parts[1].replace(":", "").isdigit(): - # Remove the first two parts ("Slide" and the number) - title_parts = title_parts[2:] - # Join the remaining parts back into a string - return " ".join(title_parts).strip() - - return title.strip() - -def clean_activity_title(title): - """ - Remove slide numbers, colons, and clean up the title. - This approach handles patterns like 'Slide X:', 'Slide X -', or any text before a colon. - """ - # Check for colon in the title - if ':' in title: - # Split by colon and take everything after it - title = title.split(':', 1)[1].strip() - return title - - # If no colon, check for the "Slide X" pattern - title_parts = title.split() - - # Check if the title starts with "Activity" followed by a number - if len(title_parts) > 1 and title_parts[0].lower() == "activity" and title_parts[1].replace(":", "").isdigit(): - # Remove the first two parts ("Activity" and the number) - title_parts = title_parts[2:] - # Join the remaining parts back into a string - return " ".join(title_parts).strip() - - return title.strip() - -def detect_bullet_level(text): - """ - Detect if text is a bullet point and determine its level. - Returns a tuple of (is_bullet, level, cleaned_text) - """ - text = text.strip() - - # Check for common bullet point markers - for marker in BULLET_MARKERS: - if text.startswith(marker): - return True, 0, text[len(marker):].strip() - - # Check for indented text with bullet markers (sub-bullets) - if text.startswith(' ') or text.startswith('\\t'): - # Remove leading whitespace - stripped = text.lstrip() - for marker in SUB_BULLET_MARKERS: - if stripped.startswith(marker): - return True, 1, stripped[len(marker):].strip() - - # If indented but no marker, treat as sub-bullet with no marker - return True, 1, stripped - - # Check for numbered bullets (1., 2., etc.) - if re.match(r'^\d+\.\s', text): - return False, 0, text # Not a bullet but a numbered point - - # Not a bullet point - return False, 0, text - -def add_text_box(slide, text, left, top, width, height, font_size=12, bold=False, - italic=False, color=COLORS["text"], alignment=PP_ALIGN.LEFT, - vertical_alignment=MSO_ANCHOR.TOP, level=0, bg_color=None, - border_color=None, shadow=False): - """Add a text box to a slide with the specified properties.""" - textbox = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height)) - text_frame = textbox.text_frame - text_frame.word_wrap = True - text_frame.vertical_anchor = vertical_alignment - - # Try to enable auto-size if available - try: - text_frame.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE - except: - print("Exception occurred while enabling auto-size for the text box in the slide") - pass # Auto-size not supported in this version - - p = text_frame.paragraphs[0] - p.alignment = alignment - p.level = level # Set indentation level - - run = p.add_run() - run.text = text - - font = run.font - font.size = Pt(font_size) - font.bold = bold - font.italic = italic - font.color.rgb = color - - # Add background color if specified - if bg_color: - fill = textbox.fill - fill.solid() - fill.fore_color.rgb = bg_color - - # Add border if specified - if border_color: - line = textbox.line - line.color.rgb = border_color - line.width = Pt(1) - - # Add shadow if requested - if shadow and THEME["content_box_shadow"]: - try: - # This is a simplified shadow effect - shadow = textbox.shadow - shadow.inherit = False - shadow.visible = True - shadow.blur_radius = Pt(5) - shadow.distance = Pt(3) - shadow.angle = 45 - shadow.color.rgb = RGBColor(0, 0, 0) - shadow.transparency = 0.7 - except: - print("Exception occurred while adding shadow to the text box in the slide") - pass # Shadow not supported in this version - - return textbox - -def add_bullet_point(paragraph, text, level=0, font_size=12, bold=False, - italic=False, color=COLORS["text"]): - """Add a bullet point to an existing paragraph with proper formatting.""" - # Set bullet properties - paragraph.level = level - - # Set bullet visibility - try: - paragraph.bullet.visible = True - except: - print("Exception occurred while setting bullet visibility in the slide") - pass # Bullet customization not supported in this version - - # Add the text - run = paragraph.add_run() - run.text = text - - # Format the text - font = run.font - font.size = Pt(font_size) - font.bold = bold - font.italic = italic - font.color.rgb = color - - return paragraph - -def add_bullet_text(slide, text, left, top, width, height, font_size=12, bold=False, - italic=False, color=COLORS["text"], level=0, - bullet_color=None, modern_bullet=False): - """Add bulleted text to a slide with proper indentation and styling.""" - textbox = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height)) - text_frame = textbox.text_frame - text_frame.word_wrap = True - - # Create the paragraph and set its properties - p = text_frame.paragraphs[0] - p.alignment = PP_ALIGN.LEFT - - # Set bullet properties - p.level = level - - # Use modern bullets if enabled - if THEME["modern_bullets"] and modern_bullet: - if level > 1: - text = f"◆ {text}" # Diamond bullet for sub-sub-points - elif level > 0: - text = f"◦ {text}" # Circle bullet for sub-points - else: - text = f"• {text}" # Bullet for main points - else: - # Traditional bullets handled by PowerPoint - try: - p.bullet.visible = True - if bullet_color: - p.bullet.color.rgb = bullet_color - except: - print("Exception occurred while setting bullet properties in the slide") - pass # Bullet customization not supported in this version - - run = p.add_run() - run.text = text - - font = run.font - font.size = Pt(font_size) - font.bold = bold - font.italic = italic - font.color.rgb = color - - return textbox - -def add_shape(slide, shape_type, left, top, width, height, fill_color=None, - line_color=None, line_width=None, shadow=False, opacity=1.0): - """Add a shape to a slide with the specified properties.""" - shape = slide.shapes.add_shape(shape_type, Inches(left), Inches(top), Inches(width), Inches(height)) - - if fill_color: - shape.fill.solid() - shape.fill.fore_color.rgb = fill_color - - # Set transparency if opacity is less than 1 - if opacity < 1.0: - try: - shape.fill.transparency = 1.0 - opacity - except: - print("Exception occurred while setting transparency for the slide shape") - pass # Transparency not supported in this version - - if line_color: - shape.line.color.rgb = line_color - - if line_width is not None: - shape.line.width = Pt(line_width) - - # Add shadow if requested - if shadow: - try: - shadow = shape.shadow - shadow.inherit = False - shadow.visible = True - shadow.blur_radius = Pt(5) - shadow.distance = Pt(3) - shadow.angle = 45 - shadow.color.rgb = RGBColor(0, 0, 0) - shadow.transparency = 0.7 - except: - print("Exception occurred while adding shadow to the slide shape") - pass # Shadow not supported in this version - - return shape - -def add_gradient_background(prs, slide, start_color, end_color, angle=90): - """Add a gradient background to a slide.""" - # Get slide dimensions from the Presentation object - slide_width = prs.slide_width - slide_height = prs.slide_height - - # Add a rectangle that covers the entire slide - shape = slide.shapes.add_shape( - MSO_SHAPE.RECTANGLE, 0, 0, slide_width, slide_height - ) - - # Remove outline - shape.line.fill.background() - - # Try to set gradient fill - try: - fill = shape.fill - fill.gradient() - fill.gradient_stops[0].color.rgb = start_color - fill.gradient_stops[0].position = 0 - fill.gradient_stops[1].color.rgb = end_color - fill.gradient_stops[1].position = 1 - fill.gradient_angle = angle - except: - # Fallback to solid fill if gradient not supported - fill = shape.fill - fill.solid() - fill.fore_color.rgb = start_color - - # Send to back so it doesn't cover other elements - try: - shape.z_order = -100 # Send to back - except: - print("Exception occurred while setting z-order for gradient background in the slide") - pass # z-order not supported in this version - - return shape - -def add_corner_accent(slide, color=COLORS["accent1"], size=1.0, position="top-right"): - """Add a decorative corner accent to a slide.""" - if position == "top-right": - left = SLIDE_WIDTH - size - top = 0 - elif position == "top-left": - left = 0 - top = 0 - elif position == "bottom-right": - left = SLIDE_WIDTH - size - top = SLIDE_HEIGHT - size - elif position == "bottom-left": - left = 0 - top = SLIDE_HEIGHT - size - - # Create a triangle shape for the corner - points = [] - if position == "top-right": - points = [(0, 0), (size, 0), (size, size)] - elif position == "top-left": - points = [(0, 0), (size, 0), (0, size)] - elif position == "bottom-right": - points = [(size, 0), (size, size), (0, size)] - elif position == "bottom-left": - points = [(0, 0), (size, size), (0, size)] - - # Add a custom shape for the corner accent - shape = slide.shapes.add_shape(MSO_SHAPE.RIGHT_TRIANGLE, - Inches(left), Inches(top), - Inches(size), Inches(size)) - - # Set fill color - shape.fill.solid() - shape.fill.fore_color.rgb = color - - # Remove outline - shape.line.fill.background() - - # Set transparency - try: - shape.fill.transparency = 0.3 # 70% opacity - except: - print("Exception occurred while setting transparency for corner accent in the slide") - pass # Transparency not supported in this version - - return shape - -def add_picture(slide, image_path, left, top, width=None, height=None, - shadow=False, border_color=None, border_width=None): - """Add a picture to a slide with the specified properties.""" - if width and height: - pic = slide.shapes.add_picture(image_path, Inches(left), Inches(top), Inches(width), Inches(height)) - elif width: - pic = slide.shapes.add_picture(image_path, Inches(left), Inches(top), width=Inches(width)) - elif height: - pic = slide.shapes.add_picture(image_path, Inches(left), Inches(top), height=Inches(height)) - else: - pic = slide.shapes.add_picture(image_path, Inches(left), Inches(top)) - - # Add border if specified - if border_color: - pic.line.color.rgb = border_color - if border_width: - pic.line.width = Pt(border_width) - else: - pic.line.width = Pt(1) - - # Add shadow if requested - if shadow: - try: - shadow = pic.shadow - shadow.inherit = False - shadow.visible = True - shadow.blur_radius = Pt(5) - shadow.distance = Pt(3) - shadow.angle = 45 - shadow.color.rgb = RGBColor(0, 0, 0) - shadow.transparency = 0.7 - except: - print("Exception occurred while adding shadow to picture in the slide") - pass # Shadow not supported in this version - - return pic - -def add_table(slide, rows, cols, left, top, width, height, data=None, - header_bg_color=None, alt_row_bg_color=None, border_color=None): - """Add a table to a slide with the specified properties.""" - table = slide.shapes.add_table(rows, cols, Inches(left), Inches(top), Inches(width), Inches(height)).table - - # Set border color if specified - if border_color: - try: - for row in table.rows: - for cell in row.cells: - cell.border.color.rgb = border_color - except: - print("Exception occurred while setting border color in the slide") - pass # Border customization not supported in this version - - if data: - for i, row_data in enumerate(data): - for j, cell_data in enumerate(row_data): - cell = table.cell(i, j) - - # Apply header background color to first row - if i == 0 and header_bg_color: - cell.fill.solid() - cell.fill.fore_color.rgb = header_bg_color - # Apply alternating row background color - elif i > 0 and alt_row_bg_color and i % 2 == 1: - cell.fill.solid() - cell.fill.fore_color.rgb = alt_row_bg_color - - if isinstance(cell_data, dict): - text = cell_data.get("text", "") - options = cell_data.get("options", {}) - - p = cell.text_frame.paragraphs[0] - p.text = text - - if "fill" in options and "color" in options["fill"]: - cell.fill.solid() - cell.fill.fore_color.rgb = options["fill"]["color"] - - if "color" in options: - p.runs[0].font.color.rgb = options["color"] - - if "fontSize" in options: - p.runs[0].font.size = Pt(options["fontSize"]) - - if "bold" in options: - p.runs[0].font.bold = options["bold"] - - if "italic" in options: - p.runs[0].font.italic = options["italic"] - - if "align" in options: - if options["align"] == "center": - p.alignment = PP_ALIGN.CENTER - elif options["align"] == "right": - p.alignment = PP_ALIGN.RIGHT - else: - cell.text = str(cell_data) - - return table - -def add_footer(slide, title_text, slide_number, total_slides, style="modern"): - """Add standardized footer to a slide with only the current page number.""" - if style == "modern": - # Add a subtle divider line above the footer - add_shape(slide, MSO_SHAPE.RECTANGLE, 0.5, FOOTER_Y - 0.05, 9.0, 0.01, - fill_color=COLORS["primary_light"], opacity=0.5) - - # Add footer with presentation title - title_box = add_text_box(slide, title_text, 0.5, FOOTER_Y, 8.5, 0.3, - font_size=10, color=COLORS["primary"], italic=True) - - # Add slide number in bottom right - only the current page number - number_box = add_text_box(slide, f"{slide_number}", 9.0, FOOTER_Y, 0.5, 0.3, - font_size=10, color=COLORS["primary"], - alignment=PP_ALIGN.RIGHT) - else: - # Classic footer style - add_text_box(slide, title_text, 0.5, FOOTER_Y, 8.5, 0.3, font_size=10, color=COLORS["royal_blue"]) - add_text_box(slide, f"{slide_number}", 9.0, FOOTER_Y, 0.5, 0.3, font_size=10, - color=COLORS["text"], alignment=PP_ALIGN.RIGHT) - -def calculate_dynamic_spacing(content_items, available_height=AVAILABLE_CONTENT_HEIGHT, min_height=0.4): - """Calculate dynamic spacing between content items based on available height.""" - if not content_items: - return min_height - - # Calculate spacing based on number of items and available height - # Ensure minimum spacing and adjust for available space - item_count = len(content_items) - spacing = min(0.8, max(min_height, available_height / max(item_count, 1))) - - return spacing - -def estimate_text_height(text, font_size, width): - """Estimate the height needed for text based on content length and width.""" - # Approximate characters per line based on font size and width - # This is a rough estimate - actual rendering may vary - chars_per_inch = 120 / (font_size / 10) # Adjust based on average character width - chars_per_line = int(chars_per_inch * width) - - # Calculate number of lines needed - if chars_per_line <= 0: - chars_per_line = 1 # Avoid division by zero - - text_length = len(text) - lines = math.ceil(text_length / chars_per_line) - - # Calculate height based on lines and font size - # Add some padding for line spacing - line_height = (font_size / 72) * 1.2 # Convert points to inches with 1.2 line spacing - - # Ensure minimum height - return max(0.2, lines * line_height) - -def check_content_overflow(y_position, content_height, footer_position=FOOTER_Y): - """Check if content would overflow the slide boundaries.""" - return (y_position + content_height) > (footer_position - 0.2) # 0.2 inch margin - -def create_title_slide(prs, content): - """Create an enhanced title slide with visual elements.""" - slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - - # Add gradient background if enabled - if THEME["use_gradients"]: - add_gradient_background(prs, slide, COLORS["gradient_start"], COLORS["gradient_end"], angle=135) - else: - # Add solid color background - add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, SLIDE_HEIGHT, - fill_color=COLORS["primary_dark"]) - - # Add decorative corner accents if enabled - if THEME["corner_accent"]: - add_corner_accent(slide, COLORS["accent1"], 2.0, "top-right") - add_corner_accent(slide, COLORS["accent2"], 1.5, "bottom-left") - - # Add title with enhanced styling - title = content.get("title", "Untitled Presentation") - title_box = add_text_box(slide, title, 0.5, 1.5, 9, 1.5, font_size=48, - bold=True, color=COLORS["text_light"], - alignment=PP_ALIGN.CENTER, shadow=True) - - # Add subtitle with course type and level - content_type_map = { - "lecture": "Lecture", - "tutorial": "Tutorial", - "workshop": "Workshop", - } - - difficulty_map = { - "introductory": "Introductory Level", - "intermediate": "Intermediate Level", - "advanced": "Advanced Level", - } - - content_type = content.get("contentType", "lecture") - difficulty_level = content.get("difficultyLevel", "intermediate") - - content_type_display = content_type_map.get(content_type, "Lecture") - difficulty_display = difficulty_map.get(difficulty_level, "Intermediate Level") - - subtitle = f"{content_type_display} | {difficulty_display}" - - # Add a decorative line between title and subtitle - line_y = 3.2 - add_shape(slide, MSO_SHAPE.RECTANGLE, 3.5, line_y, 3.0, 0.02, - fill_color=COLORS["accent2"]) - - # Add subtitle text - subtitle_box = add_text_box(slide, subtitle, 0.5, line_y + 0.2, 9, 0.5, - font_size=28, italic=True, color=COLORS["text_light"], - alignment=PP_ALIGN.CENTER) - - return slide - -def create_agenda_slide(prs, content, total_slides): - """Create enhanced agenda slides with visual improvements and overflow handling.""" - # Collect all slide titles and categorize them - agenda_items = [] - - # Introduction section - intro_items = [ - "Learning Outcomes", - "Key Terms & Concepts" - ] - agenda_items.append({"title": "Introduction", "items": intro_items}) - - # Content slides section - content_slides = [] - for slide_content in content.get("slides", []): - title = clean_slide_title(slide_content.get("title", "")) - if title: - content_slides.append(title) - - if content_slides: - agenda_items.append({"title": "Main Content", "items": content_slides}) - - # Activities section - activities = [] - for idx, activity in enumerate(content.get("activities", [])): - activity_title = activity.get("title", "") - activities.append(activity_title) - - if activities: - agenda_items.append({"title": "Activities", "items": activities}) - - # Test Your Knowledge section (quizzes and discussions) - knowledge_items = [] - - # Count quiz and discussion questions - quiz_count = 0 - discussion_count = 0 - - for idea in content.get("assessmentIdeas", []): - idea_type = idea.get("type", "").lower() - if "quiz" in idea_type: - quiz_count += len([q for q in idea.get("exampleQuestions", []) if q.get("options")]) - elif "discussion" in idea_type: - discussion_count += len(idea.get("exampleQuestions", []) if idea.get("exampleQuestions") else []) - - if quiz_count > 0: - knowledge_items.append(f"Quiz Questions ({quiz_count})") - - if discussion_count > 0: - knowledge_items.append(f"Discussion Questions ({discussion_count})") - - if knowledge_items: - agenda_items.append({"title": "Test Your Knowledge", "items": knowledge_items}) - - # Further Reading section - if content.get("furtherReadings", []): - agenda_items.append({"title": "Additional Resources", "items": ["Further Readings & Resources"]}) - - section_height = 0.5 - item_height = 0.35 - - total_height_needed = 0 - for section in agenda_items: - total_height_needed += section_height - total_height_needed += len(section["items"]) * item_height - - available_height = FOOTER_Y - 1.2 - - slides_needed = math.ceil(total_height_needed / available_height) - - # Create agenda slides - agenda_slides = [] - - for slide_idx in range(slides_needed): - slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - agenda_slides.append(slide) - - # Add blue header bar - add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, 0.8, - fill_color=COLORS["primary"]) - - # Add title - title = "Agenda" - if slide_idx > 0: - title += f" (continued {slide_idx + 1}/{slides_needed})" - - add_text_box(slide, title, 0.5, 0.1, 9, 0.6, - font_size=36, bold=True, color=COLORS["text_light"]) - - # Create a rounded container for the agenda content - container = add_shape(slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.3, 0.9, 9.4, FOOTER_Y - 1.1, - fill_color=COLORS["light"], opacity=0.9, - line_color=COLORS["primary_light"], line_width=1) - - # Distribute content across slides - y_position = 1.1 - max_y = FOOTER_Y - 0.3 # Leave space for footer - - # Track which sections and items we've already displayed - section_start_idx = 0 - item_start_idx = 0 - - # For each slide, continue where we left off - for slide_content_idx in range(slide_idx): - # Skip through sections until we find where we left off - for section_idx, section in enumerate(agenda_items[section_start_idx:], section_start_idx): - section_height = 0.5 - items_height = len(section["items"][item_start_idx:]) * item_height - total_section_height = section_height + items_height - - if total_section_height <= available_height: - # Entire section fits, move to next section - available_height -= total_section_height - section_start_idx += 1 - item_start_idx = 0 - else: - # Section doesn't fit entirely - # Calculate how many items fit - items_that_fit = int(available_height / item_height) - if items_that_fit <= 0: - # Not even one item fits, move to next slide - available_height = FOOTER_Y - 1.2 - break - - # Skip these items for the next slide - item_start_idx += items_that_fit - available_height = FOOTER_Y - 1.2 - break - - # Reset available height for next slide calculation - available_height = FOOTER_Y - 1.2 - - # Now render the content for the current slide - for section_idx, section in enumerate(agenda_items[section_start_idx:], section_start_idx): - # Check if we have room for the section title - if y_position + section_height > max_y: - break - - # Add section title with accent color - section_title = section["title"] - add_text_box(slide, section_title, 0.7, y_position, 8.5, section_height, - font_size=24, bold=True, color=COLORS["primary"]) - y_position += section_height - - # Add section items as sub-bullets - for item_idx, item in enumerate(section["items"][item_start_idx:]): - # Check if we have room for this item - if y_position + item_height > max_y: - # No more room on this slide - break - - # Create a text box for the sub-bullet - item_textbox = slide.shapes.add_textbox( - Inches(1.0), - Inches(y_position), - Inches(8.0), - Inches(item_height) - ) - - # Add the text with proper indentation - p = item_textbox.text_frame.paragraphs[0] - p.level = 1 # Set as sub-bullet - - # Set bullet visibility - try: - p.bullet.visible = True - except: - # If bullet customization not supported, use text bullet - item = f"• {item}" - - run = p.add_run() - run.text = item - - # Format the text - font = run.font - font.size = Pt(18) - font.color.rgb = COLORS["text"] - - y_position += item_height - - # If we've displayed all items in this section, reset item_start_idx for the next section - if item_idx + item_start_idx >= len(section["items"]) - 1: - item_start_idx = 0 - section_start_idx += 1 - else: - # Otherwise, remember where we left off in this section - item_start_idx += item_idx + 1 - break - - # Add footer with slide number - add_footer(slide, content.get("title", "Untitled Presentation"), - slide_idx + 2, total_slides, THEME["footer_style"]) - - return agenda_slides - -def create_learning_outcomes_slide(prs, content, total_slides): - """ - Create enhanced learning outcomes slide with visual elements. - """ - slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - - # Add blue header bar - add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, 0.8, fill_color=COLORS["primary"]) - - # Add title - add_text_box(slide, "Learning Outcomes", 0.5, 0.1, 9, 0.6, font_size=36, bold=True, color=COLORS["text_light"]) - - # Add intro text - content_type_map = { - "lecture": "lecture", - "tutorial": "tutorial", - "workshop": "workshop", - } - content_type = content.get("contentType", "lecture") - content_type_display = content_type_map.get(content_type, "lecture") - - intro_text = f"By the end of this {content_type_display}, you will be able to:" - add_text_box(slide, intro_text, 0.5, 1.0, 9, 0.5, font_size=20, italic=True, color=COLORS["dark"]) - - # Add rounded rectangle container for learning outcomes - add_shape(slide, MSO_SHAPE.ROUNDED_RECTANGLE, 0.3, 1.7, 9.4, 3.0, fill_color=COLORS["light"], - opacity=0.9, line_color=COLORS["primary_light"], line_width=1) - - # Add learning outcomes with colored squares and numbering - learning_outcomes = content.get("learningOutcomes", []) - y_position = 2.0 - - # Define colors for the bullet points - bullet_colors = [COLORS["emerald"], COLORS["medium_purple"], COLORS["emerald"]] - - for idx, outcome in enumerate(learning_outcomes): - # Remove existing numbering from the outcome text - cleaned_outcome = re.sub(r"^\d+\.\s*", "", outcome) - - # Add colored square bullet - bullet_color = bullet_colors[idx % len(bullet_colors)] - add_shape(slide, MSO_SHAPE.RECTANGLE, 0.7, y_position, 0.15, 0.15, fill_color=bullet_color) - - # Add outcome text slightly higher to align with the bullet center - add_text_box(slide, cleaned_outcome, 1.0, y_position - 0.125, 8.5, 0.4, font_size=20, - color=COLORS["text"], vertical_alignment=MSO_ANCHOR.MIDDLE) - - y_position += 0.6 - - # Add footer - add_footer(slide, content.get("title", "Untitled Presentation"), 1, total_slides, THEME["footer_style"]) - - return slide - -def create_key_terms_slide(prs, content, total_slides): - """Create enhanced key terms slides with visual improvements.""" - key_terms = content.get("keyTerms", []) - if not key_terms: - return [] - - # Calculate how many slides we need (4 terms per slide) - terms_per_slide = 4 - total_terms = len(key_terms) - slides_needed = (total_terms + terms_per_slide - 1) // terms_per_slide # Ceiling division - - slides = [] - - for slide_idx in range(slides_needed): - slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - slides.append(slide) - - # Add gradient header if enabled - add_gradient_background(prs, slide, COLORS["primary"], COLORS["primary_dark"], angle=0) - - # Add decorative corner accent if enabled - add_corner_accent(slide, COLORS["accent3"], 1.0, "bottom-left") - - # Add title - title = "Key Terms & Concepts" - if slide_idx > 0: - title += " (continued)" - add_text_box(slide, title, 0.5, 0.1, 9, 0.6, - font_size=36, bold=True, color=COLORS["text_light"]) - - # Get terms for this slide - start_idx = slide_idx * terms_per_slide - end_idx = min(start_idx + terms_per_slide, total_terms) - terms_for_slide = key_terms[start_idx:end_idx] - - # Calculate the height of the table - table_height = min(3.5, 0.8 * (len(terms_for_slide) + 1)) # Limit table height to leave space for footer - - # Add a background container for the table - if THEME["content_box_shadow"]: - add_shape(slide, MSO_SHAPE.RECTANGLE, 0.4, 0.9, 9.2, table_height + 0.2, - fill_color=COLORS["light_alt"], shadow=True) - - # Create the table - table = add_table(slide, len(terms_for_slide) + 1, 2, 0.5, 1.0, 9.0, table_height, - header_bg_color=COLORS["royal_blue"], - alt_row_bg_color=COLORS["light"], - border_color=COLORS["primary_light"]) - - # Add header row - header_cells = [ - {"text": "Term", "options": {"fill": {"color": COLORS["royal_blue"]}, "color": COLORS["text_light"], "fontSize": 18, "bold": True, "align": "center"}}, - {"text": "Definition", "options": {"fill": {"color": COLORS["royal_blue"]}, "color": COLORS["text_light"], "fontSize": 18, "bold": True, "align": "center"}} - ] - - for j, cell_data in enumerate(header_cells): - cell = table.cell(0, j) - text = cell_data["text"] - options = cell_data["options"] - - p = cell.text_frame.paragraphs[0] - p.text = text - - if "fill" in options and "color" in options["fill"]: - cell.fill.solid() - cell.fill.fore_color.rgb = options["fill"]["color"] - - if "color" in options: - p.runs[0].font.color.rgb = options["color"] - - if "fontSize" in options: - p.runs[0].font.size = Pt(options["fontSize"]) - - if "bold" in options: - p.runs[0].font.bold = options["bold"] - - if "align" in options: - if options["align"] == "center": - p.alignment = PP_ALIGN.CENTER - - # Add term rows - for i, term in enumerate(terms_for_slide): - row_idx = i + 1 # +1 because of header row - is_even_row = i % 2 == 0 - row_bg_color = COLORS["background"] if is_even_row else COLORS["light"] - - # Term cell - term_cell = table.cell(row_idx, 0) - term_cell.text = term.get("term", "") - term_cell.fill.solid() - term_cell.fill.fore_color.rgb = row_bg_color - term_cell.text_frame.paragraphs[0].runs[0].font.bold = True - term_cell.text_frame.paragraphs[0].runs[0].font.size = Pt(16) - term_cell.text_frame.paragraphs[0].runs[0].font.color.rgb = COLORS["primary_dark"] - - # Definition cell - def_cell = table.cell(row_idx, 1) - def_cell.text = term.get("definition", "") - def_cell.fill.solid() - def_cell.fill.fore_color.rgb = row_bg_color - def_cell.text_frame.paragraphs[0].runs[0].font.size = Pt(14) - - # Add footer with white title color - add_footer(slide, content.get("title", "Untitled Presentation"), slide_idx + 2, total_slides, THEME["footer_style"]) - # Explicitly set the footer title color to white - add_footer(slide, content.get("title", "Untitled Presentation"), slide_idx + 2, total_slides, THEME["footer_style"]) - - return slides +All implementation details have moved to the ``pptx_builder`` package. This +file is intentionally tiny so existing scripts that still run +``python backend/generate_pptx.py`` continue to work. -def create_content_slides(prs, content, total_slides): - """Create enhanced slides for the main content with improved formatting and visual elements.""" - slides = content.get("slides", []) - if not slides: - return [] - - result_slides = [] - - # Calculate starting slide number - slide_count_offset = 2 + len(content.get("keyTerms", [])) // 4 # Learning Outcomes + Key Terms slides - - # Skip the second slide in the content slides section - for slide_idx, slide_content in enumerate(slides): - # Skip the second slide (index 1) - if slide_idx == 1: - continue - - slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - result_slides.append(slide) - - # Add gradient header if enabled - if THEME["use_gradients"]: - add_gradient_background(prs, slide, COLORS["primary"], COLORS["primary_dark"], angle=0) - # Add a semi-transparent white overlay for the content area - add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0.8, SLIDE_WIDTH, SLIDE_HEIGHT - 0.8, - fill_color=COLORS["background"], opacity=0.9) - else: - # Add solid color header bar - add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, 0.8, - fill_color=COLORS["royal_blue"]) - - # Add decorative corner accent if enabled - if THEME["corner_accent"]: - accent_idx = slide_idx % 3 # Cycle through 3 accent colors - accent_color = [COLORS["accent1"], COLORS["accent2"], COLORS["accent3"]][accent_idx] - add_corner_accent(slide, accent_color, 1.0, "bottom-right") - - # Add slide title - Remove slide numbers from titles - original_title = slide_content.get("title", "") - cleaned_title = clean_slide_title(original_title) - add_text_box(slide, cleaned_title, 0.5, 0.1, 9, 0.6, - font_size=32, bold=True, color=COLORS["text_light"]) - - # Process slide content with improved bullet formatting - content_points = slide_content.get("content", []) - - # Create a content container with shadow if enabled - if THEME["content_box_shadow"]: - content_height = FOOTER_Y - CONTENT_START_Y - 0.2 - content_container = add_shape(slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.3, CONTENT_START_Y - 0.1, 9.4, content_height, - fill_color=COLORS["light_alt"], opacity=0.7, - line_color=COLORS["primary_light"], line_width=1, - shadow=True) - - # Create a single text box for all content to ensure proper bullet formatting - content_textbox = slide.shapes.add_textbox( - Inches(MAIN_BULLET_INDENT), - Inches(CONTENT_START_Y), - Inches(9 - MAIN_BULLET_INDENT), - Inches(FOOTER_Y - CONTENT_START_Y - 0.3) - ) - text_frame = content_textbox.text_frame - text_frame.word_wrap = True - - # Check if there are any sub-bullets in the content - has_sub_bullets = False - for point in content_points: - point_text = point if isinstance(point, str) else json.dumps(point) - if point_text.strip().startswith(' ') or point_text.strip().startswith('\\t') or point_text.strip().startswith('-'): - has_sub_bullets = True - break - - # Enhanced bullet point detection and formatting - current_paragraph = text_frame.paragraphs[0] - is_first_paragraph = True - - for point_idx, point in enumerate(content_points): - point_text = point if isinstance(point, str) else json.dumps(point) - - # Determine if this is a bullet point and its level - is_bullet = False - bullet_level = 0 - - # Check for bullet point indicators - if point_text.strip().startswith('•') or point_text.strip().startswith('*'): - is_bullet = True - point_text = point_text.strip()[1:].strip() # Remove the bullet character - elif point_text.strip().startswith('-'): - is_bullet = True - bullet_level = 1 # Sub-bullet - point_text = point_text.strip()[1:].strip() # Remove the hyphen - elif point_text.strip().startswith(' ') or point_text.strip().startswith('\\t'): - is_bullet = True - bullet_level = 1 # Indented text as sub-bullet - point_text_level = 1 # Indented text as sub-bullet - point_text = point_text.strip() - elif not has_sub_bullets: - # If no sub-bullets exist in the slide, make all points bullets - is_bullet = True - - # Create a new paragraph for each point (except the first one) - if not is_first_paragraph: - current_paragraph = text_frame.add_paragraph() - else: - is_first_paragraph = False - - # Set up bullet formatting - if is_bullet: - current_paragraph.level = bullet_level - try: - current_paragraph.bullet.visible = True - except: - # If bullet customization is not supported, use text bullets - if THEME["modern_bullets"]: - if bullet_level > 0: - point_text = f"◦ {point_text}" # Circle bullet for sub-points - else: - point_text = f"• {point_text}" # Bullet for main points - - # Add the text - run = current_paragraph.add_run() - run.text = point_text - - # Format the text based on level - font = run.font - if bullet_level == 0: - font.size = Pt(18) - font.bold = not is_bullet # Bold for headings, not for bullets - else: - font.size = Pt(16) - font.bold = False - - font.color.rgb = COLORS["text"] - - # Add speaker notes to the PowerPoint notes section, not on the slide - notes = slide_content.get("notes", "") - if notes: - notes_text = notes if isinstance(notes, str) else json.dumps(notes) - - # Format the notes for better readability - formatted_notes = notes_text - - # Add to PowerPoint's built-in notes section - if not slide.has_notes_slide: - slide.notes_slide - slide.notes_slide.notes_text_frame.text = formatted_notes - - # Add footer - # Adjust slide number to account for skipped slide - adjusted_slide_idx = slide_idx if slide_idx < 1 else slide_idx - 1 - slide_number = slide_count_offset + adjusted_slide_idx + 1 - add_footer(slide, content.get("title", "Untitled Presentation"), - slide_number, total_slides, THEME["footer_style"]) - - return result_slides +Preferred (new) usage:: -def extract_facilitation_content(text): - """ - Extract facilitation notes and learning objectives from text. - Returns a tuple of (clean_description, facilitation_notes, learning_objectives) - """ - clean_description = text - facilitation_notes = "" - learning_objectives = "" - - # Extract facilitation notes with pattern matching - facilitation_patterns = [ - "Facilitation notes:", - "Facilitation Notes:", - "FACILITATION NOTES:", - "Facilitation notes:", - "Facilitation Notes:", - "Facilitator notes:", - "Facilitator guidance:", - "Facilitation tip:" - ] + from pptx_builder.builder import create_pptx - for pattern in facilitation_patterns: - if pattern in text: - parts = text.split(pattern, 1) - clean_description = parts[0].strip() - facilitation_notes = "Facilitation Notes: " + parts[1].strip() - break - - # Extract learning objectives with pattern matching - learning_patterns = [ - "Learning Objective:", - "Learning Objectives:", - "LEARNING OBJECTIVES:", - "Learning Objective:", - "Learning Objectives:", - "Success criteria:" - ] +""" +from __future__ import annotations - for pattern in learning_patterns: - if pattern in text: - # If we already extracted facilitation notes, use the clean description - search_text = clean_description if facilitation_notes else text - - if pattern in search_text: - parts = search_text.split(pattern, 1) - clean_description = parts[0].strip() - learning_objectives = "Learning Objective: " + parts[1].strip() - - return (clean_description, facilitation_notes, learning_objectives) - -def create_activity_slides(prs, content, total_slides): - """Create activity slides matching the provided design with materials slides for each activity.""" - activities = content.get("activities", []) - if not activities: - return [] - - slides = [] - - # Calculate starting slide number - slide_count_offset = ( - 2 - + len(content.get("keyTerms", [])) // 4 - 1 # Additional Key Terms slides - + len([s for i, s in enumerate(content.get("slides", [])) if i != 1]) # Content slides (excluding second slide) - + 1 - ) - - for act_idx, activity in enumerate(activities): - # Get clean activity title (remove any existing "Activity:" prefix) - original_title = activity.get("title", "Optimizing Neural Networks") - clean_title = clean_activity_title(original_title) - - # Create the main activity slide - main_slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - slides.append(main_slide) - - # Create the materials slide for EVERY activity (initialize early) - materials_slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - slides.append(materials_slide) - - # Add blue header bar - add_shape(main_slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, 0.8, - fill_color=COLORS["activity_blue"]) - - # Add activity title with consistent formatting - title_text = f"Activity {act_idx + 1}: {clean_title}" - add_text_box(main_slide, title_text, 0.5, 0.07, 9.0, 0.6, - font_size=22, bold=True, color=COLORS["text_light"]) - - # Add purple badge for type and duration - badge_shape = add_shape(main_slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.5, 0.9, 5.0, 0.5, - fill_color=COLORS["activity_purple"], - line_color=None) - - # Add type and duration text - activity_type = activity.get("type", "Exercise") - activity_duration = activity.get("duration", "20 minutes") - type_duration_text = f"Type: {activity_type} | Duration: {activity_duration}" - add_text_box(main_slide, type_duration_text, 0.6, 0.9, 4.8, 0.5, - font_size=16, italic=True, color=COLORS["text_light"], - vertical_alignment=MSO_ANCHOR.MIDDLE) - - # Create a large rounded container for all content - content_container = add_shape(main_slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.5, 1.5, 9.0, 3.5, - fill_color=COLORS["background"], - line_color=COLORS["activity_purple"], - line_width=1) - - # Add activity description - activity_description = activity.get("description", "Optimize a neural network model for Gaudi-3 using quantization, pruning, or knowledge distillation to improve performance.") - - # Extract facilitation notes and learning objectives - clean_description, facilitation_notes, learning_objectives = extract_facilitation_content(activity_description) - - # Update description if facilitation notes or learning objectives were found - if facilitation_notes or learning_objectives: - # Update the description text boxes with clean description - add_text_box(main_slide, clean_description, 0.7, 1.7, 8.6, 0.6, - font_size=20, color=COLORS["text"]) - add_text_box(materials_slide, clean_description, 0.7, 1.7, 8.6, 0.6, - font_size=20, color=COLORS["text"]) - - # Combine notes - combined_notes = "" - if facilitation_notes: - combined_notes += facilitation_notes - if learning_objectives: - combined_notes += " " + learning_objectives if combined_notes else learning_objectives - - if combined_notes: - # Add notes to the PowerPoint notes section - if not main_slide.has_notes_slide: - main_slide.notes_slide - main_slide.notes_slide.notes_text_frame.text = combined_notes - - # Also add to materials slide notes - if not materials_slide.has_notes_slide: - materials_slide.notes_slide - materials_slide.notes_slide.notes_text_frame.text = combined_notes - - - # Use the clean description for the rest of the function - activity_description = clean_description - - # Add blue header bar - add_shape(materials_slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, 0.8, - fill_color=COLORS["activity_blue"]) - - # Add activity title with (Materials) suffix - materials_title = f"Activity {act_idx + 1}: {clean_title} (Materials)" - add_text_box(materials_slide, materials_title, 0.5, 0.07, 9.0, 0.6, - font_size=22, bold=True, color=COLORS["text_light"]) - - # Add purple badge for type and duration (same as main slide) - badge_shape = add_shape(materials_slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.5, 0.9, 5.0, 0.5, - fill_color=COLORS["activity_purple"], - line_color=None) - - # Add type and duration text - add_text_box(materials_slide, type_duration_text, 0.6, 0.9, 4.8, 0.5, - font_size=16, italic=True, color=COLORS["text_light"], - vertical_alignment=MSO_ANCHOR.MIDDLE) - - # Create a large rounded container for all content - content_container = add_shape(materials_slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.5, 1.5, 9.0, 3.5, - fill_color=COLORS["background"], - line_color=COLORS["activity_purple"], - line_width=1) - - # Add activity description (same as main slide) - add_text_box(main_slide, activity_description, 0.7, 1.7, 8.6, 0.6, - font_size=20, color=COLORS["text"]) - add_text_box(materials_slide, activity_description, 0.7, 1.7, 8.6, 0.6, - font_size=20, color=COLORS["text"]) - - # Extract facilitation notes with more robust pattern matching - facilitation_notes = "" - description_clean = activity_description - - # Check for various facilitation note formats - facilitation_patterns = [ - "Facilitation notes:", - "Facilitation Notes:", - "FACILITATION NOTES:", - "Facilitation notes:", - "Facilitation Notes:", - "Facilitator notes:", - "Facilitator guidance:", - "Success criteria:", - "Success notes:" - "Learning Objective:" - ] - - for pattern in facilitation_patterns: - if pattern in activity_description: - parts = activity_description.split(pattern, 1) - description_clean = parts[0].strip() - facilitation_notes = "Facilitation Notes: " + parts[1].strip() - break - - # If facilitation notes were found, update the slides - if facilitation_notes: - # Update the description text boxes with clean description - add_text_box(main_slide, description_clean, 0.7, 1.7, 8.6, 0.6, - font_size=20, color=COLORS["text"]) - add_text_box(materials_slide, description_clean, 0.7, 1.7, 8.6, 0.6, - font_size=20, color=COLORS["text"]) - - # Add facilitation notes to the PowerPoint notes section - if not main_slide.has_notes_slide: - main_slide.notes_slide - main_slide.notes_slide.notes_text_frame.text = facilitation_notes - - # Also add to materials slide notes - if not materials_slide.has_notes_slide: - materials_slide.notes_slide - materials_slide.notes_slide.notes_text_frame.text = facilitation_notes - - # Add a visual indicator that facilitation notes are available - add_shape(main_slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 8.5, 0.9, 1.0, 0.5, - fill_color=COLORS["activity_green"], - line_color=None) - add_text_box(main_slide, "Notes Available", 8.6, 0.95, 0.8, 0.4, - font_size=12, bold=True, color=COLORS["text_light"], - alignment=PP_ALIGN.CENTER) - - # Add the same indicator to materials slide - add_shape(materials_slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 8.5, 0.9, 1.0, 0.5, - fill_color=COLORS["activity_green"], - line_color=None) - add_text_box(materials_slide, "Notes Available", 8.6, 0.95, 0.8, 0.4, - font_size=12, bold=True, color=COLORS["text_light"], - alignment=PP_ALIGN.CENTER) - else: - # If no facilitation notes were found, use the original description - activity_description = description_clean - - # Extract learning objectives for tutorial slides if present - learning_objectives = "" - isTutorial = activity.get("type", "").lower() == "tutorial" - if isTutorial and "Learning Objective:" in activity_description: - parts = activity_description.split("Learning Objective:", 1) - activity_description = parts[0].strip() - learning_objectives = "Learning Objective: " + parts[1].strip() - - # Update the description text boxes with clean description - add_text_box(main_slide, activity_description, 0.7, 1.7, 8.6, 0.6, - font_size=20, color=COLORS["text"]) - add_text_box(materials_slide, activity_description, 0.7, 1.7, 8.6, 0.6, - font_size=20, color=COLORS["text"]) - - # Append learning objectives to notes - if not main_slide.has_notes_slide: - main_slide.notes_slide - current_notes = main_slide.notes_slide.notes_text_frame.text - main_slide.notes_slide.notes_text_frame.text = current_notes + " " + learning_objectives if current_notes else learning_objectives - - # Also add to materials slide notes - if not materials_slide.has_notes_slide: - materials_slide.notes_slide - current_notes = materials_slide.notes_slide.notes_text_frame.text - materials_slide.notes_slide.notes_text_frame.text = current_notes + " " + learning_objectives if current_notes else learning_objectives - - # Add instructions section with blue vertical accent bar - instructions_y = 2.4 - add_shape(main_slide, MSO_SHAPE.RECTANGLE, 0.7, instructions_y + 0.1, 0.1, 2.0, - fill_color=COLORS["activity_blue"]) - - # Add "Instructions:" heading - add_text_box(main_slide, "Instructions:", 0.9, instructions_y, 8.3, 0.4, - font_size=22, bold=True, color=COLORS["text"]) - - # Add numbered instructions - instructions = activity.get("instructions", []) - - # Create a text box for instructions - instructions_textbox = main_slide.shapes.add_textbox( - Inches(0.9), - Inches(instructions_y + 0.5), - Inches(8.3), - Inches(1.5) - ) - instructions_frame = instructions_textbox.text_frame - instructions_frame.word_wrap = True - - # Add each instruction as a numbered point - for idx, instruction in enumerate(instructions): - if idx > 0: - p = instructions_frame.add_paragraph() - else: - p = instructions_frame.paragraphs[0] - - # Format as numbered list - instruction_text = instruction if isinstance(instruction, str) else json.dumps(instruction) - p.text = f"{idx + 1}. {instruction_text}" - - # Format the text - for run in p.runs: - run.font.size = Pt(16) - run.font.color.rgb = COLORS["text"] - - # Add orange triangle in bottom left corner - triangle = add_shape(main_slide, MSO_SHAPE.RIGHT_TRIANGLE, - 0, SLIDE_HEIGHT - 1.5, 1.5, 1.5, - fill_color=COLORS["activity_orange"]) - - # Add footer with blue divider line - add_shape(main_slide, MSO_SHAPE.RECTANGLE, 0.5, FOOTER_Y - 0.05, 9.0, 0.01, - fill_color=COLORS["primary_light"]) - - # Add presentation title on left side of footer - presentation_title = content.get("title", "Optimizing Neural Networks on Gaudi-3 AI Accelerator") - add_text_box(main_slide, presentation_title, 0.5, FOOTER_Y, 8.0, 0.3, - font_size=10, color=COLORS["primary"], italic=True) - - # Add slide number on right side - slide_number = slide_count_offset + (act_idx * 2) + 1 # Each activity has 2 slides - add_text_box(main_slide, f"{slide_number}", 9.0, FOOTER_Y, 0.5, 0.3, - font_size=10, color=COLORS["primary"], - alignment=PP_ALIGN.RIGHT) - - # Add materials section with green vertical accent bar - materials_y = 2.4 - add_shape(materials_slide, MSO_SHAPE.RECTANGLE, 0.7, materials_y + 0.1, 0.1, 2.0, - fill_color=COLORS["activity_green"]) - - # Add "Materials needed:" heading - add_text_box(materials_slide, "Materials needed:", 0.9, materials_y, 8.3, 0.4, - font_size=22, bold=True, color=COLORS["text"]) - - # Add materials list - dynamically from the backend data - materials = activity.get("materials", ["Gaudi-3 optimization tools", "Neural network models"]) - - # Create a text box for materials - materials_textbox = materials_slide.shapes.add_textbox( - Inches(0.9), - Inches(materials_y + 0.5), - Inches(8.3), - Inches(1.5) - ) - materials_frame = materials_textbox.text_frame - materials_frame.word_wrap = True - - # Add each material as a bullet point - for idx, material in enumerate(materials): - if idx > 0: - p = materials_frame.add_paragraph() - else: - p = materials_frame.paragraphs[0] - - # Format as bullet list - material_text = material if isinstance(material, str) else json.dumps(material) - p.text = f"• {material_text}" - - # Format the text - for run in p.runs: - run.font.size = Pt(16) - run.font.color.rgb = COLORS["text"] - - # Add orange triangle in bottom left corner - triangle = add_shape(materials_slide, MSO_SHAPE.RIGHT_TRIANGLE, - 0, SLIDE_HEIGHT - 1.5, 1.5, 1.5, - fill_color=COLORS["activity_orange"]) - - # Add footer with blue divider line - add_shape(materials_slide, MSO_SHAPE.RECTANGLE, 0.5, FOOTER_Y - 0.05, 9.0, 0.01, - fill_color=COLORS["primary_light"]) - - # Add presentation title on left side of footer - add_text_box(materials_slide, presentation_title, 0.5, FOOTER_Y, 8.0, 0.3, - font_size=10, color=COLORS["primary"], italic=True) - - # Add slide number on right side (increment by 1 from the main slide) - slide_number = slide_count_offset + (act_idx * 2) + 2 # +2 for the materials slide - add_text_box(materials_slide, f"{slide_number}", 9.0, FOOTER_Y, 0.5, 0.3, - font_size=10, color=COLORS["primary"], - alignment=PP_ALIGN.RIGHT) - - return slides - -def create_quiz_slides(prs, content, total_slides): - """Create enhanced quiz question and answer slides with visual improvements based on the new design.""" - assessment_ideas = content.get("assessmentIdeas", []) - slides = [] - - # Calculate slide count offset - slide_count_offset = ( - 2 + # Learning Outcomes + first Key Terms slide - len(content.get("keyTerms", [])) // 4 - 1 + # Additional Key Terms slides - len([s for i, s in enumerate(content.get("slides", [])) if i != 1]) + # Content slides (excluding second slide) - len(content.get("activities", [])) * 2 # Activity slides (2 per activity) - + 1 - ) - - quiz_slide_count = 0 - - for idea_idx, idea in enumerate(assessment_ideas): - idea_type = idea.get("type", "Assessment") - is_quiz = "quiz" in idea_type.lower() - - if not is_quiz: - continue - - example_questions = idea.get("exampleQuestions", []) - - for q_idx, question in enumerate(example_questions): - question_text = question.get("question", "Example question") - options = question.get("options", []) - - if not options: - continue - - # Create question slide with enhanced styling - q_slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - slides.append(q_slide) - quiz_slide_count += 1 - - # Add full-width blue header bar - add_shape(q_slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, 1.0, - fill_color=COLORS["primary"]) - - # Add slide title in the blue header - add_text_box(q_slide, f"Quiz Question {q_idx + 1}", 0.5, 0.2, 9.0, 0.6, - font_size=36, bold=True, color=COLORS["text_light"], - alignment=PP_ALIGN.CENTER, vertical_alignment=MSO_ANCHOR.MIDDLE) - - # Add the question text with enhanced styling - light gray rounded rectangle - question_box = add_shape(q_slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.5, 1.1, 9.0, 0.8, - fill_color=COLORS["light"], - line_color=COLORS["light"], # Same color as fill for seamless look - line_width=1, shadow=False) - - # Add the question text - add_text_box(q_slide, question_text, 0.7, 1.2, 8.6, 0.6, - font_size=20, bold=True, color=COLORS["text"]) - - # Add options in a 2x2 grid with enhanced styling - options_per_row = 2 - option_width = 4.3 - option_height = 1.0 # Reduced height for cleaner look - option_gap = 0.4 - start_y = 2.2 # Adjusted starting position - - for opt_idx, option in enumerate(options): - row = opt_idx // options_per_row - col = opt_idx % options_per_row - option_x = 0.5 + col * (option_width + option_gap) - option_y = start_y + row * (option_height + 0.4) - - # Add option box with enhanced styling - light gray rounded rectangle - option_box = add_shape(q_slide, MSO_SHAPE.ROUNDED_RECTANGLE, - option_x, option_y, option_width, option_height, - fill_color=COLORS["light"], - line_color=COLORS["light"], # Same color as fill for seamless look - line_width=1, shadow=False) - - # Add option letter in a blue circle with enhanced styling - circle_size = 0.6 # Slightly smaller circle - circle_x = option_x + 0.2 - circle_y = option_y + (option_height - circle_size) / 2 # Center vertically - - circle = add_shape(q_slide, MSO_SHAPE.OVAL, - circle_x, circle_y, circle_size, circle_size, - fill_color=COLORS["primary"]) # Blue circle - - # Add letter - ensure it's centered in the circle - letter = chr(65 + opt_idx) # A, B, C, D... - letter_textbox = add_text_box(q_slide, letter, circle_x, circle_y, circle_size, circle_size, - font_size=24, bold=True, color=COLORS["text_light"], - alignment=PP_ALIGN.CENTER, vertical_alignment=MSO_ANCHOR.MIDDLE) - - # Add option text with improved styling - centered vertically and horizontally - text_x = circle_x + circle_size + 0.2 - text_width = option_width - (text_x - option_x) - 0.2 - - # Create text box for option text with center alignment - text_box = add_text_box(q_slide, option, text_x, option_y, - text_width, option_height, - font_size=18, color=COLORS["text"], - alignment=PP_ALIGN.CENTER, - vertical_alignment=MSO_ANCHOR.MIDDLE) - - # Add footer with presentation title and slide number - presentation_title = content.get("title", "Untitled Presentation") - slide_number = slide_count_offset + quiz_slide_count - - # Add footer - add_footer(q_slide, presentation_title, slide_number, total_slides, THEME["footer_style"]) - - # Create answer slide with enhanced styling to match the image - a_slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - slides.append(a_slide) - quiz_slide_count += 1 - - # Add full-width blue header bar (taller than before) - add_shape(a_slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, 1.2, - fill_color=COLORS["primary"]) - - # Add slide title in the blue header - centered - add_text_box(a_slide, f"Quiz Answer {q_idx + 1}", 0.5, 0.3, 9.0, 0.6, - font_size=40, bold=True, color=COLORS["text_light"], - alignment=PP_ALIGN.CENTER, vertical_alignment=MSO_ANCHOR.MIDDLE) - - # Add the question text as a reminder with enhanced styling - light gray rounded rectangle - question_box = add_shape(a_slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.5, 1.4, 9.0, 0.8, - fill_color=COLORS["light"], - line_color=COLORS["light"], - line_width=1, shadow=True) - - add_text_box(a_slide, f"Question: {question_text}", 0.7, 1.5, 8.6, 0.6, - font_size=18, italic=True, color=COLORS["text"]) - - # Add correct answer with enhanced styling - dark background with green border - correct_answer = question.get("correctAnswer", "") - if correct_answer: - # Add a highlight box for the answer with enhanced styling - answer_box = add_shape(a_slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.5, 2.4, 9.0, 1.0, - fill_color=COLORS["dark_alt"], - line_color=COLORS["success"], - line_width=3, shadow=True) - - # Add "Correct Answer:" text with enhanced styling - yellow/gold text - add_text_box(a_slide, "Correct Answer:", 0.7, 2.5, 8.6, 0.4, - font_size=20, bold=True, color=COLORS["warning"]) - - # Add the answer text with enhanced styling - white text - add_text_box(a_slide, correct_answer, 0.7, 2.9, 8.6, 0.4, - font_size=18, color=COLORS["text_light"]) - - # Add explanation section with enhanced styling - explanation = question.get("explanation", "") - if explanation: - # Add explanation container with enhanced styling - light background with subtle border - explanation_box = add_shape(a_slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.5, 3.6, 9.0, 1.4, - fill_color=COLORS["light"], - line_color=COLORS["primary_light"], - line_width=1, shadow=True) - - # Add blue vertical accent bar on the left - add_shape(a_slide, MSO_SHAPE.RECTANGLE, 0.7, 3.7, 0.1, 1.2, - fill_color=COLORS["primary"]) - - # Add explanation title - add_text_box(a_slide, "Explanation:", 0.9, 3.7, 8.5, 0.4, - font_size=20, bold=True, color=COLORS["text"]) - - # Format and add the explanation with improved styling - explanation_text = explanation if isinstance(explanation, str) else json.dumps(explanation) - add_text_box(a_slide, explanation_text, 0.9, 4.2, 8.5, 0.7, - font_size=16, color=COLORS["text"]) - - # Add footer with presentation title on left and slide number on right - presentation_title = content.get("title", "Untitled Presentation") - slide_number = slide_count_offset + quiz_slide_count - - # Add divider line above footer - add_shape(a_slide, MSO_SHAPE.RECTANGLE, 0.5, FOOTER_Y - 0.05, 9.0, 0.01, - fill_color=COLORS["primary_light"]) - - # Add presentation title on left side of footer - add_text_box(a_slide, presentation_title, 0.5, FOOTER_Y, 8.0, 0.3, - font_size=10, color=COLORS["primary"], italic=True) - - # Add slide number on right side - add_text_box(a_slide, f"{slide_number}", 9.0, FOOTER_Y, 0.5, 0.3, - font_size=10, color=COLORS["primary"], - alignment=PP_ALIGN.RIGHT) - - return slides - -def create_discussion_slides(prs, content, total_slides): - """Create discussion question slides with guidance on the same slide and answer slides.""" - assessment_ideas = content.get("assessmentIdeas", []) - slides = [] - - # Calculate slide count offset including quiz slides - slide_count_offset = ( - 2 + # Learning Outcomes + first Key Terms slide - len(content.get("keyTerms", [])) // 4 - 1 + # Additional Key Terms slides - len([s for i, s in enumerate(content.get("slides", [])) if i != 1]) + # Content slides (excluding second slide) - len(content.get("activities", [])) * 2 # Activity slides (2 per activity) - + 1 - ) - - # Count quiz slides - quiz_count = 0 - for idea in content.get("assessmentIdeas", []): - if "quiz" in idea.get("type", "").lower(): - quiz_count += len([q for q in idea.get("exampleQuestions", []) if q.get("options")]) * 2 - - discussion_slide_count = 0 - - for idea_idx, idea in enumerate(assessment_ideas): - idea_type = idea.get("type", "Assessment") - is_discussion = "discussion" in idea_type.lower() - - if not is_discussion: - continue - - example_questions = idea.get("exampleQuestions", []) - - for q_idx, question in enumerate(example_questions): - question_text = question.get("question", "Example question") - guidance = question.get("correctAnswer", "") - - # Create question slide with guidance - q_slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - slides.append(q_slide) - discussion_slide_count += 1 - - # Add header bar - add_shape(q_slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, 0.8, - fill_color=COLORS["primary"]) - - # Add slide title - add_text_box(q_slide, f"Discussion Question {q_idx + 1}", 0.5, 0.1, 9.0, 0.6, - font_size=32, bold=True, color=COLORS["text_light"]) - - # Add the question text in a rounded box at the top - question_box = add_shape(q_slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.5, 1.1, 9.0, 0.8, - fill_color=COLORS["light"], - line_color=COLORS["light"], - line_width=1, shadow=True) - - question_text_box = add_text_box(q_slide, question_text, 0.7, 1.2, 8.6, 0.6, - font_size=20, bold=True, color=COLORS["text"]) - - # Dynamically calculate the height of the question text - question_text_height = estimate_text_height(question_text, 20, 8.6) - next_text_y = 1.2 + question_text_height + 1.2 # Add some padding - - # Add discussion prompt dynamically below the question - add_shape(q_slide, MSO_SHAPE.RECTANGLE, 0.7, next_text_y, 0.1, 0.4, - fill_color=COLORS["primary"]) - add_text_box(q_slide, "Group Discussion:", 0.9, next_text_y, 8.5, 0.4, - font_size=20, bold=True, color=COLORS["primary"]) - - # Add discussion instructions dynamically below the prompt - instructions_y = next_text_y + 0.5 - instructions = "Discuss this question with your group and prepare to share your thoughts with the class." - add_text_box(q_slide, instructions, 0.9, instructions_y, 8.5, 0.4, - font_size=18, color=COLORS["text"]) - - # Add footer with presentation title and slide number - presentation_title = content.get("title", "Untitled Presentation") - slide_number = slide_count_offset + quiz_count + discussion_slide_count - - # Add footer - add_footer(q_slide, presentation_title, slide_number, total_slides, THEME["footer_style"]) - - # Create answer slide with facilitator guidance - a_slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - slides.append(a_slide) - discussion_slide_count += 1 - - # Add full-width blue header bar - add_shape(a_slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, 0.8, - fill_color=COLORS["primary"]) - - # Add slide title - add_text_box(a_slide, f"Facilitator Guidance: Question {q_idx + 1}", 0.5, 0.1, 9.0, 0.6, - font_size=32, bold=True, color=COLORS["text_light"]) - - # Add the question text as a reminder - question_box = add_shape(a_slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.5, 1.1, 9.0, 0.8, - fill_color=COLORS["light"], - line_color=COLORS["light"], - line_width=1, shadow=True) - - add_text_box(a_slide, f"Question: {question_text}", 0.7, 1.2, 8.6, 0.6, - font_size=18, italic=True, color=COLORS["text"]) - - # Add facilitator guidance section dynamically - guidance_y = 1.2 + question_text_height + 0.7 - if guidance: - # Add a guidance container with enhanced styling - guidance_box = add_shape(a_slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.5, guidance_y, 9.0, 2.8, - fill_color=COLORS["light"], - line_color=COLORS["accent2"], - line_width=2, shadow=True) - - # Add accent bar on the left - add_shape(a_slide, MSO_SHAPE.RECTANGLE, 0.7, guidance_y + 0.1, 0.1, 2.5, - fill_color=COLORS["accent2"]) - - # Add "Facilitator Guidance:" heading - add_text_box(a_slide, "Facilitator Guidance:", 0.9, guidance_y + 0.1, 8.5, 0.4, - font_size=20, bold=True, color=COLORS["accent2"]) - - # Format and add the guidance with improved styling - guidance_text = guidance if isinstance(guidance, str) else json.dumps(guidance) - add_text_box(a_slide, guidance_text, 0.9, guidance_y + 0.6, 8.3, 1.5, - font_size=16, color=COLORS["text"]) - - # Add footer - slide_number = slide_count_offset + quiz_count + discussion_slide_count - add_footer(a_slide, presentation_title, slide_number, total_slides, THEME["footer_style"]) - - return slides - -def create_further_readings_slides(prs, content, total_slides): - """Create further readings slides with version 7 styling.""" - readings = content.get("furtherReadings", []) - if not readings: - return [] - - slides = [] - - # Calculate slide count offset - slide_count_offset = ( - 2 + # Learning Outcomes + first Key Terms slide - len(content.get("keyTerms", [])) // 4 - 1 + # Additional Key Terms slides - len([s for i, s in enumerate(content.get("slides", [])) if i != 1]) + # Content slides (excluding second slide) - len(content.get("activities", [])) * 2 # Activity slides (2 per activity) - + 1 - ) - - # Count quiz slides - quiz_count = 0 - for idea in content.get("assessmentIdeas", []): - if "quiz" in idea.get("type", "").lower(): - quiz_count += len([q for q in idea.get("exampleQuestions", []) if q.get("options")]) * 2 - - # Count discussion slides - now 2 slides per question (with guidance) - discussion_count = 0 - for idea in content.get("assessmentIdeas", []): - if "discussion" in idea.get("type", "").lower(): - discussion_count += len(idea.get("exampleQuestions", []) if idea.get("exampleQuestions") else []) * 2 - - # Calculate how many slides we need (2 readings per slide) - readings_per_slide = 2 - total_readings = len(readings) - slides_needed = (total_readings + readings_per_slide - 1) // readings_per_slide - - for slide_idx in range(slides_needed): - slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - slides.append(slide) - - # Add header bar (version 7 style - blue header) - add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, 0.8, - fill_color=COLORS["primary"]) - - # Add title - title = "Further Readings & Resources" - if slide_idx > 0: - title += f" (continued)" - add_text_box(slide, title, 0.5, 0.1, 9, 0.6, - font_size=32, bold=True, color=COLORS["text_light"]) - - # Add content container - if THEME["content_box_shadow"]: - content_container = add_shape(slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.3, 1.0, 9.4, FOOTER_Y - 1.2, - fill_color=COLORS["light_alt"], opacity=0.7, - line_color=COLORS["primary_light"], line_width=1, - shadow=True) - - # Get readings for this slide - start_idx = slide_idx * readings_per_slide - end_idx = min(start_idx + readings_per_slide, total_readings) - readings_for_slide = readings[start_idx:end_idx] - - # Calculate the starting Y position for the readings - y_position = 1.2 - - # Add readings with version 7 styling - for i, reading in enumerate(readings_for_slide): - # Add reading title with accent bar - reading_title = reading.get("title", "Untitled Reading") - add_shape(slide, MSO_SHAPE.RECTANGLE, 0.7, y_position, 0.1, 0.4, - fill_color=COLORS["primary"]) - add_text_box(slide, reading_title, 0.9, y_position, 8.3, 0.4, - font_size=20, bold=True, color=COLORS["primary"]) - y_position += 0.5 - - # Add reading URL or author - reading_author = reading.get("author", "") - reading_description = reading.get("readingDescription", "") - add_text_box(slide, f"Author: {reading_author}", 0.9, y_position, 8.3, 0.3, - font_size=16, italic=True, color=COLORS["primary"]) - y_position += 0.4 - - # Add reading description - add_text_box(slide, reading_description, 0.9, y_position, 8.3, 0.6, - font_size=16, color=COLORS["text"]) - - # Add separator line if not the last reading - if i < len(readings_for_slide) - 1: - y_position += 0.8 - add_shape(slide, MSO_SHAPE.RECTANGLE, 0.7, y_position, 8.5, 0.01, - fill_color=COLORS["primary_light"], opacity=0.5) - y_position += 0.2 - else: - y_position += 0.8 - - # Add footer - slide_number = slide_count_offset + quiz_count + discussion_count + slide_idx + 1 - add_footer(slide, content.get("title", "Untitled Presentation"), slide_number, total_slides, THEME["footer_style"]) - - return slides - -def create_facilitation_notes_slide(prs, content, total_slides): - """Create a dedicated slide that summarizes all facilitation notes.""" - activities = content.get("activities", []) - - # Calculate slide count offset - slide_count_offset = ( - 2 + # Learning Outcomes + first Key Terms slide - len(content.get("keyTerms", [])) // 4 - 1 + # Additional Key Terms slides - len([s for i, s in enumerate(content.get("slides", [])) if i != 1]) + # Content slides (excluding second slide) - len(content.get("activities", [])) * 2 # Activity slides (2 per activity) - + 1 - + len(content.get("furtherReadings", [])) // 2 # Further readings slides - ) - - # Count quiz slides - quiz_count = 0 - for idea in content.get("assessmentIdeas", []): - if "quiz" in idea.get("type", "").lower(): - quiz_count += len([q for q in idea.get("exampleQuestions", []) if q.get("options")]) * 2 - - # Count discussion slides - now 2 slides per question (with guidance) - discussion_count = 0 - for idea in content.get("assessmentIdeas", []): - if "discussion" in idea.get("type", "").lower(): - discussion_count += len(idea.get("exampleQuestions", []) if idea.get("exampleQuestions") else []) * 2 - - # Check if there are any activities with facilitation notes - has_facilitation_notes = False - for activity in activities: - description = activity.get("description", "") - _, facilitation_notes, _ = extract_facilitation_content(description) - if facilitation_notes: - has_facilitation_notes = True - break - - # If no facilitation notes, don't create the slide - if not has_facilitation_notes: - return None - - # Create the slide - slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - - # Add header bar - add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, 0.8, - fill_color=COLORS["primary"]) - - # Add title - add_text_box(slide, "Facilitation Notes Summary", 0.5, 0.1, 9.0, 0.6, - font_size=32, bold=True, color=COLORS["text_light"]) - - # Create a container for the notes - add_shape(slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.5, 1.0, 9.0, FOOTER_Y - 1.2, - fill_color=COLORS["light_alt"], - line_color=COLORS["primary_light"], - line_width=1, shadow=True) - - # Add facilitation notes from each activity - y_position = 1.2 - for idx, activity in enumerate(activities): - title = activity.get("title", "") - description = activity.get("description", "") - - _, facilitation_notes, _ = extract_facilitation_content(description) - - if facilitation_notes: - # Add activity title - add_shape(slide, MSO_SHAPE.RECTANGLE, 0.7, y_position, 0.1, 0.4, - fill_color=COLORS["activity_green"]) - add_text_box(slide, title, 0.9, y_position, 8.3, 0.4, - font_size=18, bold=True, color=COLORS["primary"]) - y_position += 0.5 - - # Add facilitation notes - notes_text = facilitation_notes.replace("Facilitation Notes: ", "") - add_text_box(slide, notes_text, 0.9, y_position, 8.3, 0.6, - font_size=14, color=COLORS["text"]) - y_position += 0.8 - - # Add separator if not the last item - if idx < len(activities) - 1: - add_shape(slide, MSO_SHAPE.RECTANGLE, 0.7, y_position, 8.5, 0.01, - fill_color=COLORS["primary_light"], opacity=0.5) - y_position += 0.3 - - # Check if we need a new slide (if y_position is too large) - if y_position > FOOTER_Y - 0.5: - # Add "continued on next slide" text - add_text_box(slide, "Continued on next slide...", 0.9, y_position - 0.3, 8.3, 0.3, - font_size=12, italic=True, color=COLORS["text_muted"]) - - # Add footer - add_footer(slide, content.get("title", "Untitled Presentation"), - total_slides - 1, total_slides + 1, THEME["footer_style"]) - - # Create a new slide - slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - - # Add header bar - add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, 0.8, - fill_color=COLORS["primary"]) - - # Add title - add_text_box(slide, "Facilitation Notes Summary (Continued)", 0.5, 0.1, 9.0, 0.6, - font_size=32, bold=True, color=COLORS["text_light"]) - - # Create a container for the notes - add_shape(slide, MSO_SHAPE.ROUNDED_RECTANGLE, - 0.5, 1.0, 9.0, FOOTER_Y - 1.2, - fill_color=COLORS["light_alt"], - line_color=COLORS["primary_light"], - line_width=1, shadow=True) - - # Reset y_position - y_position = 1.2 - - # Add footer - # Add footer - slide_number = slide_count_offset + quiz_count + discussion_count + 1 - add_footer(slide, content.get("title", "Untitled Presentation"), slide_number, total_slides, THEME["footer_style"]) - # add_footer(slide, content.get("title", "Untitled Presentation"), - # total_slides, total_slides + 1, THEME["footer_style"]) - - return slide - -def create_closing_slide(prs, content, total_slides, slide_number): - """Create an enhanced closing slide with visual elements.""" - slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout - - # Add gradient background if enabled - if THEME["use_gradients"]: - add_gradient_background(prs, slide, COLORS["gradient_start"], COLORS["gradient_end"], angle=135) - else: - # Add solid color background - add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, SLIDE_HEIGHT, - fill_color=COLORS["primary_dark"]) - - # Add decorative corner accents if enabled - if THEME["corner_accent"]: - add_corner_accent(slide, COLORS["accent1"], 2.0, "top-right") - add_corner_accent(slide, COLORS["accent2"], 1.5, "bottom-left") - - # Add title with enhanced styling - title = "Thank You!" - title_box = add_text_box(slide, title, 0.5, 1.5, 9, 1.5, font_size=48, - bold=True, color=COLORS["text_light"], - alignment=PP_ALIGN.CENTER, shadow=True) - - # Add subtitle with course title - presentation_title = content.get("title", "Untitled Presentation") - subtitle = f"Presentation: {presentation_title}" - - # Add a decorative line between title and subtitle - line_y = 3.2 - add_shape(slide, MSO_SHAPE.RECTANGLE, 3.5, line_y, 3.0, 0.02, - fill_color=COLORS["accent2"]) - - # Add subtitle text - subtitle_box = add_text_box(slide, subtitle, 0.5, line_y + 0.2, 9, 0.5, - font_size=28, italic=True, color=COLORS["text_light"], - alignment=PP_ALIGN.CENTER) - - # Add footer - add_footer(slide, content.get("title", "Untitled Presentation"), slide_number, total_slides, THEME["footer_style"]) - - return slide - -def calculate_total_slides(content): - """Calculate the total number of slides based on the content.""" - total = 0 - - # Title slide - total += 1 - - # Agenda slides - agenda_items = [] - agenda_items.append({"title": "Introduction", "items": [ - "Learning Outcomes", - "Key Terms & Concepts" - ]}) - - content_slides = [] - for slide_content in content.get("slides", []): - if slide_content.get("title", ""): - title = clean_slide_title(slide_content.get("title", "")) - if title: - content_slides.append(title) - - if content_slides: - agenda_items.append({"title": "Main Content", "items": content_slides}) - - activities = [] - for idx, activity in enumerate(content.get("activities", [])): - activity_title = activity.get("title", "") - activities.append(activity_title) - - if activities: - agenda_items.append({"title": "Activities", "items": activities}) - - knowledge_items = [] - quiz_count = 0 - discussion_count = 0 - - for idea in content.get("assessmentIdeas", []): - idea_type = idea.get("type", "").lower() - if "quiz" in idea_type: - quiz_count += len([q for q in idea.get("exampleQuestions", []) if q.get("options")]) - elif "discussion" in idea_type: - discussion_count += len(idea.get("exampleQuestions", []) if idea.get("exampleQuestions") else []) - - if quiz_count > 0: - knowledge_items.append(f"Quiz Questions ({quiz_count})") - - if discussion_count > 0: - knowledge_items.append(f"Discussion Questions ({discussion_count})") - - if knowledge_items: - agenda_items.append({"title": "Test Your Knowledge", "items": knowledge_items}) - - if content.get("furtherReadings", []): - agenda_items.append({"title": "Additional Resources", "items": ["Further Readings & Resources"]}) - - section_height = 0.5 - item_height = 0.35 - - total_height_needed = 0 - for section in agenda_items: - total_height_needed += section_height - total_height_needed += len(section["items"]) * item_height - - available_height = FOOTER_Y - 1.2 - - slides_needed = math.ceil(total_height_needed / available_height) - total += slides_needed - - # Learning outcomes slide - total += 1 - - # Key terms slides - key_terms = content.get("keyTerms", []) - key_terms_per_slide = 4 - if key_terms: - total += (len(key_terms) + key_terms_per_slide - 1) // key_terms_per_slide - - # Content slides - total += len(content.get("slides", [])) - - # Activity slides (2 per activity) - total += len(content.get("activities", [])) * 2 - - # Assessment slides - for idea in content.get("assessmentIdeas", []): - if "quiz" in idea.get("type", "").lower(): - total += len([q for q in idea.get("exampleQuestions", []) if q.get("options")]) * 2 - - # Count discussion slides - now 2 slides per question (with guidance) - for idea in content.get("assessmentIdeas", []): - if "discussion" in idea.get("type", "").lower(): - total += len(idea.get("exampleQuestions", []) if idea.get("exampleQuestions") else []) * 2 - - # Further Reading slides - readings = content.get("furtherReadings", []) - readings_per_slide = 2 - if readings: - total += (len(readings) + readings_per_slide - 1) // readings_per_slide - - # Closing slide - total += 1 - - # Check if we need a facilitation notes slide - has_facilitation_notes = False - for activity in content.get("activities", []): - description = activity.get("description", "") - for pattern in ["Facilitation notes:", "Facilitation Notes:", "Facilitator notes:"]: - if pattern in description: - has_facilitation_notes = True - break - if has_facilitation_notes: - break +import os +import re +import sys +from typing import Any, Dict - # Add facilitation notes slide if needed - if has_facilitation_notes: - total += 1 - - return total +from pptx_builder.builder import cli_build as _cli_build, create_pptx as _create_pptx +__all__ = ["create_pptx", "main"] -# Update the main function to handle multiple agenda slides -def main(): - """Main function to generate PowerPoint presentation.""" - # Define base directory for all content and output files - BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +def main() -> None: # pragma: no cover - thin glue + if len(sys.argv) not in (3, 4): + print("Usage: python generate_pptx.py [lang]") + sys.exit(1) - # Define allowed content paths with fixed allowed names - ALLOWED_CONTENT_PATHS = { - "default": os.path.join(BASE_DIR, "content", "default_content.json"), + base_dir = os.path.dirname(os.path.abspath(__file__)) + allowed_content = { + "default": os.path.join(base_dir, "content", "default_content.json") } - # Define allowed output directory - OUTPUT_DIR = os.path.join(BASE_DIR, "output") - if not os.path.exists(OUTPUT_DIR): - try: - os.makedirs(OUTPUT_DIR) - except OSError as e: - print(f"Error creating output directory: {e}") - sys.exit(1) + content_key, output_name = sys.argv[1], sys.argv[2] + language = sys.argv[3] if len(sys.argv) == 4 else "en" - if len(sys.argv) != 3: - print("Usage: python generate_pptx.py ") + if content_key not in allowed_content: + print("Unknown content key") sys.exit(1) - - content_key = sys.argv[1] - output_name = sys.argv[2] - - # Strictly validate output name (only allow alphanumeric, underscore, hyphen) - if not re.match(r'^[a-zA-Z0-9_\-]+$', output_name): + if not re.match(r"^[a-zA-Z0-9_\-]+$", output_name): print("Invalid output name. Only alphanumeric, underscore, hyphen allowed.") sys.exit(1) + if not output_name.endswith(".pptx"): + output_name += ".pptx" - # Look up content path in the allowed paths dictionary - if content_key == "default": - content_path = ALLOWED_CONTENT_PATHS["default"] - else: - sys.exit(1) - - # Ensure content path exists - if not os.path.exists(content_path): - print(f"Content file does not exist: {content_path}") - sys.exit(1) - - # Normalize paths to absolute paths to prevent path traversal - content_path = os.path.abspath(content_path) - - # Verify content path is within allowed directories (defense in depth) - if not any(content_path.startswith(os.path.abspath(allowed_path)) - for allowed_path in [ALLOWED_CONTENT_PATHS["default"]]): - print("Security violation: Content path outside of allowed directory") - sys.exit(1) - - # Construct output path with sanitized filename - if not output_name.endswith('.pptx'): - output_name += '.pptx' - - output_path = os.path.join(OUTPUT_DIR, output_name) - - # Normalize output path - output_path = os.path.abspath(output_path) - - # Verify output path is within OUTPUT_DIR - if not output_path.startswith(os.path.abspath(OUTPUT_DIR)): + out_dir = os.path.join(base_dir, "output") + os.makedirs(out_dir, exist_ok=True) + output_path = os.path.abspath(os.path.join(out_dir, output_name)) + if not output_path.startswith(os.path.abspath(out_dir)): print("Security violation: Output path outside of allowed directory") sys.exit(1) - # Load content from JSON file - try: - with open(content_path, 'r') as f: - content = json.load(f) - except json.JSONDecodeError: - print("Invalid JSON format in content file") - sys.exit(1) - except Exception as e: - print(f"Error loading content file: {e}") - sys.exit(1) - - - # Calculate total number of slides for internal tracking - total_slides = calculate_total_slides(content) - - # Create presentation - prs = Presentation() - - # Set slide dimensions to 16:9 aspect ratio - prs.slide_width = Inches(SLIDE_WIDTH) - prs.slide_height = Inches(SLIDE_HEIGHT) - - # Create title slide (no page number) - title_slide = create_title_slide(prs, content) - - # Create agenda slide (page 2) - agenda_slides = create_agenda_slide(prs, content, total_slides) - - # Create learning outcomes slide (page 1) - learning_outcomes_slide = create_learning_outcomes_slide(prs, content, total_slides) - - # Key terms slides (starting from page 2) - key_terms_slides = create_key_terms_slide(prs, content, total_slides) - - # Calculate the starting slide number for content slides - content_start_num = 2 + len(key_terms_slides) - - # Create content slides - content_slides = create_content_slides(prs, content, total_slides) - - # Calculate the starting slide number for activity slides - activity_start_num = content_start_num + len(content_slides) - - # Create activity slides - activity_slides = create_activity_slides(prs, content, total_slides) - - # Calculate the starting slide number for quiz slides - quiz_start_num = activity_start_num + len(activity_slides) - - # Create quiz slides - quiz_slides = create_quiz_slides(prs, content, total_slides) - - # Count quiz slides - quiz_count = len(quiz_slides) - - # Calculate the starting slide number for discussion slides - discussion_start_num = quiz_start_num + quiz_count - - # Create discussion slides with facilitator guidance - discussion_slides = create_discussion_slides(prs, content, total_slides) - - # Count discussion slides - now only 1 per question - discussion_count = len(discussion_slides) - - # Calculate the starting slide number for further readings - readings_start_num = discussion_start_num + discussion_count - - # Create further readings slides with version 7 styling - readings_slides = create_further_readings_slides(prs, content, total_slides) - - # Count readings slides - readings_count = len(readings_slides) - - # Calculate the final slide number - closing_num = readings_start_num + readings_count - - # Create facilitation notes summary slide if applicable - facilitation_slide = create_facilitation_notes_slide(prs, content, total_slides) - if facilitation_slide: - # Increment total_slides since we added a new slide - total_slides += 1 - # Update closing_num - closing_num += 1 - - # Create closing slide with updated page number - closing_slide = create_closing_slide(prs, content, total_slides, closing_num) - - # Save presentation - prs.save(output_path) + _cli_build(allowed_content[content_key], output_path, language) print(f"PowerPoint presentation saved to {output_path}") -if __name__ == "__main__": - main() - -def create_pptx(content: dict, output_path: str): - """ - Generate a PowerPoint presentation based on the provided content. - - Args: - content (dict): The content for the presentation. Expected keys include: - - title (str): Title of the presentation. - - slides (list): List of slides, where each slide is a dict with keys: - - heading (str): Slide heading. - - body (str): Slide body text. - output_path (str): The file path to save the generated PPTX file. - """ - try: - # Validate output path - BASE_DIR = os.path.dirname(os.path.abspath(__file__)) - - # Normalize output path to prevent path traversal attacks - normalized_output_path = os.path.abspath(output_path) - - # Verify the output path is within one of the allowed directories - # Get parent directory to check if it's in a system temp directory or the application's output directory - output_parent = os.path.dirname(normalized_output_path) - output_dir = os.path.join(BASE_DIR, "output") - - # Check if output is in system temp dir or the output dir - is_in_temp = output_parent.startswith(os.path.abspath(tempfile.gettempdir())) - is_in_output = normalized_output_path.startswith(os.path.abspath(output_dir)) - - if not (is_in_temp or is_in_output): - raise ValueError("Security violation: Output path must be in allowed directories") - - # Create a new PowerPoint presentation - prs = Presentation() - - # Set slide dimensions to 16:9 aspect ratio - prs.slide_width = Inches(SLIDE_WIDTH) - prs.slide_height = Inches(SLIDE_HEIGHT) - # Calculate total number of slides for internal tracking - total_slides = calculate_total_slides(content) +def create_pptx( + content: Dict[str, Any], output_path: str, language: str = "en" +) -> None: + """Re-export of the real builder function for legacy import paths.""" + _create_pptx(content, output_path, language) - # Create title slide - create_title_slide(prs, content) - # Create agenda slides - create_agenda_slide(prs, content, total_slides) - - # Create learning outcomes slide - create_learning_outcomes_slide(prs, content, total_slides) - - # Create key terms slides - create_key_terms_slide(prs, content, total_slides) - - # Create content slides - create_content_slides(prs, content, total_slides) - - # Create activity slides - create_activity_slides(prs, content, total_slides) - - # Create quiz slides - create_quiz_slides(prs, content, total_slides) - - # Create discussion slides - create_discussion_slides(prs, content, total_slides) - - # Create further readings slides - create_further_readings_slides(prs, content, total_slides) - - # Create facilitation notes slide if applicable - facilitation_slide = create_facilitation_notes_slide(prs, content, total_slides) - if facilitation_slide: - total_slides += 1 # Increment total slides if facilitation notes slide is added - - # Create closing slide - create_closing_slide(prs, content, total_slides, total_slides) - - # Save the presentation to the specified output path - prs.save(normalized_output_path) - except Exception as e: - print(f"Error generating PPTX: {e}") - raise \ No newline at end of file +if __name__ == "__main__": # pragma: no cover + main() diff --git a/backend/main.py b/backend/main.py index acf66da..1e5d9d1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,7 +1,6 @@ # Copyright (C) 2025 Intel Corporation # SPDX-License-Identifier: Apache-2.0 - -from fastapi import FastAPI, HTTPException, File, UploadFile +from fastapi import BackgroundTasks, FastAPI, HTTPException, File, UploadFile from pydantic import BaseModel import fitz # PyMuPDF from pathlib import Path @@ -12,11 +11,15 @@ from generate_image_embedding import generate_image_embedding from fastapi.responses import FileResponse, JSONResponse from generate_pptx import create_pptx +from generate_pptx import create_pptx from starlette.background import BackgroundTask import tempfile import imagehash from PIL import Image import io +import uuid +from typing import Dict +import json app = FastAPI() @@ -26,22 +29,10 @@ OUTPUT_DIR.mkdir(parents=True, exist_ok=True) -@app.post("/parse") -async def parse_pdf(file: UploadFile = File(...)): - """ - Endpoint to parse a PDF file uploaded via multipart/form-data. - Extracts images, generates captions and embeddings, and returns the data. - """ - temp_file_path = None +def process_pdf_to_file(job_id: str, pdf_path: str, filename: str): try: - # Create temp file with delete=False to avoid Windows file locking issues - with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as temp_file: - temp_file.write(await file.read()) - temp_file_path = temp_file.name - - print(f"DEBUG : Temporary PDF file created at: {temp_file_path}") - # Open the PDF file using PyMuPDF (now works on Windows since file is closed) - pdf_file = fitz.open(str(temp_file_path)) + print(f"Processing job {job_id}") + pdf_file = fitz.open(str(pdf_path)) image_data = [] image_order = 1 seen_hashes = set() @@ -88,33 +79,67 @@ async def parse_pdf(file: UploadFile = File(...)): # Prepare the response data response_data = { - "name": file.filename, + "name": filename, "details": f"Extracted {len(image_data)} images from the PDF.", "images": image_data, "text": extracted_text, } - return JSONResponse(content=response_data) + temp_dir = tempfile.gettempdir() + result_path = os.path.join(temp_dir, f"{job_id}.json") + with open(result_path, "w") as f: + json.dump(response_data, f) except Exception as e: - print(f"Error processing PDF: {e}") - raise HTTPException( - status_code=500, detail=f"An error occurred while processing the PDF: {e}" - ) + print(f"Error in processing pdf job_id: {job_id}: {e}") + finally: - # Clean up temporary file on Windows - if temp_file_path and os.path.exists(temp_file_path): - try: - os.unlink(temp_file_path) - print(f"DEBUG: Cleaned up temporary file: {temp_file_path}") - except Exception as cleanup_error: - print( - f"Warning: Failed to clean up temporary file {temp_file_path}: {cleanup_error}" - ) + try: + if os.path.exists(pdf_path): + os.remove(pdf_path) + except Exception as cleanup_err: + print(f"Warning: Failed to remove temporary PDF {pdf_path}: {cleanup_err}") + + +@app.post("/upload") +async def upload_file( + file: UploadFile = File(...), background_tasks: BackgroundTasks = None +): + try: + # Generate job ID + job_id = str(uuid.uuid4()) + tmp_dir = tempfile.gettempdir() + tmp_path = os.path.join(tmp_dir, f"{job_id}_{file.filename}") + + # Save uploaded file to /tmp + with open(tmp_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + # Schedule background PDF processing + background_tasks.add_task(process_pdf_to_file, job_id, tmp_path, file.filename) + + return {"jobID": job_id} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error uploading file: {e}") + + +@app.get("/result/{job_id}") +def get_result(job_id: str): + temp_dir = tempfile.gettempdir() + result_path = os.path.join(temp_dir, f"{job_id}.json") + if not os.path.exists(result_path): + return JSONResponse( + status_code=202, content={"message": "PDF processing not complete yet."} + ) + + with open(result_path, "r") as f: + result = json.load(f) + return result class PPTXRequest(BaseModel): content: dict + language: str | None = "en" def validate_and_transform_content(content: dict) -> dict: @@ -130,7 +155,7 @@ def validate_and_transform_content(content: dict) -> dict: """ # Ensure required keys exist with default values if missing transformed_content = { - "title": content.get("title", "Untitled Presentation"), + "title": content.get("title", ""), "contentType": content.get("contentType", "lecture"), "difficultyLevel": content.get("difficultyLevel", "intermediate"), "slides": content.get("slides", []), @@ -143,13 +168,13 @@ def validate_and_transform_content(content: dict) -> dict: # Validate slides structure for slide in transformed_content["slides"]: - slide.setdefault("title", "Untitled Slide") + slide.setdefault("title", "") slide.setdefault("content", []) slide.setdefault("notes", "") # Validate activities structure for activity in transformed_content["activities"]: - activity.setdefault("title", "Untitled Activity") + activity.setdefault("title", "") activity.setdefault("description", "") activity.setdefault("type", "Exercise") activity.setdefault("duration", "20 minutes") @@ -168,13 +193,13 @@ def validate_and_transform_content(content: dict) -> dict: # Validate key terms structure for term in transformed_content["keyTerms"]: - term.setdefault("term", "Untitled Term") - term.setdefault("definition", "No definition provided.") + term.setdefault("term", "") + term.setdefault("definition", "") # Validate further readings structure for reading in transformed_content["furtherReadings"]: - reading.setdefault("title", "Untitled Reading") - reading.setdefault("author", "Unknown Author") + reading.setdefault("title", "") + reading.setdefault("author", "") reading.setdefault("readingDescription", "") return transformed_content @@ -198,7 +223,10 @@ async def generate_pptx(request: PPTXRequest): print(temp_pptx_path) # Generate the PPTX file - create_pptx(transformed_content, temp_pptx_path) + lang = (request.language or "en").lower() + if lang not in ["en", "id"]: + lang = "en" + create_pptx(transformed_content, temp_pptx_path, lang) print(f"Temporary PPTX file created at: {temp_pptx_path}") if not os.path.exists(temp_pptx_path): diff --git a/backend/pptx_builder/__init__.py b/backend/pptx_builder/__init__.py new file mode 100644 index 0000000..4664da2 --- /dev/null +++ b/backend/pptx_builder/__init__.py @@ -0,0 +1 @@ +# pptx_builder package initialization diff --git a/backend/pptx_builder/builder.py b/backend/pptx_builder/builder.py new file mode 100644 index 0000000..aa51c6f --- /dev/null +++ b/backend/pptx_builder/builder.py @@ -0,0 +1,64 @@ +import os +import json +import tempfile +from pptx import Presentation +from pptx.util import Inches +from .constants import SLIDE_WIDTH, SLIDE_HEIGHT +from .localization import set_language, t +from .slide_counter import calculate_total_slides +from .sections import ( + create_title_slide, + create_agenda_slide, + create_learning_outcomes_slide, + create_key_terms_slide, + create_content_slides, + create_activity_slides, + create_quiz_slides, + create_discussion_slides, + create_further_readings_slides, + create_facilitation_notes_slide, + create_closing_slide, +) + + +def build_full_presentation(content, language="en"): + set_language(language) + total_slides = calculate_total_slides(content) + prs = Presentation() + prs.slide_width = Inches(SLIDE_WIDTH) + prs.slide_height = Inches(SLIDE_HEIGHT) + create_title_slide(prs, content) + create_agenda_slide(prs, content, total_slides) + create_learning_outcomes_slide(prs, content, total_slides) + create_key_terms_slide(prs, content, total_slides) + create_content_slides(prs, content, total_slides) + create_activity_slides(prs, content, total_slides) + create_quiz_slides(prs, content, total_slides) + create_discussion_slides(prs, content, total_slides) + create_further_readings_slides(prs, content, total_slides) + facilitation_slide = create_facilitation_notes_slide(prs, content, total_slides) + if facilitation_slide: + total_slides += 1 # update for closing slide numbering if needed + create_closing_slide(prs, content, total_slides, total_slides) + return prs + + +def create_pptx(content: dict, output_path: str, language: str = "en"): + base_dir = os.path.dirname(os.path.abspath(__file__)) + normalized_output_path = os.path.abspath(output_path) + allowed_output = os.path.abspath(os.path.join(base_dir, "..", "output")) + parent = os.path.dirname(normalized_output_path) + is_temp = parent.startswith(os.path.abspath(tempfile.gettempdir())) + is_out = normalized_output_path.startswith(allowed_output) + if not (is_temp or is_out): + raise ValueError( + "Security violation: Output path must be in allowed directories" + ) + prs = build_full_presentation(content, language) + prs.save(normalized_output_path) + + +def cli_build(content_path, output_path, language="en"): + with open(content_path, "r") as f: + content = json.load(f) + create_pptx(content, output_path, language) diff --git a/backend/pptx_builder/constants.py b/backend/pptx_builder/constants.py new file mode 100644 index 0000000..b32b8c6 --- /dev/null +++ b/backend/pptx_builder/constants.py @@ -0,0 +1,177 @@ +from pptx.dml.color import RGBColor + +# Dimensions & layout +SLIDE_WIDTH = 10 # inches +SLIDE_HEIGHT = 5.625 # inches +FOOTER_Y = 5.3 +CONTENT_START_Y = 1.5 +AVAILABLE_CONTENT_HEIGHT = FOOTER_Y - CONTENT_START_Y +MAIN_BULLET_INDENT = 0.5 +SUB_BULLET_INDENT = 1.0 +SUB_SUB_BULLET_INDENT = 1.5 + +THEME = { + "use_gradients": True, + "corner_accent": True, + "slide_border": False, + "content_box_shadow": True, + "modern_bullets": True, + "footer_style": "modern", # "modern" or "classic" +} + +COLORS = { + "primary": RGBColor(37, 99, 235), + "primary_light": RGBColor(96, 165, 250), + "primary_dark": RGBColor(30, 64, 175), + "secondary": RGBColor(79, 70, 229), + "secondary_light": RGBColor(139, 92, 246), + "secondary_dark": RGBColor(67, 56, 202), + "accent1": RGBColor(139, 92, 246), + "accent2": RGBColor(16, 185, 129), + "accent3": RGBColor(245, 158, 11), + "accent4": RGBColor(239, 68, 68), + "light": RGBColor(243, 244, 246), + "light_alt": RGBColor(249, 250, 251), + "dark": RGBColor(31, 41, 55), + "dark_alt": RGBColor(17, 24, 39), + "text": RGBColor(17, 24, 39), + "text_light": RGBColor(255, 255, 255), + "text_muted": RGBColor(107, 114, 128), + "success": RGBColor(16, 185, 129), + "warning": RGBColor(245, 158, 11), + "error": RGBColor(239, 68, 68), + "info": RGBColor(59, 130, 246), + "background": RGBColor(255, 255, 255), + "background_alt": RGBColor(249, 250, 251), + "royal_blue": RGBColor(65, 105, 225), + "medium_purple": RGBColor(147, 112, 219), + "dark_blue": RGBColor(26, 43, 60), + "teal": RGBColor(20, 184, 166), + "emerald": RGBColor(16, 185, 129), + "gradient_start": RGBColor(65, 105, 225), + "gradient_end": RGBColor(147, 112, 219), + "activity_purple": RGBColor(139, 92, 246), + "activity_blue": RGBColor(37, 99, 235), + "activity_green": RGBColor(16, 185, 129), + "activity_orange": RGBColor(249, 115, 22), +} + +GLOBAL_LANG = "en" + +LABELS = { + "en": { + "learningOutcomes": "Learning Outcomes", + "byTheEnd": "By the end of this {contentType}, you will be able to:", + "keyTerms": "Key Terms & Concepts", + "agenda": "Agenda", + "continued": " (continued)", + "agendaContinued": " (continued {idx}/{total})", + "introduction": "Introduction", + "mainContent": "Main Content", + "activities": "Activities", + "testYourKnowledge": "Test Your Knowledge", + "quizQuestions": "Quiz Questions ({count})", + "discussionQuestions": "Discussion Questions ({count})", + "additionalResources": "Additional Resources", + "furtherReadings": "Further Readings & Resources", + "activity": "Activity {num}: {title}", + "materialsSuffix": "(Materials)", + "type": "Type", + "duration": "Duration", + "instructions": "Instructions:", + "materialsNeeded": "Materials needed:", + "notesAvailable": "Notes Available", + "quizQuestion": "Quiz Question {num}", + "quizAnswer": "Quiz Answer {num}", + "question": "Question: {text}", + "correctAnswer": "Correct Answer:", + "explanation": "Explanation:", + "groupDiscussion": "Group Discussion:", + "groupInstruction": "Discuss this question with your group and prepare to share your thoughts with the class.", + "facilitatorGuidance": "Facilitator Guidance: Question {num}", + "author": "Author:", + "term": "Term", + "definition": "Definition", + "discussionQuestion": "Discussion Question {num}", + "thankYou": "Thank You!", + "presentation": "Presentation:", + "facilitationNotesSummary": "Facilitation Notes Summary", + "continuedNextSlide": "Continued on next slide...", + "facilitationNotesLabel": "Facilitation Notes:", + "learningObjectiveLabel": "Learning Objective:", + "successCriteriaLabel": "Success criteria:", + "untitledPresentation": "Untitled Presentation", + "untitledReading": "Untitled Reading", + "untitledActivity": "Untitled Activity", + "unknownAuthor": "Unknown Author", + "contentTypeNames": { + "lecture": "Lecture", + "tutorial": "Tutorial", + "workshop": "Workshop", + }, + "difficultyNames": { + "introductory": "Introductory Level", + "intermediate": "Intermediate Level", + "advanced": "Advanced Level", + }, + }, + "id": { + "learningOutcomes": "Capaian Pembelajaran", + "byTheEnd": "Di akhir {contentType} ini, Anda akan dapat:", + "keyTerms": "Istilah Kunci & Konsep", + "agenda": "Agenda", + "continued": " (lanjutan)", + "agendaContinued": " (lanjutan {idx}/{total})", + "introduction": "Pendahuluan", + "mainContent": "Konten Utama", + "activities": "Aktivitas", + "testYourKnowledge": "Uji Pengetahuan Anda", + "quizQuestions": "Soal Kuis ({count})", + "discussionQuestions": "Pertanyaan Diskusi ({count})", + "additionalResources": "Sumber Tambahan", + "furtherReadings": "Bacaan Lanjutan & Sumber", + "activity": "Aktivitas {num}: {title}", + "materialsSuffix": "(Perlengkapan)", + "type": "Jenis", + "duration": "Durasi", + "instructions": "Instruksi:", + "materialsNeeded": "Perlengkapan yang dibutuhkan:", + "notesAvailable": "Catatan Tersedia", + "quizQuestion": "Soal Kuis {num}", + "quizAnswer": "Jawaban Kuis {num}", + "question": "Pertanyaan: {text}", + "correctAnswer": "Jawaban Benar:", + "explanation": "Penjelasan:", + "groupDiscussion": "Diskusi Kelompok:", + "groupInstruction": "Diskusikan pertanyaan ini dengan kelompok Anda dan siapkan untuk berbagi pendapat dengan kelas.", + "facilitatorGuidance": "Panduan Fasilitator: Pertanyaan {num}", + "author": "Penulis:", + "term": "Istilah", + "definition": "Definisi", + "discussionQuestion": "Pertanyaan Diskusi {num}", + "thankYou": "Terima kasih!", + "presentation": "Presentasi:", + "facilitationNotesSummary": "Ringkasan Catatan Fasilitasi", + "continuedNextSlide": "Bersambung di slide berikutnya...", + "facilitationNotesLabel": "Catatan Fasilitasi:", + "learningObjectiveLabel": "Tujuan Pembelajaran:", + "successCriteriaLabel": "Kriteria keberhasilan:", + "untitledPresentation": "Presentasi Tanpa Judul", + "untitledReading": "Bacaan Tanpa Judul", + "untitledActivity": "Aktivitas Tanpa Judul", + "unknownAuthor": "Penulis Tidak Diketahui", + "contentTypeNames": { + "lecture": "Kuliah", + "tutorial": "Tutorial", + "workshop": "Lokakarya", + }, + "difficultyNames": { + "introductory": "Tingkat Dasar", + "intermediate": "Tingkat Menengah", + "advanced": "Tingkat Lanjutan", + }, + }, +} + +BULLET_MARKERS = ["•", "*", "-", "○", "◦", "▪", "▫", "◆", "◇", "►", "▻", "▶", "▷"] +SUB_BULLET_MARKERS = ["-", "○", "◦", "▪", "▫"] diff --git a/backend/pptx_builder/localization.py b/backend/pptx_builder/localization.py new file mode 100644 index 0000000..806c767 --- /dev/null +++ b/backend/pptx_builder/localization.py @@ -0,0 +1,13 @@ +from . import constants + + +def set_language(lang: str): + lang = (lang or "en").lower() + constants.GLOBAL_LANG = "id" if lang == "id" else "en" + + +def t(key: str, **kwargs): + label = constants.LABELS.get(constants.GLOBAL_LANG, {}).get(key, key) + if isinstance(label, dict): + return label + return label.format(**kwargs) if kwargs else label diff --git a/backend/pptx_builder/sections/__init__.py b/backend/pptx_builder/sections/__init__.py new file mode 100644 index 0000000..ddec6e3 --- /dev/null +++ b/backend/pptx_builder/sections/__init__.py @@ -0,0 +1,25 @@ +from .title import create_title_slide +from .agenda import create_agenda_slide +from .learning_outcomes import create_learning_outcomes_slide +from .key_terms import create_key_terms_slide +from .content import create_content_slides +from .activities import create_activity_slides +from .quiz import create_quiz_slides +from .discussion import create_discussion_slides +from .readings import create_further_readings_slides +from .facilitation import create_facilitation_notes_slide +from .closing import create_closing_slide + +__all__ = [ + "create_title_slide", + "create_agenda_slide", + "create_learning_outcomes_slide", + "create_key_terms_slide", + "create_content_slides", + "create_activity_slides", + "create_quiz_slides", + "create_discussion_slides", + "create_further_readings_slides", + "create_facilitation_notes_slide", + "create_closing_slide", +] diff --git a/backend/pptx_builder/sections/activities.py b/backend/pptx_builder/sections/activities.py new file mode 100644 index 0000000..069ab6c --- /dev/null +++ b/backend/pptx_builder/sections/activities.py @@ -0,0 +1,295 @@ +import json +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import MSO_ANCHOR, PP_ALIGN +from pptx.util import Inches, Pt +from ..constants import COLORS, SLIDE_WIDTH, SLIDE_HEIGHT, FOOTER_Y, THEME +from ..shapes import add_shape, add_text_box, add_footer +from ..localization import t +from ..utils import clean_activity_title, extract_facilitation_content + + +def create_activity_slides(prs, content, total_slides): + activities = content.get("activities", []) + if not activities: + return [] + slides = [] + slide_count_offset = ( + 2 + + len(content.get("keyTerms", [])) // 4 + - 1 + + len([s for i, s in enumerate(content.get("slides", [])) if i != 1]) + + 1 + ) + for act_idx, activity in enumerate(activities): + original_title = activity.get("title", "") or t("untitledActivity") + clean_title = clean_activity_title(original_title) + main_slide = prs.slides.add_slide(prs.slide_layouts[6]) + slides.append(main_slide) + materials_slide = prs.slides.add_slide(prs.slide_layouts[6]) + slides.append(materials_slide) + # Header bars + for slide in (main_slide, materials_slide): + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0, + 0, + SLIDE_WIDTH, + 0.8, + fill_color=COLORS["activity_blue"], + ) + add_text_box( + main_slide, + t("activity", num=act_idx + 1, title=clean_title), + 0.5, + 0.07, + 9.0, + 0.6, + font_size=22, + bold=True, + color=COLORS["text_light"], + ) + add_text_box( + materials_slide, + f"{t('activity', num=act_idx + 1, title=clean_title)} {t('materialsSuffix')}", + 0.5, + 0.07, + 9.0, + 0.6, + font_size=22, + bold=True, + color=COLORS["text_light"], + ) + # Badge + for slide in (main_slide, materials_slide): + add_shape( + slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.5, + 0.9, + 5.0, + 0.5, + fill_color=COLORS["activity_purple"], + ) + activity_type = activity.get("type", "Exercise") + activity_duration = activity.get("duration", "20 minutes") + type_duration_text = ( + f"{t('type')}: {activity_type} | {t('duration')}: {activity_duration}" + ) + for slide in (main_slide, materials_slide): + add_text_box( + slide, + type_duration_text, + 0.6, + 0.9, + 4.8, + 0.5, + font_size=16, + italic=True, + color=COLORS["text_light"], + vertical_alignment=MSO_ANCHOR.MIDDLE, + ) + # Containers + for slide in (main_slide, materials_slide): + add_shape( + slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.5, + 1.5, + 9.0, + 3.5, + fill_color=COLORS["background"], + line_color=COLORS["activity_purple"], + line_width=1, + ) + activity_description = activity.get("description", "") + clean_description, facilitation_notes, learning_objectives = ( + extract_facilitation_content(activity_description) + ) + for slide in (main_slide, materials_slide): + add_text_box( + slide, + clean_description, + 0.7, + 1.7, + 8.6, + 0.6, + font_size=20, + color=COLORS["text"], + ) + if facilitation_notes or learning_objectives: + combined = [] + if facilitation_notes: + combined.append(f"{t('facilitationNotesLabel')} {facilitation_notes}") + if learning_objectives: + combined.append(f"{t('learningObjectiveLabel')} {learning_objectives}") + notes_text = "\n\n".join(combined) + for slide in (main_slide, materials_slide): + if not slide.has_notes_slide: + slide.notes_slide + slide.notes_slide.notes_text_frame.text = notes_text + if facilitation_notes: + for slide in (main_slide, materials_slide): + add_shape( + slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 8.5, + 0.9, + 1.0, + 0.5, + fill_color=COLORS["activity_green"], + ) + add_text_box( + slide, + t("notesAvailable"), + 8.6, + 0.95, + 0.8, + 0.4, + font_size=12, + bold=True, + color=COLORS["text_light"], + alignment=PP_ALIGN.CENTER, + ) + # Instructions + instructions_y = 2.4 + add_shape( + main_slide, + MSO_SHAPE.RECTANGLE, + 0.7, + instructions_y + 0.1, + 0.1, + 2.0, + fill_color=COLORS["activity_blue"], + ) + add_text_box( + main_slide, + t("instructions"), + 0.9, + instructions_y, + 8.3, + 0.4, + font_size=22, + bold=True, + color=COLORS["text"], + ) + instructions = activity.get("instructions", []) + instr_tb = main_slide.shapes.add_textbox( + Inches(0.9), Inches(instructions_y + 0.5), Inches(8.3), Inches(1.5) + ) + frame = instr_tb.text_frame + frame.word_wrap = True + for idx, instruction in enumerate(instructions): + p = frame.paragraphs[0] if idx == 0 else frame.add_paragraph() + text = ( + instruction if isinstance(instruction, str) else json.dumps(instruction) + ) + p.text = f"{idx + 1}. {text}" + for run in p.runs: + run.font.size = Pt(16) + run.font.color.rgb = COLORS["text"] + # Materials + materials_y = 2.4 + add_shape( + materials_slide, + MSO_SHAPE.RECTANGLE, + 0.7, + materials_y + 0.1, + 0.1, + 2.0, + fill_color=COLORS["activity_green"], + ) + add_text_box( + materials_slide, + t("materialsNeeded"), + 0.9, + materials_y, + 8.3, + 0.4, + font_size=22, + bold=True, + color=COLORS["text"], + ) + materials = activity.get( + "materials", ["Gaudi-3 optimization tools", "Neural network models"] + ) + mat_tb = materials_slide.shapes.add_textbox( + Inches(0.9), Inches(materials_y + 0.5), Inches(8.3), Inches(1.5) + ) + mat_frame = mat_tb.text_frame + mat_frame.word_wrap = True + for idx, material in enumerate(materials): + p = mat_frame.paragraphs[0] if idx == 0 else mat_frame.add_paragraph() + text = material if isinstance(material, str) else json.dumps(material) + p.text = f"• {text}" + for run in p.runs: + run.font.size = Pt(16) + run.font.color.rgb = COLORS["text"] + # Bottom accent triangles + for slide in (main_slide, materials_slide): + add_shape( + slide, + MSO_SHAPE.RIGHT_TRIANGLE, + 0, + SLIDE_HEIGHT - 1.5, + 1.5, + 1.5, + fill_color=COLORS["activity_orange"], + ) + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0.5, + FOOTER_Y - 0.05, + 9.0, + 0.01, + fill_color=COLORS["primary_light"], + ) + presentation_title = content.get("title") or t("untitledPresentation") + main_num = slide_count_offset + (act_idx * 2) + 1 + materials_num = slide_count_offset + (act_idx * 2) + 2 + add_text_box( + main_slide, + presentation_title, + 0.5, + FOOTER_Y, + 8.0, + 0.3, + font_size=10, + color=COLORS["primary"], + italic=True, + ) + add_text_box( + main_slide, + f"{main_num}", + 9.0, + FOOTER_Y, + 0.5, + 0.3, + font_size=10, + color=COLORS["primary"], + alignment=PP_ALIGN.RIGHT, + ) + add_text_box( + materials_slide, + presentation_title, + 0.5, + FOOTER_Y, + 8.0, + 0.3, + font_size=10, + color=COLORS["primary"], + italic=True, + ) + add_text_box( + materials_slide, + f"{materials_num}", + 9.0, + FOOTER_Y, + 0.5, + 0.3, + font_size=10, + color=COLORS["primary"], + alignment=PP_ALIGN.RIGHT, + ) + return slides diff --git a/backend/pptx_builder/sections/agenda.py b/backend/pptx_builder/sections/agenda.py new file mode 100644 index 0000000..57512d0 --- /dev/null +++ b/backend/pptx_builder/sections/agenda.py @@ -0,0 +1,150 @@ +import math +from pptx.enum.shapes import MSO_SHAPE +from pptx.util import Inches, Pt +from pptx.enum.text import PP_ALIGN +from ..constants import COLORS, SLIDE_WIDTH, FOOTER_Y, THEME +from ..shapes import add_shape, add_text_box, add_footer +from ..localization import t +from ..utils import clean_slide_title + + +def create_agenda_slide(prs, content, total_slides): + agenda_items = [] + intro_items = [t("learningOutcomes"), t("keyTerms")] + agenda_items.append({"title": t("introduction"), "items": intro_items}) + content_slides = [] + for slide_content in content.get("slides", []): + title = clean_slide_title(slide_content.get("title", "")) + if title: + content_slides.append(title) + if content_slides: + agenda_items.append({"title": t("mainContent"), "items": content_slides}) + activities = [a.get("title", "") for a in content.get("activities", [])] + if activities: + agenda_items.append({"title": t("activities"), "items": activities}) + knowledge_items = [] + quiz_count = 0 + discussion_count = 0 + for idea in content.get("assessmentIdeas", []): + idea_type = idea.get("type", "").lower() + if "quiz" in idea_type: + quiz_count += len( + [q for q in idea.get("exampleQuestions", []) if q.get("options")] + ) + elif "discussion" in idea_type: + discussion_count += len( + idea.get("exampleQuestions", []) if idea.get("exampleQuestions") else [] + ) + if quiz_count > 0: + knowledge_items.append(t("quizQuestions", count=quiz_count)) + if discussion_count > 0: + knowledge_items.append(t("discussionQuestions", count=discussion_count)) + if knowledge_items: + agenda_items.append({"title": t("testYourKnowledge"), "items": knowledge_items}) + if content.get("furtherReadings", []): + agenda_items.append( + {"title": t("additionalResources"), "items": [t("furtherReadings")]} + ) + section_height = 0.5 + item_height = 0.35 + total_height_needed = 0 + for section in agenda_items: + total_height_needed += section_height + len(section["items"]) * item_height + available_height = FOOTER_Y - 1.2 + slides_needed = math.ceil(total_height_needed / available_height) + agenda_slides = [] + section_start_idx = 0 + item_start_idx = 0 + consumed = [] # track per slide + for slide_idx in range(slides_needed): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + agenda_slides.append(slide) + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0, + 0, + SLIDE_WIDTH, + 0.8, + fill_color=COLORS["primary"], + ) + title = t("agenda") + if slide_idx > 0: + title += t("agendaContinued", idx=slide_idx + 1, total=slides_needed) + add_text_box( + slide, + title, + 0.5, + 0.1, + 9, + 0.6, + font_size=36, + bold=True, + color=COLORS["text_light"], + ) + add_shape( + slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.3, + 0.9, + 9.4, + FOOTER_Y - 1.1, + fill_color=COLORS["light"], + opacity=0.9, + line_color=COLORS["primary_light"], + line_width=1, + ) + y = 1.1 + max_y = FOOTER_Y - 0.3 + current_section_idx = section_start_idx + current_item_start = item_start_idx + while current_section_idx < len(agenda_items) and y < max_y: + section = agenda_items[current_section_idx] + if y + section_height > max_y: + break + add_text_box( + slide, + section["title"], + 0.7, + y, + 8.5, + section_height, + font_size=24, + bold=True, + color=COLORS["primary"], + ) + y += section_height + items = section["items"][current_item_start:] + for idx, item in enumerate(items): + if y + item_height > max_y: + item_start_idx = current_item_start + idx + break + tb = slide.shapes.add_textbox( + Inches(1.0), Inches(y), Inches(8.0), Inches(item_height) + ) + p = tb.text_frame.paragraphs[0] + p.level = 1 + try: + p.bullet.visible = True + except: + item = f"• {item}" + run = p.add_run() + run.text = item + run.font.size = Pt(18) + run.font.color.rgb = COLORS["text"] + y += item_height + else: + current_section_idx += 1 + current_item_start = 0 + item_start_idx = 0 + section_start_idx = current_section_idx + continue + break + add_footer( + slide, + (content.get("title") or t("untitledPresentation")), + slide_idx + 2, + total_slides, + THEME["footer_style"], + ) + return agenda_slides diff --git a/backend/pptx_builder/sections/closing.py b/backend/pptx_builder/sections/closing.py new file mode 100644 index 0000000..0378580 --- /dev/null +++ b/backend/pptx_builder/sections/closing.py @@ -0,0 +1,72 @@ +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import PP_ALIGN +from ..constants import COLORS, SLIDE_WIDTH, SLIDE_HEIGHT, THEME +from ..shapes import ( + add_text_box, + add_shape, + add_corner_accent, + add_gradient_background, + add_footer, +) +from ..localization import t + + +def create_closing_slide(prs, content, total_slides, slide_number): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + if THEME["use_gradients"]: + add_gradient_background( + prs, slide, COLORS["gradient_start"], COLORS["gradient_end"], angle=135 + ) + else: + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0, + 0, + SLIDE_WIDTH, + SLIDE_HEIGHT, + fill_color=COLORS["primary_dark"], + ) + if THEME["corner_accent"]: + add_corner_accent(slide, COLORS["accent1"], 2.0, "top-right") + add_corner_accent(slide, COLORS["accent2"], 1.5, "bottom-left") + title = t("thankYou") + add_text_box( + slide, + title, + 0.5, + 1.5, + 9, + 1.5, + font_size=48, + bold=True, + color=COLORS["text_light"], + alignment=PP_ALIGN.CENTER, + shadow=True, + ) + presentation_title = content.get("title") or t("untitledPresentation") + subtitle = f"{t('presentation')} {presentation_title}" + line_y = 3.2 + add_shape( + slide, MSO_SHAPE.RECTANGLE, 3.5, line_y, 3.0, 0.02, fill_color=COLORS["accent2"] + ) + add_text_box( + slide, + subtitle, + 0.5, + line_y + 0.2, + 9, + 0.5, + font_size=28, + italic=True, + color=COLORS["text_light"], + alignment=PP_ALIGN.CENTER, + ) + add_footer( + slide, + (content.get("title") or t("untitledPresentation")), + slide_number, + total_slides, + THEME["footer_style"], + ) + return slide diff --git a/backend/pptx_builder/sections/content.py b/backend/pptx_builder/sections/content.py new file mode 100644 index 0000000..d44df7e --- /dev/null +++ b/backend/pptx_builder/sections/content.py @@ -0,0 +1,156 @@ +import json +from pptx.enum.shapes import MSO_SHAPE +from ..constants import ( + COLORS, + THEME, + SLIDE_WIDTH, + FOOTER_Y, + CONTENT_START_Y, + MAIN_BULLET_INDENT, +) +from ..shapes import ( + add_gradient_background, + add_shape, + add_corner_accent, + add_text_box, + add_footer, +) +from ..localization import t +from ..utils import clean_slide_title +from pptx.util import Inches, Pt + + +def create_content_slides(prs, content, total_slides): + slides_data = content.get("slides", []) + if not slides_data: + return [] + result = [] + slide_count_offset = 2 + len(content.get("keyTerms", [])) // 4 + for slide_idx, slide_content in enumerate(slides_data): + if slide_idx == 1: # skip second slide as per original logic + continue + slide = prs.slides.add_slide(prs.slide_layouts[6]) + result.append(slide) + if THEME["use_gradients"]: + add_gradient_background( + prs, slide, COLORS["primary"], COLORS["primary_dark"], angle=0 + ) + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0, + 0.8, + SLIDE_WIDTH, + (5.625 - 0.8), + fill_color=COLORS["background"], + opacity=0.9, + ) + else: + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0, + 0, + SLIDE_WIDTH, + 0.8, + fill_color=COLORS["royal_blue"], + ) + if THEME["corner_accent"]: + accent_color = [COLORS["accent1"], COLORS["accent2"], COLORS["accent3"]][ + slide_idx % 3 + ] + add_corner_accent(slide, accent_color, 1.0, "bottom-right") + cleaned_title = clean_slide_title(slide_content.get("title", "")) + add_text_box( + slide, + cleaned_title, + 0.5, + 0.1, + 9, + 0.6, + font_size=32, + bold=True, + color=COLORS["text_light"], + ) + points = slide_content.get("content", []) + if THEME["content_box_shadow"]: + content_height = FOOTER_Y - CONTENT_START_Y - 0.2 + add_shape( + slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.3, + CONTENT_START_Y - 0.1, + 9.4, + content_height, + fill_color=COLORS["light_alt"], + opacity=0.7, + line_color=COLORS["primary_light"], + line_width=1, + shadow=True, + ) + tb = slide.shapes.add_textbox( + Inches(MAIN_BULLET_INDENT), + Inches(CONTENT_START_Y), + Inches(9 - MAIN_BULLET_INDENT), + Inches(FOOTER_Y - CONTENT_START_Y - 0.3), + ) + tf = tb.text_frame + tf.word_wrap = True + has_sub = any( + p.strip().startswith((" ", "\\t", "-")) + for p in points + if isinstance(p, str) + ) + p = tf.paragraphs[0] + first = True + for point in points: + text = point if isinstance(point, str) else json.dumps(point) + is_bullet = False + level = 0 + if text.strip().startswith(("•", "*")): + is_bullet = True + text = text.strip()[1:].strip() + elif text.strip().startswith("-"): + is_bullet = True + level = 1 + text = text.strip()[1:].strip() + elif text.strip().startswith(" ") or text.strip().startswith("\\t"): + is_bullet = True + level = 1 + text = text.strip() + elif not has_sub: + is_bullet = True + if not first: + p = tf.add_paragraph() + else: + first = False + if is_bullet: + p.level = level + try: + p.bullet.visible = True + except: + if THEME["modern_bullets"]: + text = ("◦ " if level > 0 else "• ") + text + run = p.add_run() + run.text = text + font = run.font + font.size = Pt(18) if level == 0 else Pt(16) + font.bold = not is_bullet and level == 0 + font.color.rgb = COLORS["text"] + notes = slide_content.get("notes", "") + if notes: + if not slide.has_notes_slide: + slide.notes_slide + slide.notes_slide.notes_text_frame.text = ( + notes if isinstance(notes, str) else json.dumps(notes) + ) + adjusted_idx = slide_idx if slide_idx < 1 else slide_idx - 1 + slide_number = slide_count_offset + adjusted_idx + 1 + add_footer( + slide, + (content.get("title") or t("untitledPresentation")), + slide_number, + total_slides, + THEME["footer_style"], + ) + return result diff --git a/backend/pptx_builder/sections/discussion.py b/backend/pptx_builder/sections/discussion.py new file mode 100644 index 0000000..a50f77e --- /dev/null +++ b/backend/pptx_builder/sections/discussion.py @@ -0,0 +1,216 @@ +import json +from pptx.enum.shapes import MSO_SHAPE +from ..constants import COLORS, SLIDE_WIDTH, FOOTER_Y, THEME +from ..shapes import add_shape, add_text_box, add_footer +from ..localization import t +from ..utils import estimate_text_height + + +def create_discussion_slides(prs, content, total_slides): + assessment_ideas = content.get("assessmentIdeas", []) + slides = [] + slide_count_offset = ( + 2 + + len(content.get("keyTerms", [])) // 4 + - 1 + + len([s for i, s in enumerate(content.get("slides", [])) if i != 1]) + + len(content.get("activities", [])) * 2 + + 1 + ) + quiz_count = 0 + for idea in content.get("assessmentIdeas", []): + if "quiz" in idea.get("type", "").lower(): + quiz_count += ( + len([q for q in idea.get("exampleQuestions", []) if q.get("options")]) + * 2 + ) + discussion_slide_count = 0 + for idea in assessment_ideas: + if "discussion" not in idea.get("type", "").lower(): + continue + for q_idx, question in enumerate(idea.get("exampleQuestions", [])): + question_text = question.get("question", "Example question") + guidance = question.get("correctAnswer", "") + q_slide = prs.slides.add_slide(prs.slide_layouts[6]) + slides.append(q_slide) + discussion_slide_count += 1 + add_shape( + q_slide, + MSO_SHAPE.RECTANGLE, + 0, + 0, + SLIDE_WIDTH, + 0.8, + fill_color=COLORS["primary"], + ) + add_text_box( + q_slide, + t("discussionQuestion", num=q_idx + 1), + 0.5, + 0.1, + 9.0, + 0.6, + font_size=32, + bold=True, + color=COLORS["text_light"], + ) + add_shape( + q_slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.5, + 1.1, + 9.0, + 0.8, + fill_color=COLORS["light"], + line_color=COLORS["light"], + line_width=1, + ) + add_text_box( + q_slide, + question_text, + 0.7, + 1.2, + 8.6, + 0.6, + font_size=20, + bold=True, + color=COLORS["text"], + ) + question_text_height = estimate_text_height(question_text, 20, 8.6) + next_y = 1.2 + question_text_height + 1.2 + add_shape( + q_slide, + MSO_SHAPE.RECTANGLE, + 0.7, + next_y, + 0.1, + 0.4, + fill_color=COLORS["primary"], + ) + add_text_box( + q_slide, + t("groupDiscussion"), + 0.9, + next_y, + 8.5, + 0.4, + font_size=20, + bold=True, + color=COLORS["primary"], + ) + add_text_box( + q_slide, + t("groupInstruction"), + 0.9, + next_y + 0.5, + 8.5, + 0.4, + font_size=18, + color=COLORS["text"], + ) + presentation_title = content.get("title") or t("untitledPresentation") + slide_number = slide_count_offset + quiz_count + discussion_slide_count + add_footer( + q_slide, + presentation_title, + slide_number, + total_slides, + THEME["footer_style"], + ) + a_slide = prs.slides.add_slide(prs.slide_layouts[6]) + slides.append(a_slide) + discussion_slide_count += 1 + add_shape( + a_slide, + MSO_SHAPE.RECTANGLE, + 0, + 0, + SLIDE_WIDTH, + 0.8, + fill_color=COLORS["primary"], + ) + add_text_box( + a_slide, + t("facilitatorGuidance", num=q_idx + 1), + 0.5, + 0.1, + 9.0, + 0.6, + font_size=32, + bold=True, + color=COLORS["text_light"], + ) + add_shape( + a_slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.5, + 1.1, + 9.0, + 0.8, + fill_color=COLORS["light"], + line_color=COLORS["light"], + line_width=1, + ) + add_text_box( + a_slide, + f"{t('question', text=question_text)}", + 0.7, + 1.2, + 8.6, + 0.6, + font_size=18, + italic=True, + color=COLORS["text"], + ) + guidance_y = 1.2 + question_text_height + 0.7 + if guidance: + add_shape( + a_slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.5, + guidance_y, + 9.0, + 2.8, + fill_color=COLORS["light"], + line_color=COLORS["accent2"], + line_width=2, + ) + add_shape( + a_slide, + MSO_SHAPE.RECTANGLE, + 0.7, + guidance_y + 0.1, + 0.1, + 2.5, + fill_color=COLORS["accent2"], + ) + add_text_box( + a_slide, + t("facilitatorGuidance", num=q_idx + 1).split(":")[0] + ":", + 0.9, + guidance_y + 0.1, + 8.5, + 0.4, + font_size=20, + bold=True, + color=COLORS["accent2"], + ) + add_text_box( + a_slide, + guidance if isinstance(guidance, str) else json.dumps(guidance), + 0.9, + guidance_y + 0.6, + 8.3, + 1.5, + font_size=16, + color=COLORS["text"], + ) + slide_number = slide_count_offset + quiz_count + discussion_slide_count + add_footer( + a_slide, + presentation_title, + slide_number, + total_slides, + THEME["footer_style"], + ) + return slides diff --git a/backend/pptx_builder/sections/facilitation.py b/backend/pptx_builder/sections/facilitation.py new file mode 100644 index 0000000..0c270fb --- /dev/null +++ b/backend/pptx_builder/sections/facilitation.py @@ -0,0 +1,186 @@ +from pptx.enum.shapes import MSO_SHAPE +from ..constants import COLORS, SLIDE_WIDTH, FOOTER_Y, THEME, GLOBAL_LANG, LABELS +from ..shapes import add_shape, add_text_box, add_footer +from ..localization import t +from ..utils import extract_facilitation_content + + +def create_facilitation_notes_slide(prs, content, total_slides): + activities = content.get("activities", []) + slide_count_offset = ( + 2 + + len(content.get("keyTerms", [])) // 4 + - 1 + + len([s for i, s in enumerate(content.get("slides", [])) if i != 1]) + + len(content.get("activities", [])) * 2 + + 1 + + len(content.get("furtherReadings", [])) // 2 + ) + quiz_count = 0 + for idea in content.get("assessmentIdeas", []): + if "quiz" in idea.get("type", "").lower(): + quiz_count += ( + len([q for q in idea.get("exampleQuestions", []) if q.get("options")]) + * 2 + ) + discussion_count = 0 + for idea in content.get("assessmentIdeas", []): + if "discussion" in idea.get("type", "").lower(): + discussion_count += ( + len( + idea.get("exampleQuestions", []) + if idea.get("exampleQuestions") + else [] + ) + * 2 + ) + has_notes = False + for activity in activities: + _, facilitation_notes, _ = extract_facilitation_content( + activity.get("description", "") + ) + if facilitation_notes: + has_notes = True + break + if not has_notes: + return None + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_shape( + slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, 0.8, fill_color=COLORS["primary"] + ) + add_text_box( + slide, + LABELS[GLOBAL_LANG].get( + "facilitationNotesSummary", "Facilitation Notes Summary" + ), + 0.5, + 0.1, + 9.0, + 0.6, + font_size=32, + bold=True, + color=COLORS["text_light"], + ) + add_shape( + slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.5, + 1.0, + 9.0, + FOOTER_Y - 1.2, + fill_color=COLORS["light_alt"], + line_color=COLORS["primary_light"], + line_width=1, + shadow=True, + ) + y = 1.2 + for idx, activity in enumerate(activities): + title = activity.get("title", "") + description = activity.get("description", "") + _, facilitation_notes, _ = extract_facilitation_content(description) + if facilitation_notes: + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0.7, + y, + 0.1, + 0.4, + fill_color=COLORS["activity_green"], + ) + add_text_box( + slide, + title, + 0.9, + y, + 8.3, + 0.4, + font_size=18, + bold=True, + color=COLORS["primary"], + ) + y += 0.5 + notes_text = facilitation_notes.replace("Facilitation Notes: ", "") + add_text_box( + slide, notes_text, 0.9, y, 8.3, 0.6, font_size=14, color=COLORS["text"] + ) + y += 0.8 + if idx < len(activities) - 1: + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0.7, + y, + 8.5, + 0.01, + fill_color=COLORS["primary_light"], + opacity=0.5, + ) + y += 0.3 + if y > FOOTER_Y - 0.5: + add_text_box( + slide, + LABELS[GLOBAL_LANG].get( + "continuedNextSlide", "Continued on next slide..." + ), + 0.9, + y - 0.3, + 8.3, + 0.3, + font_size=12, + italic=True, + color=COLORS["text_muted"], + ) + add_footer( + slide, + (content.get("title") or t("untitledPresentation")), + total_slides - 1, + total_slides + 1, + THEME["footer_style"], + ) + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0, + 0, + SLIDE_WIDTH, + 0.8, + fill_color=COLORS["primary"], + ) + add_text_box( + slide, + LABELS[GLOBAL_LANG].get( + "facilitationNotesSummary", "Facilitation Notes Summary" + ) + + LABELS[GLOBAL_LANG].get("continued", " (continued)"), + 0.5, + 0.1, + 9.0, + 0.6, + font_size=32, + bold=True, + color=COLORS["text_light"], + ) + add_shape( + slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.5, + 1.0, + 9.0, + FOOTER_Y - 1.2, + fill_color=COLORS["light_alt"], + line_color=COLORS["primary_light"], + line_width=1, + shadow=True, + ) + y = 1.2 + slide_number = slide_count_offset + quiz_count + discussion_count + 1 + add_footer( + slide, + (content.get("title") or t("untitledPresentation")), + slide_number, + total_slides, + THEME["footer_style"], + ) + return slide diff --git a/backend/pptx_builder/sections/key_terms.py b/backend/pptx_builder/sections/key_terms.py new file mode 100644 index 0000000..06acd7c --- /dev/null +++ b/backend/pptx_builder/sections/key_terms.py @@ -0,0 +1,135 @@ +from pptx.enum.shapes import MSO_SHAPE +from pptx.util import Pt +from ..constants import COLORS, THEME, FOOTER_Y, SLIDE_WIDTH +from ..shapes import ( + add_gradient_background, + add_corner_accent, + add_text_box, + add_shape, + add_table, + add_footer, +) +from ..localization import t + + +def create_key_terms_slide(prs, content, total_slides): + key_terms = content.get("keyTerms", []) + if not key_terms: + return [] + terms_per_slide = 4 + total_terms = len(key_terms) + slides_needed = (total_terms + terms_per_slide - 1) // terms_per_slide + slides = [] + for slide_idx in range(slides_needed): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slides.append(slide) + add_gradient_background( + prs, slide, COLORS["primary"], COLORS["primary_dark"], angle=0 + ) + add_corner_accent(slide, COLORS["accent3"], 1.0, "bottom-left") + title = t("keyTerms") + if slide_idx > 0: + title += t("continued") + add_text_box( + slide, + title, + 0.5, + 0.1, + 9, + 0.6, + font_size=36, + bold=True, + color=COLORS["text_light"], + ) + start_idx = slide_idx * terms_per_slide + end_idx = min(start_idx + terms_per_slide, total_terms) + terms_for_slide = key_terms[start_idx:end_idx] + table_height = min(3.5, 0.8 * (len(terms_for_slide) + 1)) + if THEME["content_box_shadow"]: + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0.4, + 0.9, + 9.2, + table_height + 0.2, + fill_color=COLORS["light_alt"], + shadow=True, + ) + table = add_table( + slide, + len(terms_for_slide) + 1, + 2, + 0.5, + 1.0, + 9.0, + table_height, + header_bg_color=COLORS["royal_blue"], + alt_row_bg_color=COLORS["light"], + border_color=COLORS["primary_light"], + ) + headers = [ + { + "text": t("term"), + "options": { + "fill": {"color": COLORS["royal_blue"]}, + "color": COLORS["text_light"], + "fontSize": 18, + "bold": True, + "align": "center", + }, + }, + { + "text": t("definition"), + "options": { + "fill": {"color": COLORS["royal_blue"]}, + "color": COLORS["text_light"], + "fontSize": 18, + "bold": True, + "align": "center", + }, + }, + ] + for j, cell_data in enumerate(headers): + cell = table.cell(0, j) + p = cell.text_frame.paragraphs[0] + p.text = cell_data["text"] + opts = cell_data["options"] + if "fill" in opts and "color" in opts["fill"]: + cell.fill.solid() + cell.fill.fore_color.rgb = opts["fill"]["color"] + if p.runs: + run = p.runs[0] + run.font.color.rgb = opts["color"] + run.font.size = Pt(opts["fontSize"]) + run.font.bold = opts["bold"] + if opts.get("align") == "center": + p.alignment = 1 # PP_ALIGN.CENTER value + for i, term in enumerate(terms_for_slide): + row_idx = i + 1 + even = i % 2 == 0 + bg = COLORS["background"] if even else COLORS["light"] + term_cell = table.cell(row_idx, 0) + term_cell.text = term.get("term", "") + term_cell.fill.solid() + term_cell.fill.fore_color.rgb = bg + if term_cell.text_frame.paragraphs[0].runs: + r = term_cell.text_frame.paragraphs[0].runs[0] + r.font.bold = True + r.font.size = Pt(16) + r.font.color.rgb = COLORS["primary_dark"] + def_cell = table.cell(row_idx, 1) + def_cell.text = term.get("definition", "") + def_cell.fill.solid() + def_cell.fill.fore_color.rgb = bg + if def_cell.text_frame.paragraphs[0].runs: + dr = def_cell.text_frame.paragraphs[0].runs[0] + dr.font.size = Pt(14) + add_footer( + slide, + (content.get("title") or t("untitledPresentation")), + slide_idx + 2, + total_slides, + THEME["footer_style"], + ) + return slides diff --git a/backend/pptx_builder/sections/learning_outcomes.py b/backend/pptx_builder/sections/learning_outcomes.py new file mode 100644 index 0000000..7f9f05a --- /dev/null +++ b/backend/pptx_builder/sections/learning_outcomes.py @@ -0,0 +1,89 @@ +import re +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import MSO_ANCHOR +from ..constants import COLORS, SLIDE_WIDTH, THEME, FOOTER_Y +from ..shapes import add_shape, add_text_box, add_footer +from ..localization import t + + +def create_learning_outcomes_slide(prs, content, total_slides): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_shape( + slide, MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_WIDTH, 0.8, fill_color=COLORS["primary"] + ) + add_text_box( + slide, + t("learningOutcomes"), + 0.5, + 0.1, + 9, + 0.6, + font_size=36, + bold=True, + color=COLORS["text_light"], + ) + ct_names = t("contentTypeNames") + content_type = content.get("contentType", "lecture") + content_type_display = ( + ct_names.get(content_type, "lecture") + if isinstance(ct_names, dict) + else "lecture" + ) + intro_text = t("byTheEnd", contentType=content_type_display) + add_text_box( + slide, + intro_text, + 0.5, + 1.0, + 9, + 0.5, + font_size=20, + italic=True, + color=COLORS["dark"], + ) + add_shape( + slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.3, + 1.7, + 9.4, + 3.0, + fill_color=COLORS["light"], + opacity=0.9, + line_color=COLORS["primary_light"], + line_width=1, + ) + learning_outcomes = content.get("learningOutcomes", []) + y = 2.0 + bullet_colors = [COLORS["emerald"], COLORS["medium_purple"], COLORS["emerald"]] + for idx, outcome in enumerate(learning_outcomes): + cleaned = re.sub(r"^\d+\.\s*", "", outcome) + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0.7, + y, + 0.15, + 0.15, + fill_color=bullet_colors[idx % len(bullet_colors)], + ) + add_text_box( + slide, + cleaned, + 1.0, + y - 0.125, + 8.5, + 0.4, + font_size=20, + color=COLORS["text"], + vertical_alignment=MSO_ANCHOR.MIDDLE, + ) + y += 0.6 + add_footer( + slide, + (content.get("title") or t("untitledPresentation")), + 1, + total_slides, + THEME["footer_style"], + ) + return slide diff --git a/backend/pptx_builder/sections/quiz.py b/backend/pptx_builder/sections/quiz.py new file mode 100644 index 0000000..1f6c6b0 --- /dev/null +++ b/backend/pptx_builder/sections/quiz.py @@ -0,0 +1,281 @@ +import json +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import MSO_ANCHOR, PP_ALIGN +from ..constants import COLORS, SLIDE_WIDTH, FOOTER_Y, THEME +from ..shapes import add_shape, add_text_box, add_footer +from ..localization import t + + +def create_quiz_slides(prs, content, total_slides): + assessment_ideas = content.get("assessmentIdeas", []) + slides = [] + slide_count_offset = ( + 2 + + len(content.get("keyTerms", [])) // 4 + - 1 + + len([s for i, s in enumerate(content.get("slides", [])) if i != 1]) + + len(content.get("activities", [])) * 2 + + 1 + ) + quiz_slide_count = 0 + for idea in assessment_ideas: + if "quiz" not in idea.get("type", "").lower(): + continue + for q_idx, question in enumerate(idea.get("exampleQuestions", [])): + question_text = question.get("question", "Example question") + options = question.get("options", []) + if not options: + continue + q_slide = prs.slides.add_slide(prs.slide_layouts[6]) + slides.append(q_slide) + quiz_slide_count += 1 + add_shape( + q_slide, + MSO_SHAPE.RECTANGLE, + 0, + 0, + SLIDE_WIDTH, + 1.0, + fill_color=COLORS["primary"], + ) + add_text_box( + q_slide, + t("quizQuestion", num=q_idx + 1), + 0.5, + 0.2, + 9.0, + 0.6, + font_size=36, + bold=True, + color=COLORS["text_light"], + alignment=PP_ALIGN.CENTER, + vertical_alignment=MSO_ANCHOR.MIDDLE, + ) + add_shape( + q_slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.5, + 1.1, + 9.0, + 0.8, + fill_color=COLORS["light"], + line_color=COLORS["light"], + line_width=1, + ) + add_text_box( + q_slide, + question_text, + 0.7, + 1.2, + 8.6, + 0.6, + font_size=20, + bold=True, + color=COLORS["text"], + ) + options_per_row = 2 + option_width = 4.3 + option_height = 1.0 + option_gap = 0.4 + start_y = 2.2 + for opt_idx, option in enumerate(options): + row = opt_idx // options_per_row + col = opt_idx % options_per_row + ox = 0.5 + col * (option_width + option_gap) + oy = start_y + row * (option_height + 0.4) + add_shape( + q_slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + ox, + oy, + option_width, + option_height, + fill_color=COLORS["light"], + line_color=COLORS["light"], + ) + circle_size = 0.6 + cx = ox + 0.2 + cy = oy + (option_height - circle_size) / 2 + add_shape( + q_slide, + MSO_SHAPE.OVAL, + cx, + cy, + circle_size, + circle_size, + fill_color=COLORS["primary"], + ) + add_text_box( + q_slide, + chr(65 + opt_idx), + cx, + cy, + circle_size, + circle_size, + font_size=24, + bold=True, + color=COLORS["text_light"], + alignment=PP_ALIGN.CENTER, + vertical_alignment=MSO_ANCHOR.MIDDLE, + ) + text_x = cx + circle_size + 0.2 + add_text_box( + q_slide, + option, + text_x, + oy, + option_width - (text_x - ox) - 0.2, + option_height, + font_size=18, + color=COLORS["text"], + alignment=PP_ALIGN.CENTER, + vertical_alignment=MSO_ANCHOR.MIDDLE, + ) + presentation_title = content.get("title") or t("untitledPresentation") + slide_number = slide_count_offset + quiz_slide_count + add_footer( + q_slide, + presentation_title, + slide_number, + total_slides, + THEME["footer_style"], + ) + # Answer slide + a_slide = prs.slides.add_slide(prs.slide_layouts[6]) + slides.append(a_slide) + quiz_slide_count += 1 + add_shape( + a_slide, + MSO_SHAPE.RECTANGLE, + 0, + 0, + SLIDE_WIDTH, + 1.2, + fill_color=COLORS["primary"], + ) + add_text_box( + a_slide, + t("quizAnswer", num=q_idx + 1), + 0.5, + 0.3, + 9.0, + 0.6, + font_size=40, + bold=True, + color=COLORS["text_light"], + alignment=PP_ALIGN.CENTER, + vertical_alignment=MSO_ANCHOR.MIDDLE, + ) + add_shape( + a_slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.5, + 1.4, + 9.0, + 0.8, + fill_color=COLORS["light"], + line_color=COLORS["light"], + line_width=1, + ) + add_text_box( + a_slide, + f"{t('question', text=question_text)}", + 0.7, + 1.5, + 8.6, + 0.6, + font_size=18, + italic=True, + color=COLORS["text"], + ) + correct_answer = question.get("correctAnswer", "") + if correct_answer: + add_shape( + a_slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.5, + 2.4, + 9.0, + 1.0, + fill_color=COLORS["dark_alt"], + line_color=COLORS["success"], + line_width=3, + ) + add_text_box( + a_slide, + t("correctAnswer"), + 0.7, + 2.5, + 8.6, + 0.4, + font_size=20, + bold=True, + color=COLORS["warning"], + ) + add_text_box( + a_slide, + correct_answer, + 0.7, + 2.9, + 8.6, + 0.4, + font_size=18, + color=COLORS["text_light"], + ) + explanation = question.get("explanation", "") + if explanation: + add_shape( + a_slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.5, + 3.6, + 9.0, + 1.4, + fill_color=COLORS["light"], + line_color=COLORS["primary_light"], + line_width=1, + ) + add_shape( + a_slide, + MSO_SHAPE.RECTANGLE, + 0.7, + 3.7, + 0.1, + 1.2, + fill_color=COLORS["primary"], + ) + add_text_box( + a_slide, + t("explanation"), + 0.9, + 3.7, + 8.5, + 0.4, + font_size=20, + bold=True, + color=COLORS["text"], + ) + add_text_box( + a_slide, + ( + explanation + if isinstance(explanation, str) + else json.dumps(explanation) + ), + 0.9, + 4.2, + 8.5, + 0.7, + font_size=16, + color=COLORS["text"], + ) + presentation_title = content.get("title") or t("untitledPresentation") + slide_number = slide_count_offset + quiz_slide_count + add_footer( + a_slide, + presentation_title, + slide_number, + total_slides, + THEME["footer_style"], + ) + return slides diff --git a/backend/pptx_builder/sections/readings.py b/backend/pptx_builder/sections/readings.py new file mode 100644 index 0000000..265decb --- /dev/null +++ b/backend/pptx_builder/sections/readings.py @@ -0,0 +1,157 @@ +from pptx.enum.shapes import MSO_SHAPE +from ..constants import COLORS, SLIDE_WIDTH, FOOTER_Y, THEME +from ..shapes import add_shape, add_text_box, add_footer +from ..localization import t + + +def create_further_readings_slides(prs, content, total_slides): + readings = content.get("furtherReadings", []) + if not readings: + return [] + slides = [] + slide_count_offset = ( + 2 + + len(content.get("keyTerms", [])) // 4 + - 1 + + len([s for i, s in enumerate(content.get("slides", [])) if i != 1]) + + len(content.get("activities", [])) * 2 + + 1 + ) + quiz_count = 0 + for idea in content.get("assessmentIdeas", []): + if "quiz" in idea.get("type", "").lower(): + quiz_count += ( + len([q for q in idea.get("exampleQuestions", []) if q.get("options")]) + * 2 + ) + discussion_count = 0 + for idea in content.get("assessmentIdeas", []): + if "discussion" in idea.get("type", "").lower(): + discussion_count += ( + len( + idea.get("exampleQuestions", []) + if idea.get("exampleQuestions") + else [] + ) + * 2 + ) + readings_per_slide = 2 + total_readings = len(readings) + slides_needed = (total_readings + readings_per_slide - 1) // readings_per_slide + for slide_idx in range(slides_needed): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slides.append(slide) + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0, + 0, + SLIDE_WIDTH, + 0.8, + fill_color=COLORS["primary"], + ) + title = t("furtherReadings") + if slide_idx > 0: + title += t("continued") + add_text_box( + slide, + title, + 0.5, + 0.1, + 9, + 0.6, + font_size=32, + bold=True, + color=COLORS["text_light"], + ) + if THEME["content_box_shadow"]: + add_shape( + slide, + MSO_SHAPE.ROUNDED_RECTANGLE, + 0.3, + 1.0, + 9.4, + FOOTER_Y - 1.2, + fill_color=COLORS["light_alt"], + opacity=0.7, + line_color=COLORS["primary_light"], + line_width=1, + shadow=True, + ) + start_idx = slide_idx * readings_per_slide + end_idx = min(start_idx + readings_per_slide, total_readings) + readings_for_slide = readings[start_idx:end_idx] + y = 1.2 + for i, reading in enumerate(readings_for_slide): + reading_title = reading.get("title") or t("untitledReading") + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0.7, + y, + 0.1, + 0.4, + fill_color=COLORS["primary"], + ) + add_text_box( + slide, + reading_title, + 0.9, + y, + 8.3, + 0.4, + font_size=20, + bold=True, + color=COLORS["primary"], + ) + y += 0.5 + reading_author = reading.get("author") or t("unknownAuthor") + reading_description = reading.get("readingDescription", "") + add_text_box( + slide, + f"{t('author')} {reading_author}", + 0.9, + y, + 8.3, + 0.3, + font_size=16, + italic=True, + color=COLORS["primary"], + ) + y += 0.4 + add_text_box( + slide, + reading_description, + 0.9, + y, + 8.3, + 0.6, + font_size=16, + color=COLORS["text"], + ) + if i < len(readings_for_slide) - 1: + y += 0.8 + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0.7, + y, + 8.5, + 0.01, + fill_color=COLORS["primary_light"], + opacity=0.5, + ) + y += 0.2 + else: + y += 0.8 + slide_number = ( + slide_count_offset + quiz_count + discussion_count + slide_idx + 1 + ) + add_footer( + slide, + (content.get("title") or t("untitledPresentation")), + slide_number, + total_slides, + THEME["footer_style"], + ) + return slides diff --git a/backend/pptx_builder/sections/title.py b/backend/pptx_builder/sections/title.py new file mode 100644 index 0000000..7422495 --- /dev/null +++ b/backend/pptx_builder/sections/title.py @@ -0,0 +1,72 @@ +from pptx.enum.text import PP_ALIGN +from pptx.enum.shapes import MSO_SHAPE +from ..constants import COLORS, SLIDE_WIDTH, SLIDE_HEIGHT, THEME +from ..shapes import add_text_box, add_shape, add_corner_accent, add_gradient_background +from ..localization import t + + +def create_title_slide(prs, content): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + if THEME["use_gradients"]: + add_gradient_background( + prs, slide, COLORS["gradient_start"], COLORS["gradient_end"], angle=135 + ) + else: + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0, + 0, + SLIDE_WIDTH, + SLIDE_HEIGHT, + fill_color=COLORS["primary_dark"], + ) + if THEME["corner_accent"]: + add_corner_accent(slide, COLORS["accent1"], 2.0, "top-right") + add_corner_accent(slide, COLORS["accent2"], 1.5, "bottom-left") + title = content.get("title", t("untitledPresentation")) + add_text_box( + slide, + title, + 0.5, + 1.5, + 9, + 1.5, + font_size=48, + bold=True, + color=COLORS["text_light"], + alignment=PP_ALIGN.CENTER, + shadow=True, + ) + ct_names = t("contentTypeNames") + diff_names = t("difficultyNames") + content_type = content.get("contentType", "lecture") + difficulty = content.get("difficultyLevel", "intermediate") + ct_disp = ( + ct_names.get(content_type, "Lecture") + if isinstance(ct_names, dict) + else "Lecture" + ) + diff_disp = ( + diff_names.get(difficulty, "Intermediate Level") + if isinstance(diff_names, dict) + else "Intermediate Level" + ) + subtitle = f"{ct_disp} | {diff_disp}" + line_y = 3.2 + add_shape( + slide, MSO_SHAPE.RECTANGLE, 3.5, line_y, 3.0, 0.02, fill_color=COLORS["accent2"] + ) + add_text_box( + slide, + subtitle, + 0.5, + line_y + 0.2, + 9, + 0.5, + font_size=28, + italic=True, + color=COLORS["text_light"], + alignment=PP_ALIGN.CENTER, + ) + return slide diff --git a/backend/pptx_builder/shapes.py b/backend/pptx_builder/shapes.py new file mode 100644 index 0000000..bf68263 --- /dev/null +++ b/backend/pptx_builder/shapes.py @@ -0,0 +1,219 @@ +from pptx.util import Inches, Pt +from pptx.enum.text import PP_ALIGN, MSO_ANCHOR, MSO_AUTO_SIZE +from pptx.enum.shapes import MSO_SHAPE +from pptx.dml.color import RGBColor +from .constants import COLORS, THEME, SLIDE_WIDTH, SLIDE_HEIGHT, FOOTER_Y + + +def add_text_box( + slide, + text, + left, + top, + width, + height, + font_size=12, + bold=False, + italic=False, + color=COLORS["text"], + alignment=PP_ALIGN.LEFT, + vertical_alignment=MSO_ANCHOR.TOP, + level=0, + bg_color=None, + border_color=None, + shadow=False, +): + textbox = slide.shapes.add_textbox( + Inches(left), Inches(top), Inches(width), Inches(height) + ) + tf = textbox.text_frame + tf.word_wrap = True + tf.vertical_anchor = vertical_alignment + try: + tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE + except: + pass + p = tf.paragraphs[0] + p.alignment = alignment + p.level = level + run = p.add_run() + run.text = text + font = run.font + font.size = Pt(font_size) + font.bold = bold + font.italic = italic + font.color.rgb = color + if bg_color: + fill = textbox.fill + fill.solid() + fill.fore_color.rgb = bg_color + if border_color: + line = textbox.line + line.color.rgb = border_color + line.width = Pt(1) + if shadow and THEME["content_box_shadow"]: + try: + sh = textbox.shadow + sh.inherit = False + sh.visible = True + sh.blur_radius = Pt(5) + sh.distance = Pt(3) + sh.angle = 45 + sh.color.rgb = RGBColor(0, 0, 0) + sh.transparency = 0.7 + except: + pass + return textbox + + +def add_shape( + slide, + shape_type, + left, + top, + width, + height, + fill_color=None, + line_color=None, + line_width=None, + shadow=False, + opacity=1.0, +): + shape = slide.shapes.add_shape( + shape_type, Inches(left), Inches(top), Inches(width), Inches(height) + ) + if fill_color: + shape.fill.solid() + shape.fill.fore_color.rgb = fill_color + if opacity < 1.0: + try: + shape.fill.transparency = 1.0 - opacity + except: + pass + if line_color: + shape.line.color.rgb = line_color + if line_width is not None: + shape.line.width = Pt(line_width) + if shadow: + try: + sh = shape.shadow + sh.inherit = False + sh.visible = True + sh.blur_radius = Pt(5) + sh.distance = Pt(3) + sh.angle = 45 + sh.color.rgb = RGBColor(0, 0, 0) + sh.transparency = 0.7 + except: + pass + return shape + + +def add_gradient_background(prs, slide, start_color, end_color, angle=90): + shape = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, 0, 0, prs.slide_width, prs.slide_height + ) + shape.line.fill.background() + try: + fill = shape.fill + fill.gradient() + fill.gradient_stops[0].color.rgb = start_color + fill.gradient_stops[0].position = 0 + fill.gradient_stops[1].color.rgb = end_color + fill.gradient_stops[1].position = 1 + fill.gradient_angle = angle + except: + fill = shape.fill + fill.solid() + fill.fore_color.rgb = start_color + return shape + + +def add_corner_accent(slide, color=COLORS["accent1"], size=1.0, position="top-right"): + if position == "top-right": + left, top = SLIDE_WIDTH - size, 0 + elif position == "top-left": + left, top = 0, 0 + elif position == "bottom-right": + left, top = SLIDE_WIDTH - size, SLIDE_HEIGHT - size + else: + left, top = 0, SLIDE_HEIGHT - size + shape = slide.shapes.add_shape( + MSO_SHAPE.RIGHT_TRIANGLE, Inches(left), Inches(top), Inches(size), Inches(size) + ) + shape.fill.solid() + shape.fill.fore_color.rgb = color + shape.line.fill.background() + try: + shape.fill.transparency = 0.3 + except: + pass + return shape + + +def add_table(slide, rows, cols, left, top, width, height, **kwargs): + table = slide.shapes.add_table( + rows, cols, Inches(left), Inches(top), Inches(width), Inches(height) + ).table + return table + + +def add_footer(slide, title_text, slide_number, total_slides, style="modern"): + from pptx.enum.shapes import MSO_SHAPE + from pptx.enum.text import PP_ALIGN + + if style == "modern": + add_shape( + slide, + MSO_SHAPE.RECTANGLE, + 0.5, + FOOTER_Y - 0.05, + 9.0, + 0.01, + fill_color=COLORS["primary_light"], + opacity=0.5, + ) + add_text_box( + slide, + title_text, + 0.5, + FOOTER_Y, + 8.5, + 0.3, + font_size=10, + color=COLORS["primary"], + italic=True, + ) + add_text_box( + slide, + f"{slide_number}", + 9.0, + FOOTER_Y, + 0.5, + 0.3, + font_size=10, + color=COLORS["primary"], + alignment=PP_ALIGN.RIGHT, + ) + else: + add_text_box( + slide, + title_text, + 0.5, + FOOTER_Y, + 8.5, + 0.3, + font_size=10, + color=COLORS["royal_blue"], + ) + add_text_box( + slide, + f"{slide_number}", + 9.0, + FOOTER_Y, + 0.5, + 0.3, + font_size=10, + color=COLORS["text"], + alignment=PP_ALIGN.RIGHT, + ) diff --git a/backend/pptx_builder/slide_counter.py b/backend/pptx_builder/slide_counter.py new file mode 100644 index 0000000..79b4c96 --- /dev/null +++ b/backend/pptx_builder/slide_counter.py @@ -0,0 +1,105 @@ +from .utils import clean_slide_title +from .constants import FOOTER_Y +import math + + +def calculate_total_slides(content): + total = 0 + total += 1 # Title + # Agenda slides + agenda_items = [] + agenda_items.append( + { + "title": "Introduction", + "items": ["Learning Outcomes", "Key Terms & Concepts"], + } + ) + content_slides = [] + for slide_content in content.get("slides", []): + if slide_content.get("title", ""): + title = clean_slide_title(slide_content.get("title", "")) + if title: + content_slides.append(title) + if content_slides: + agenda_items.append({"title": "Main Content", "items": content_slides}) + activities = [] + for activity in content.get("activities", []): + activities.append(activity.get("title", "")) + if activities: + agenda_items.append({"title": "Activities", "items": activities}) + knowledge_items = [] + quiz_count = 0 + discussion_count = 0 + for idea in content.get("assessmentIdeas", []): + idea_type = idea.get("type", "").lower() + if "quiz" in idea_type: + quiz_count += len( + [q for q in idea.get("exampleQuestions", []) if q.get("options")] + ) + elif "discussion" in idea_type: + discussion_count += len( + idea.get("exampleQuestions", []) if idea.get("exampleQuestions") else [] + ) + if quiz_count > 0: + knowledge_items.append(f"Quiz Questions ({quiz_count})") + if discussion_count > 0: + knowledge_items.append(f"Discussion Questions ({discussion_count})") + if knowledge_items: + agenda_items.append({"title": "Test Your Knowledge", "items": knowledge_items}) + if content.get("furtherReadings", []): + agenda_items.append( + {"title": "Additional Resources", "items": ["Further Readings & Resources"]} + ) + section_height = 0.5 + item_height = 0.35 + total_height_needed = 0 + for section in agenda_items: + total_height_needed += section_height + total_height_needed += len(section["items"]) * item_height + available_height = FOOTER_Y - 1.2 + slides_needed = math.ceil(total_height_needed / available_height) + total += slides_needed + total += 1 # Learning outcomes + key_terms = content.get("keyTerms", []) + key_terms_per_slide = 4 + if key_terms: + total += (len(key_terms) + key_terms_per_slide - 1) // key_terms_per_slide + total += len(content.get("slides", [])) + total += len(content.get("activities", [])) * 2 + for idea in content.get("assessmentIdeas", []): + if "quiz" in idea.get("type", "").lower(): + total += ( + len([q for q in idea.get("exampleQuestions", []) if q.get("options")]) + * 2 + ) + for idea in content.get("assessmentIdeas", []): + if "discussion" in idea.get("type", "").lower(): + total += ( + len( + idea.get("exampleQuestions", []) + if idea.get("exampleQuestions") + else [] + ) + * 2 + ) + readings = content.get("furtherReadings", []) + readings_per_slide = 2 + if readings: + total += (len(readings) + readings_per_slide - 1) // readings_per_slide + total += 1 # Closing + has_facilitation_notes = False + for activity in content.get("activities", []): + description = activity.get("description", "") + for pattern in [ + "Facilitation notes:", + "Facilitation Notes:", + "Facilitator notes:", + ]: + if pattern in description: + has_facilitation_notes = True + break + if has_facilitation_notes: + break + if has_facilitation_notes: + total += 1 + return total diff --git a/backend/pptx_builder/utils.py b/backend/pptx_builder/utils.py new file mode 100644 index 0000000..eb2dfa0 --- /dev/null +++ b/backend/pptx_builder/utils.py @@ -0,0 +1,110 @@ +import math, re, json +from .constants import ( + BULLET_MARKERS, + SUB_BULLET_MARKERS, + FOOTER_Y, + CONTENT_START_Y, + AVAILABLE_CONTENT_HEIGHT, +) + + +def clean_slide_title(title: str) -> str: + if ":" in title: + return title.split(":", 1)[1].strip() + parts = title.split() + if ( + len(parts) > 1 + and parts[0].lower() == "slide" + and parts[1].replace(":", "").isdigit() + ): + return " ".join(parts[2:]).strip() + return title.strip() + + +def clean_activity_title(title: str) -> str: + if ":" in title: + return title.split(":", 1)[1].strip() + parts = title.split() + if ( + len(parts) > 1 + and parts[0].lower() == "activity" + and parts[1].replace(":", "").isdigit() + ): + return " ".join(parts[2:]).strip() + return title.strip() + + +def detect_bullet_level(text: str): + text = text.strip() + for marker in BULLET_MARKERS: + if text.startswith(marker): + return True, 0, text[len(marker) :].strip() + if text.startswith(" ") or text.startswith("\t"): + stripped = text.lstrip() + for marker in SUB_BULLET_MARKERS: + if stripped.startswith(marker): + return True, 1, stripped[len(marker) :].strip() + return True, 1, stripped + if re.match(r"^\d+\.\s", text): + return False, 0, text + return False, 0, text + + +def calculate_dynamic_spacing( + items, available_height=AVAILABLE_CONTENT_HEIGHT, min_height=0.4 +): + if not items: + return min_height + count = len(items) + return min(0.8, max(min_height, available_height / max(count, 1))) + + +def estimate_text_height(text: str, font_size: int, width: float): + chars_per_inch = 120 / (font_size / 10) + chars_per_line = max(1, int(chars_per_inch * width)) + lines = math.ceil(len(text) / chars_per_line) + line_height = (font_size / 72) * 1.2 + return max(0.2, lines * line_height) + + +def check_content_overflow(y: float, h: float, footer=FOOTER_Y): + return (y + h) > (footer - 0.2) + + +def extract_facilitation_content(text: str): + clean_description = text + facilitation_notes = "" + learning_objectives = "" + facilitation_patterns = [ + "Facilitation notes:", + "Facilitation Notes:", + "FACILITATION NOTES:", + "Facilitator notes:", + "Facilitator guidance:", + "Facilitation tip:", + "Catatan fasilitasi:", + "Catatan Fasilitasi:", + "Panduan Fasilitator:", + ] + for pattern in facilitation_patterns: + if pattern in text: + parts = text.split(pattern, 1) + clean_description = parts[0].strip() + facilitation_notes = parts[1].strip() + break + learning_patterns = [ + "Learning Objective:", + "Learning Objectives:", + "LEARNING OBJECTIVES:", + "Success criteria:", + "Tujuan Pembelajaran:", + "Kriteria keberhasilan:", + ] + source = clean_description if facilitation_notes else text + for pattern in learning_patterns: + if pattern in source: + parts = source.split(pattern, 1) + clean_description = parts[0].strip() + learning_objectives = parts[1].strip() + break + return clean_description, facilitation_notes, learning_objectives diff --git a/frontend/.env.template b/frontend/.env.template index 4a43baa..b9f861e 100644 --- a/frontend/.env.template +++ b/frontend/.env.template @@ -9,10 +9,10 @@ PAYLOAD_SECRET= # Frontend - RAG RAG_TEMPERATURE=0.1 -RAG_EMBEDDING_MODEL=bge-large:335m +RAG_EMBEDDING_MODEL=bge-m3:567m RAG_EMBEDDING_CHUNK_SIZE_TOKEN=200 RAG_EMBEDDING_CHUNK_OVERLAP_TOKEN=50 -RAG_RERANKING_MODEL=llama3.2 +RAG_RERANKING_MODEL=aisingapore/Llama-SEA-LION-v3.5-8B-R:latest RAG_RERANKING_TOP_K=5 RAG_CONTEXT_SIMILARITY_THRESHOLD=0.7 RAG_CONTEXT_SIMILARITY_TOP_K=3 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3155351..bffa883 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,12 +8,15 @@ "name": "university-curriculum-enabling-tool", "version": "2025.0.0", "dependencies": { - "@ai-sdk/react": "^1.1.24", + "@ai-sdk/openai": "^2.0.40", + "@ai-sdk/provider": "^2.0.0", + "@ai-sdk/provider-utils": "^3.0.0", + "@ai-sdk/react": "^2.0.59", "@emoji-mart/data": "^1.2.1", - "@hookform/resolvers": "^3.10.0", - "@payloadcms/db-sqlite": "^3.51.0", - "@payloadcms/next": "^3.51.0", - "@payloadcms/richtext-lexical": "^3.51.0", + "@hookform/resolvers": "^5.2.2", + "@payloadcms/db-sqlite": "^3.58.0", + "@payloadcms/next": "^3.58.0", + "@payloadcms/richtext-lexical": "^3.58.0", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "^1.1.2", @@ -38,7 +41,7 @@ "@sentry/nextjs": "^9.0.1", "@tanstack/react-query": "^5.74.4", "@tanstack/react-table": "^8.21.2", - "ai": "^4.1.20", + "ai": "^5.0.59", "archiver": "^7.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -53,14 +56,15 @@ "geist": "^1.3.1", "graphql": "^16.10.0", "jsdom": "^26.1.0", + "jsonrepair": "^3.13.1", "jspdf": "^3.0.2", "jspdf-autotable": "^5.0.2", "lodash": "^4.17.21", "lucide-react": "^0.474.0", "next": "^15.5.2", "next-themes": "^0.4.6", - "ollama-ai-provider": "^1.2.0", - "payload": "^3.51.0", + "ollama-ai-provider-v2": "^1.4.1", + "payload": "^3.58.0", "pdf-parse": "^1.1.1", "pm2": "^6.0.10", "react": "^19.0.0", @@ -79,7 +83,7 @@ "tesseract.js": "^6.0.0", "vaul": "^1.1.2", "webpack": "^5.99.5", - "zod": "^3.24.2", + "zod": "^4.1.11", "zustand": "^5.0.3" }, "devDependencies": { @@ -106,10 +110,43 @@ "typescript": "^5" } }, + "node_modules/@ai-sdk/gateway": { + "version": "1.0.33", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.33.tgz", + "integrity": "sha512-v9i3GPEo4t3fGcSkQkc07xM6KJN75VUv7C1Mqmmsu2xD8lQwnQfsrgAXyNuWe20yGY0eHuheSPDZhiqsGKtH1g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.10", + "@vercel/oidc": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "2.0.42", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.42.tgz", + "integrity": "sha512-9mM6QS8k0ooH9qMC27nlrYLQmNDnO6Rk0JTmFo/yUxpABEWOcvQhMWNHbp9lFL6Ty5vkdINrujhsAQfWuEleOg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/provider": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", - "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz", + "integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==", "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" @@ -119,30 +156,30 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", - "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.10.tgz", + "integrity": "sha512-T1gZ76gEIwffep6MWI0QNy9jgoybUHE7TRaHB5k54K8mF91ciGFlbtCGxDYhMH3nCRergKwYFIDeFF0hJSIQHQ==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "1.1.3", - "nanoid": "^3.3.8", - "secure-json-parse": "^2.7.0" + "@ai-sdk/provider": "2.0.0", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.5" }, "engines": { "node": ">=18" }, "peerDependencies": { - "zod": "^3.23.8" + "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/@ai-sdk/react": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.12.tgz", - "integrity": "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==", + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-2.0.60.tgz", + "integrity": "sha512-Ev0MC0I7eDcCH4FnrHzK48g9bJjyF3F67MMq76qoVsbtcs6fGIO5RjmYgPoFeSo8/yQ5EM6i/14yfcD0oB+moA==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider-utils": "2.2.8", - "@ai-sdk/ui-utils": "1.2.11", + "@ai-sdk/provider-utils": "3.0.10", + "ai": "5.0.60", "swr": "^2.2.5", "throttleit": "2.1.0" }, @@ -151,7 +188,7 @@ }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", - "zod": "^3.23.8" + "zod": "^3.25.76 || ^4.1.8" }, "peerDependenciesMeta": { "zod": { @@ -159,23 +196,6 @@ } } }, - "node_modules/@ai-sdk/ui-utils": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz", - "integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "1.1.3", - "@ai-sdk/provider-utils": "2.2.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.23.8" - } - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -188,19 +208,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "11.9.3", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", @@ -252,30 +259,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", - "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", + "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.6", - "@babel/parser": "^7.28.0", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.0", - "@babel/types": "^7.28.0", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -300,13 +307,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", - "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.0", - "@babel/types": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -363,14 +370,14 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -407,25 +414,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz", - "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2" + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.0" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -435,9 +442,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", - "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -458,17 +465,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", - "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", + "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.0", + "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", - "@babel/types": "^7.28.0", + "@babel/types": "^7.28.4", "debug": "^4.3.1" }, "engines": { @@ -476,9 +483,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -499,9 +506,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", - "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "funding": [ { "type": "github", @@ -541,9 +548,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", - "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "funding": [ { "type": "github", @@ -556,7 +563,7 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.0.2", + "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "engines": { @@ -674,21 +681,21 @@ "license": "Apache-2.0" }, "node_modules/@emnapi/core": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", - "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", + "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.0.4", + "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", - "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", "license": "MIT", "optional": true, "dependencies": { @@ -696,9 +703,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", - "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "dev": true, "license": "MIT", "optional": true, @@ -863,7 +870,7 @@ "deprecated": "Merged into tsx: https://tsx.is", "license": "MIT", "dependencies": { - "esbuild": "~0.25.9", + "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, @@ -879,9 +886,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", - "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", "cpu": [ "ppc64" ], @@ -895,9 +902,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", - "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", "cpu": [ "arm" ], @@ -911,9 +918,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", - "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", "cpu": [ "arm64" ], @@ -927,9 +934,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", - "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", "cpu": [ "x64" ], @@ -943,9 +950,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", - "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", "cpu": [ "arm64" ], @@ -959,9 +966,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", - "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", "cpu": [ "x64" ], @@ -975,9 +982,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", - "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", "cpu": [ "arm64" ], @@ -991,9 +998,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", - "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", "cpu": [ "x64" ], @@ -1007,9 +1014,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", - "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", "cpu": [ "arm" ], @@ -1023,9 +1030,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", - "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", "cpu": [ "arm64" ], @@ -1039,9 +1046,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", - "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", "cpu": [ "ia32" ], @@ -1055,9 +1062,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", - "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", "cpu": [ "loong64" ], @@ -1071,9 +1078,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", - "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", "cpu": [ "mips64el" ], @@ -1087,9 +1094,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", - "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", "cpu": [ "ppc64" ], @@ -1103,9 +1110,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", - "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", "cpu": [ "riscv64" ], @@ -1119,9 +1126,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", - "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", "cpu": [ "s390x" ], @@ -1135,9 +1142,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", - "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", "cpu": [ "x64" ], @@ -1151,9 +1158,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", - "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", "cpu": [ "arm64" ], @@ -1167,9 +1174,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", - "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", "cpu": [ "x64" ], @@ -1183,9 +1190,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", - "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", "cpu": [ "arm64" ], @@ -1199,9 +1206,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", - "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", "cpu": [ "x64" ], @@ -1215,9 +1222,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", - "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", "cpu": [ "arm64" ], @@ -1231,9 +1238,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", - "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", "cpu": [ "x64" ], @@ -1247,9 +1254,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", - "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", "cpu": [ "arm64" ], @@ -1263,9 +1270,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", - "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", "cpu": [ "ia32" ], @@ -1279,9 +1286,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", - "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", "cpu": [ "x64" ], @@ -1295,9 +1302,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "license": "MIT", "dependencies": { @@ -1399,9 +1406,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", - "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", + "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", "dev": true, "license": "MIT", "engines": { @@ -1436,9 +1443,9 @@ } }, "node_modules/@faceless-ui/modal": { - "version": "3.0.0-beta.2", - "resolved": "https://registry.npmjs.org/@faceless-ui/modal/-/modal-3.0.0-beta.2.tgz", - "integrity": "sha512-UmXvz7Iw3KMO4Pm3llZczU4uc5pPQDb6rdqwoBvYDFgWvkraOAHKx0HxSZgwqQvqOhn8joEFBfFp6/Do2562ow==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@faceless-ui/modal/-/modal-3.0.0.tgz", + "integrity": "sha512-o3oEFsot99EQ8RJc1kL3s/nNMHX+y+WMXVzSSmca9L0l2MR6ez2QM1z1yIelJX93jqkLXQ9tW+R9tmsYa+O4Qg==", "funding": [ { "type": "individual", @@ -1452,8 +1459,8 @@ "react-transition-group": "4.4.5" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@faceless-ui/scroll-info": { @@ -1498,9 +1505,9 @@ } }, "node_modules/@floating-ui/dom": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz", - "integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.3", @@ -1508,12 +1515,12 @@ } }, "node_modules/@floating-ui/react": { - "version": "0.27.15", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.15.tgz", - "integrity": "sha512-0LGxhBi3BB1DwuSNQAmuaSuertFzNAerlMdPbotjTVnvPtdOs7CkrHLaev5NIXemhzDXNC0tFzuseut7cWA5mw==", + "version": "0.27.16", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz", + "integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==", "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.1.5", + "@floating-ui/react-dom": "^2.1.6", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.0.0" }, @@ -1523,12 +1530,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.5.tgz", - "integrity": "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.3" + "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", @@ -1542,12 +1549,15 @@ "license": "MIT" }, "node_modules/@hookform/resolvers": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", - "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, "peerDependencies": { - "react-hook-form": "^7.0.0" + "react-hook-form": "^7.55.0" } }, "node_modules/@humanfs/core": { @@ -1561,33 +1571,19 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1616,6 +1612,16 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -1725,9 +1731,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", - "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", "cpu": [ "ppc64" ], @@ -1849,9 +1855,9 @@ } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", - "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", "cpu": [ "ppc64" ], @@ -1867,7 +1873,7 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.0" + "@img/sharp-libvips-linux-ppc64": "1.2.3" } }, "node_modules/@img/sharp-linux-s390x": { @@ -1978,9 +1984,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", - "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", "cpu": [ "arm64" ], @@ -2061,6 +2067,16 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2087,9 +2103,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.30", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", - "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2103,41 +2119,41 @@ "license": "MIT" }, "node_modules/@lexical/clipboard": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.28.0.tgz", - "integrity": "sha512-LYqion+kAwFQJStA37JAEMxTL/m1WlZbotDfM/2WuONmlO0yWxiyRDI18oeCwhBD6LQQd9c3Ccxp9HFwUG1AVw==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.35.0.tgz", + "integrity": "sha512-ko7xSIIiayvDiqjNDX6fgH9RlcM6r9vrrvJYTcfGVBor5httx16lhIi0QJZ4+RNPvGtTjyFv4bwRmsixRRwImg==", "license": "MIT", "dependencies": { - "@lexical/html": "0.28.0", - "@lexical/list": "0.28.0", - "@lexical/selection": "0.28.0", - "@lexical/utils": "0.28.0", - "lexical": "0.28.0" + "@lexical/html": "0.35.0", + "@lexical/list": "0.35.0", + "@lexical/selection": "0.35.0", + "@lexical/utils": "0.35.0", + "lexical": "0.35.0" } }, "node_modules/@lexical/code": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.28.0.tgz", - "integrity": "sha512-9LOKSWdRhxqAKRq5yveNC21XKtW4h2rmFNTucwMWZ9vLu9xteOHEwZdO1Qv82PFUmgCpAhg6EntmnZu9xD3K7Q==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.35.0.tgz", + "integrity": "sha512-ox4DZwETQ9IA7+DS6PN8RJNwSAF7RMjL7YTVODIqFZ5tUFIf+5xoCHbz7Fll0Bvixlp12hVH90xnLwTLRGpkKw==", "license": "MIT", "dependencies": { - "@lexical/utils": "0.28.0", - "lexical": "0.28.0", + "@lexical/utils": "0.35.0", + "lexical": "0.35.0", "prismjs": "^1.30.0" } }, "node_modules/@lexical/devtools-core": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.28.0.tgz", - "integrity": "sha512-Fk4itAjZ+MqTYXN84aE5RDf+wQX67N5nyo3JVxQTFZGAghx7Ux1xLWHB25zzD0YfjMtJ0NQROAbE3xdecZzxcQ==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.35.0.tgz", + "integrity": "sha512-C2wwtsMCR6ZTfO0TqpSM17RLJWyfHmifAfCTjFtOJu15p3M6NO/nHYK5Mt7YMQteuS89mOjB4ng8iwoLEZ6QpQ==", "license": "MIT", "dependencies": { - "@lexical/html": "0.28.0", - "@lexical/link": "0.28.0", - "@lexical/mark": "0.28.0", - "@lexical/table": "0.28.0", - "@lexical/utils": "0.28.0", - "lexical": "0.28.0" + "@lexical/html": "0.35.0", + "@lexical/link": "0.35.0", + "@lexical/mark": "0.35.0", + "@lexical/table": "0.35.0", + "@lexical/utils": "0.35.0", + "lexical": "0.35.0" }, "peerDependencies": { "react": ">=17.x", @@ -2145,152 +2161,153 @@ } }, "node_modules/@lexical/dragon": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.28.0.tgz", - "integrity": "sha512-T6T8YaHnhU863ruuqmRHTLUYa8sfg/ArYcrnNGZGfpvvFTfFjpWb/ELOvOWo8N6Y/4fnSLjQ20aXexVW1KcTBQ==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.35.0.tgz", + "integrity": "sha512-SL6mT5pcqrt6hEbJ16vWxip5+r3uvMd0bQV5UUxuk+cxIeuP86iTgRh0HFR7SM2dRTYovL6/tM/O+8QLAUGTIg==", "license": "MIT", "dependencies": { - "lexical": "0.28.0" + "lexical": "0.35.0" } }, "node_modules/@lexical/hashtag": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.28.0.tgz", - "integrity": "sha512-zcqX9Qna4lj96bAUfwSQSVEhYQ0O5erSjrIhOVqEgeQ5ubz0EvqnnMbbwNHIb2n6jzSwAvpD/3UZJZtolh+zVg==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.35.0.tgz", + "integrity": "sha512-LYJWzXuO2ZjKsvQwrLkNZiS2TsjwYkKjlDgtugzejquTBQ/o/nfSn/MmVx6EkYLOYizaJemmZbz3IBh+u732FA==", "license": "MIT", "dependencies": { - "@lexical/utils": "0.28.0", - "lexical": "0.28.0" + "@lexical/utils": "0.35.0", + "lexical": "0.35.0" } }, "node_modules/@lexical/headless": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/headless/-/headless-0.28.0.tgz", - "integrity": "sha512-btcaTfw9I/xQ/XYom6iKWgsPecmRawGd/5jOhP7QDtLUp7gxgM7/kiCZFYa8jDJO6j20rXuWTkc81ynVpKvjow==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/headless/-/headless-0.35.0.tgz", + "integrity": "sha512-UPmCqOsdGGC7/8Fkae2ADkTQfxTZOKxNEVKuqPfCkFs4Bag3s4z3V61jE+wYzqyU8eJh4DqZYSHoPzZCj8P9jg==", "license": "MIT", "dependencies": { - "lexical": "0.28.0" + "lexical": "0.35.0" } }, "node_modules/@lexical/history": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.28.0.tgz", - "integrity": "sha512-CHzDxaGDn6qCFFhU0YKP1B8sgEb++0Ksqsj6BfDL/6TMxoLNQwRQhP3BUNNXl1kvUhxTQZgk3b9MjJZRaFKG9Q==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.35.0.tgz", + "integrity": "sha512-onjDRLLxGbCfHexSxxrQaDaieIHyV28zCDrbxR5dxTfW8F8PxjuNyuaG0z6o468AXYECmclxkP+P4aT6poHEpQ==", "license": "MIT", "dependencies": { - "@lexical/utils": "0.28.0", - "lexical": "0.28.0" + "@lexical/utils": "0.35.0", + "lexical": "0.35.0" } }, "node_modules/@lexical/html": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.28.0.tgz", - "integrity": "sha512-ayb0FPxr55Ko99/d9ewbfrApul4L0z+KpU2ZG03im7EvUPVLyIGLx4S0QguMDvQh0Vu+eJ7/EESuonDs5BCe3A==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.35.0.tgz", + "integrity": "sha512-rXGFE5S5rKsg3tVnr1s4iEgOfCApNXGpIFI3T2jGEShaCZ5HLaBY9NVBXnE9Nb49e9bkDkpZ8FZd1qokCbQXbw==", "license": "MIT", "dependencies": { - "@lexical/selection": "0.28.0", - "@lexical/utils": "0.28.0", - "lexical": "0.28.0" + "@lexical/selection": "0.35.0", + "@lexical/utils": "0.35.0", + "lexical": "0.35.0" } }, "node_modules/@lexical/link": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.28.0.tgz", - "integrity": "sha512-T5VKxpOnML5DcXv2lW3Le0vjNlcbdohZjS9f6PAvm6eX8EzBKDpLQCopr1/0KGdlLd1QrzQsykQrdU7ieC4LRg==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.35.0.tgz", + "integrity": "sha512-+0Wx6cBwO8TfdMzpkYFacsmgFh8X1rkiYbq3xoLvk3qV8upYxaMzK1s8Q1cpKmWyI0aZrU6z7fiK4vUqB7+69w==", "license": "MIT", "dependencies": { - "@lexical/utils": "0.28.0", - "lexical": "0.28.0" + "@lexical/utils": "0.35.0", + "lexical": "0.35.0" } }, "node_modules/@lexical/list": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.28.0.tgz", - "integrity": "sha512-3a8QcZ75n2TLxP+xkSPJ2V15jsysMLMe0YoObG+ew/sioVelIU8GciYsWBo5GgQmwSzJNQJeK5cJ9p1b71z2cg==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.35.0.tgz", + "integrity": "sha512-owsmc8iwgExBX8sFe8fKTiwJVhYULt9hD1RZ/HwfaiEtRZZkINijqReOBnW2mJfRxBzhFSWc4NG3ISB+fHYzqw==", "license": "MIT", "dependencies": { - "@lexical/selection": "0.28.0", - "@lexical/utils": "0.28.0", - "lexical": "0.28.0" + "@lexical/selection": "0.35.0", + "@lexical/utils": "0.35.0", + "lexical": "0.35.0" } }, "node_modules/@lexical/mark": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.28.0.tgz", - "integrity": "sha512-v5PzmTACsJrw3GvNZy2rgPxrNn9InLvLFoKqrSlNhhyvYNIAcuC4KVy00LKLja43Gw/fuB3QwKohYfAtM3yR3g==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.35.0.tgz", + "integrity": "sha512-W0hwMTAVeexvpk9/+J6n1G/sNkpI/Meq1yeDazahFLLAwXLHtvhIAq2P/klgFknDy1hr8X7rcsQuN/bqKcKHYg==", "license": "MIT", "dependencies": { - "@lexical/utils": "0.28.0", - "lexical": "0.28.0" + "@lexical/utils": "0.35.0", + "lexical": "0.35.0" } }, "node_modules/@lexical/markdown": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.28.0.tgz", - "integrity": "sha512-F3JXClqN4cjmXYLDK0IztxkbZuqkqS/AVbxnhGvnDYHQ9Gp8l7BonczhOiPwmJCDubJrAACP0L9LCqyt0jDRFw==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.35.0.tgz", + "integrity": "sha512-BlNyXZAt4gWidMw0SRWrhBETY1BpPglFBZI7yzfqukFqgXRh7HUQA28OYeI/nsx9pgNob8TiUduUwShqqvOdEA==", "license": "MIT", "dependencies": { - "@lexical/code": "0.28.0", - "@lexical/link": "0.28.0", - "@lexical/list": "0.28.0", - "@lexical/rich-text": "0.28.0", - "@lexical/text": "0.28.0", - "@lexical/utils": "0.28.0", - "lexical": "0.28.0" + "@lexical/code": "0.35.0", + "@lexical/link": "0.35.0", + "@lexical/list": "0.35.0", + "@lexical/rich-text": "0.35.0", + "@lexical/text": "0.35.0", + "@lexical/utils": "0.35.0", + "lexical": "0.35.0" } }, "node_modules/@lexical/offset": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.28.0.tgz", - "integrity": "sha512-/SMDQgBPeWM936t04mtH6UAn3xAjP/meu9q136bcT3S7p7V8ew9JfNp9aznTPTx+2W3brJORAvUow7Xn1fSHmw==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.35.0.tgz", + "integrity": "sha512-DRE4Df6qYf2XiV6foh6KpGNmGAv2ANqt3oVXpyS6W8hTx3+cUuAA1APhCZmLNuU107um4zmHym7taCu6uXW5Yg==", "license": "MIT", "dependencies": { - "lexical": "0.28.0" + "lexical": "0.35.0" } }, "node_modules/@lexical/overflow": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.28.0.tgz", - "integrity": "sha512-ppmhHXEZVicBm05w9EVflzwFavTVNAe4q0bkabWUeW0IoCT3Vg2A3JT7PC9ypmp+mboUD195foFEr1BBSv1Y8Q==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.35.0.tgz", + "integrity": "sha512-B25YvnJQTGlZcrNv7b0PJBLWq3tl8sql497OHfYYLem7EOMPKKDGJScJAKM/91D4H/mMAsx5gnA/XgKobriuTg==", "license": "MIT", "dependencies": { - "lexical": "0.28.0" + "lexical": "0.35.0" } }, "node_modules/@lexical/plain-text": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.28.0.tgz", - "integrity": "sha512-Jj2dCMDEfRuVetfDKcUes8J5jvAfZrLnILFlHxnu7y+lC+7R/NR403DYb3NJ8H7+lNiH1K15+U2K7ewbjxS6KQ==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.35.0.tgz", + "integrity": "sha512-lwBCUNMJf7Gujp2syVWMpKRahfbTv5Wq+H3HK1Q1gKH1P2IytPRxssCHvexw9iGwprSyghkKBlbF3fGpEdIJvQ==", "license": "MIT", "dependencies": { - "@lexical/clipboard": "0.28.0", - "@lexical/selection": "0.28.0", - "@lexical/utils": "0.28.0", - "lexical": "0.28.0" + "@lexical/clipboard": "0.35.0", + "@lexical/selection": "0.35.0", + "@lexical/utils": "0.35.0", + "lexical": "0.35.0" } }, "node_modules/@lexical/react": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.28.0.tgz", - "integrity": "sha512-dWPnxrKrbQFjNqExqnaAsV0UEUgw/5M1ZYRWd5FGBGjHqVTCaX2jNHlKLMA68Od0VPIoOX2Zy1TYZ8ZKtsj5Dg==", - "license": "MIT", - "dependencies": { - "@lexical/devtools-core": "0.28.0", - "@lexical/dragon": "0.28.0", - "@lexical/hashtag": "0.28.0", - "@lexical/history": "0.28.0", - "@lexical/link": "0.28.0", - "@lexical/list": "0.28.0", - "@lexical/mark": "0.28.0", - "@lexical/markdown": "0.28.0", - "@lexical/overflow": "0.28.0", - "@lexical/plain-text": "0.28.0", - "@lexical/rich-text": "0.28.0", - "@lexical/table": "0.28.0", - "@lexical/text": "0.28.0", - "@lexical/utils": "0.28.0", - "@lexical/yjs": "0.28.0", - "lexical": "0.28.0", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.35.0.tgz", + "integrity": "sha512-uYAZSqumH8tRymMef+A0f2hQvMwplKK9DXamcefnk3vSNDHHqRWQXpiUo6kD+rKWuQmMbVa5RW4xRQebXEW+1A==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.8", + "@lexical/devtools-core": "0.35.0", + "@lexical/dragon": "0.35.0", + "@lexical/hashtag": "0.35.0", + "@lexical/history": "0.35.0", + "@lexical/link": "0.35.0", + "@lexical/list": "0.35.0", + "@lexical/mark": "0.35.0", + "@lexical/markdown": "0.35.0", + "@lexical/overflow": "0.35.0", + "@lexical/plain-text": "0.35.0", + "@lexical/rich-text": "0.35.0", + "@lexical/table": "0.35.0", + "@lexical/text": "0.35.0", + "@lexical/utils": "0.35.0", + "@lexical/yjs": "0.35.0", + "lexical": "0.35.0", "react-error-boundary": "^3.1.4" }, "peerDependencies": { @@ -2315,67 +2332,67 @@ } }, "node_modules/@lexical/rich-text": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.28.0.tgz", - "integrity": "sha512-y+vUWI+9uFupIb9UvssKU/DKcT9dFUZuQBu7utFkLadxCNyXQHeRjxzjzmvFiM3DBV0guPUDGu5VS5TPnIA+OA==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.35.0.tgz", + "integrity": "sha512-qEHu8g7vOEzz9GUz1VIUxZBndZRJPh9iJUFI+qTDHj+tQqnd5LCs+G9yz6jgNfiuWWpezTp0i1Vz/udNEuDPKQ==", "license": "MIT", "dependencies": { - "@lexical/clipboard": "0.28.0", - "@lexical/selection": "0.28.0", - "@lexical/utils": "0.28.0", - "lexical": "0.28.0" + "@lexical/clipboard": "0.35.0", + "@lexical/selection": "0.35.0", + "@lexical/utils": "0.35.0", + "lexical": "0.35.0" } }, "node_modules/@lexical/selection": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.28.0.tgz", - "integrity": "sha512-AJDi67Nsexyejzp4dEQSVoPov4P+FJ0t1v6DxUU+YmcvV56QyJQi6ue0i/xd8unr75ZufzLsAC0cDJJCEI7QDA==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.35.0.tgz", + "integrity": "sha512-mMtDE7Q0nycXdFTTH/+ta6EBrBwxBB4Tg8QwsGntzQ1Cq//d838dpXpFjJOqHEeVHUqXpiuj+cBG8+bvz/rPRw==", "license": "MIT", "dependencies": { - "lexical": "0.28.0" + "lexical": "0.35.0" } }, "node_modules/@lexical/table": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.28.0.tgz", - "integrity": "sha512-HMPCwXdj0sRWdlDzsHcNWRgbeKbEhn3L8LPhFnTq7q61gZ4YW2umdmuvQFKnIBcKq49drTH8cUwZoIwI8+AEEw==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.35.0.tgz", + "integrity": "sha512-9jlTlkVideBKwsEnEkqkdg7A3mije1SvmfiqoYnkl1kKJCLA5iH90ywx327PU0p+bdnURAytWUeZPXaEuEl2OA==", "license": "MIT", "dependencies": { - "@lexical/clipboard": "0.28.0", - "@lexical/utils": "0.28.0", - "lexical": "0.28.0" + "@lexical/clipboard": "0.35.0", + "@lexical/utils": "0.35.0", + "lexical": "0.35.0" } }, "node_modules/@lexical/text": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.28.0.tgz", - "integrity": "sha512-PT/A2RZv+ktn7SG/tJkOpGlYE6zjOND59VtRHnV/xciZ+jEJVaqAHtWjhbWibAIZQAkv/O7UouuDqzDaNTSGAA==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.35.0.tgz", + "integrity": "sha512-uaMh46BkysV8hK8wQwp5g/ByZW+2hPDt8ahAErxtf8NuzQem1FHG/f5RTchmFqqUDVHO3qLNTv4AehEGmXv8MA==", "license": "MIT", "dependencies": { - "lexical": "0.28.0" + "lexical": "0.35.0" } }, "node_modules/@lexical/utils": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.28.0.tgz", - "integrity": "sha512-Qw00DjkS1nRK7DLSgqJpJ77Ti2AuiOQ6m5eM38YojoWXkVmoxqKAUMaIbVNVKqjFgrQvKFF46sXxIJPbUQkB0w==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.35.0.tgz", + "integrity": "sha512-2H393EYDnFznYCDFOW3MHiRzwEO5M/UBhtUjvTT+9kc+qhX4U3zc8ixQalo5UmZ5B2nh7L/inXdTFzvSRXtsRA==", "license": "MIT", "dependencies": { - "@lexical/list": "0.28.0", - "@lexical/selection": "0.28.0", - "@lexical/table": "0.28.0", - "lexical": "0.28.0" + "@lexical/list": "0.35.0", + "@lexical/selection": "0.35.0", + "@lexical/table": "0.35.0", + "lexical": "0.35.0" } }, "node_modules/@lexical/yjs": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.28.0.tgz", - "integrity": "sha512-rKHpUEd3nrvMY7ghmOC0AeGSYT7YIviba+JViaOzrCX4/Wtv5C/3Sl7Io12Z9k+s1BKmy7C28bOdQHvRWaD7vQ==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.35.0.tgz", + "integrity": "sha512-3DSP7QpmTGYU9bN/yljP0PIao4tNIQtsR4ycauWNSawxs/GQCZtSmAPcLRnCm6qpqsDDjUtKjO/1Ej8FRp0m0w==", "license": "MIT", "dependencies": { - "@lexical/offset": "0.28.0", - "@lexical/selection": "0.28.0", - "lexical": "0.28.0" + "@lexical/offset": "0.35.0", + "@lexical/selection": "0.35.0", + "lexical": "0.35.0" }, "peerDependencies": { "yjs": ">=13.5.22" @@ -2568,9 +2585,9 @@ "license": "MIT" }, "node_modules/@next/env": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.2.tgz", - "integrity": "sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.4.tgz", + "integrity": "sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2584,9 +2601,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.2.tgz", - "integrity": "sha512-8bGt577BXGSd4iqFygmzIfTYizHb0LGWqH+qgIF/2EDxS5JsSdERJKA8WgwDyNBZgTIIA4D8qUtoQHmxIIquoQ==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.4.tgz", + "integrity": "sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA==", "cpu": [ "arm64" ], @@ -2600,9 +2617,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.2.tgz", - "integrity": "sha512-2DjnmR6JHK4X+dgTXt5/sOCu/7yPtqpYt8s8hLkHFK3MGkka2snTv3yRMdHvuRtJVkPwCGsvBSwmoQCHatauFQ==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.4.tgz", + "integrity": "sha512-QOTCFq8b09ghfjRJKfb68kU9k2K+2wsC4A67psOiMn849K9ZXgCSRQr0oVHfmKnoqCbEmQWG1f2h1T2vtJJ9mA==", "cpu": [ "x64" ], @@ -2616,9 +2633,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.2.tgz", - "integrity": "sha512-3j7SWDBS2Wov/L9q0mFJtEvQ5miIqfO4l7d2m9Mo06ddsgUK8gWfHGgbjdFlCp2Ek7MmMQZSxpGFqcC8zGh2AA==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.4.tgz", + "integrity": "sha512-eRD5zkts6jS3VfE/J0Kt1VxdFqTnMc3QgO5lFE5GKN3KDI/uUpSyK3CjQHmfEkYR4wCOl0R0XrsjpxfWEA++XA==", "cpu": [ "arm64" ], @@ -2632,9 +2649,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.2.tgz", - "integrity": "sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.4.tgz", + "integrity": "sha512-TOK7iTxmXFc45UrtKqWdZ1shfxuL4tnVAOuuJK4S88rX3oyVV4ZkLjtMT85wQkfBrOOvU55aLty+MV8xmcJR8A==", "cpu": [ "arm64" ], @@ -2648,9 +2665,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.2.tgz", - "integrity": "sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.4.tgz", + "integrity": "sha512-7HKolaj+481FSW/5lL0BcTkA4Ueam9SPYWyN/ib/WGAFZf0DGAN8frNpNZYFHtM4ZstrHZS3LY3vrwlIQfsiMA==", "cpu": [ "x64" ], @@ -2664,9 +2681,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.2.tgz", - "integrity": "sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.4.tgz", + "integrity": "sha512-nlQQ6nfgN0nCO/KuyEUwwOdwQIGjOs4WNMjEUtpIQJPR2NUfmGpW2wkJln1d4nJ7oUzd1g4GivH5GoEPBgfsdw==", "cpu": [ "x64" ], @@ -2680,9 +2697,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.2.tgz", - "integrity": "sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.4.tgz", + "integrity": "sha512-PcR2bN7FlM32XM6eumklmyWLLbu2vs+D7nJX8OAIoWy69Kef8mfiN4e8TUv2KohprwifdpFKPzIP1njuCjD0YA==", "cpu": [ "arm64" ], @@ -2696,9 +2713,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.2.tgz", - "integrity": "sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.4.tgz", + "integrity": "sha512-1ur2tSHZj8Px/KMAthmuI9FMp/YFusMMGoRNJaRZMOlSkgvLjzosSdQI0cJAKogdHl3qXUQKL9MGaYvKwA7DXg==", "cpu": [ "x64" ], @@ -2766,22 +2783,21 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.200.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.200.0.tgz", - "integrity": "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", + "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api": "^1.3.0" }, "engines": { - "node": ">=8.0.0" + "node": ">=14" } }, "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.0.1.tgz", - "integrity": "sha512-XuY23lSI3d4PEqKA+7SLtAgwqIfc6E/E9eAQWLN1vlpC53ybO3o6jW4BsXo1xvz9lYyyWItfQDDLzezER01mCw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.1.0.tgz", + "integrity": "sha512-zOyetmZppnwTyPrt4S7jMfXiSX9yyfF0hxlA8B5oo2TtKl+/RGCy7fi4DrBfIf3lCPrkKsRBWZZD7RFojK7FDg==", "license": "Apache-2.0", "peer": true, "engines": { @@ -2792,9 +2808,9 @@ } }, "node_modules/@opentelemetry/core": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", - "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -2808,20 +2824,20 @@ } }, "node_modules/@opentelemetry/instrumentation": { - "version": "0.200.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.200.0.tgz", - "integrity": "sha512-pmPlzfJd+vvgaZd/reMsC8RWgTXn2WY1OWT5RT42m3aOn5532TozwXNDhg1vzqJ+jnvmkREcdLr27ebJEQt0Jg==", + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", + "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", "license": "Apache-2.0", - "peer": true, "dependencies": { - "@opentelemetry/api-logs": "0.200.0", + "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", "shimmer": "^1.2.1" }, "engines": { - "node": "^18.19.0 || >=20.6.0" + "node": ">=14" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" @@ -2844,18 +2860,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-amqplib/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/instrumentation-amqplib/node_modules/@opentelemetry/core": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", @@ -2871,26 +2875,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/instrumentation-amqplib/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-amqplib/node_modules/@opentelemetry/semantic-conventions": { "version": "1.28.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", @@ -2918,18 +2902,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-connect/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/instrumentation-connect/node_modules/@opentelemetry/core": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", @@ -2945,26 +2917,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/instrumentation-connect/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-connect/node_modules/@opentelemetry/semantic-conventions": { "version": "1.28.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", @@ -2989,38 +2941,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-dataloader/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation-dataloader/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-express": { "version": "0.47.1", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.47.1.tgz", @@ -3038,18 +2958,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-express/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/instrumentation-express/node_modules/@opentelemetry/core": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", @@ -3065,26 +2973,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/instrumentation-express/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-express/node_modules/@opentelemetry/semantic-conventions": { "version": "1.28.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", @@ -3110,18 +2998,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-fs/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/instrumentation-fs/node_modules/@opentelemetry/core": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", @@ -3137,30 +3013,10 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/instrumentation-fs/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-fs/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", - "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "node_modules/@opentelemetry/instrumentation-fs/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -3181,38 +3037,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-generic-pool/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation-generic-pool/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-graphql": { "version": "0.47.1", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.47.1.tgz", @@ -3228,38 +3052,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-graphql/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation-graphql/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-hapi": { "version": "0.45.2", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.45.2.tgz", @@ -3277,18 +3069,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-hapi/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/instrumentation-hapi/node_modules/@opentelemetry/core": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", @@ -3304,26 +3084,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/instrumentation-hapi/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-hapi/node_modules/@opentelemetry/semantic-conventions": { "version": "1.28.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", @@ -3352,18 +3112,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", @@ -3379,26 +3127,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/semantic-conventions": { "version": "1.28.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", @@ -3425,38 +3153,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-ioredis/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation-ioredis/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-kafkajs": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.7.1.tgz", @@ -3473,38 +3169,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-kafkajs/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation-kafkajs/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-knex": { "version": "0.44.1", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.44.1.tgz", @@ -3521,38 +3185,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-knex/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation-knex/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-koa": { "version": "0.47.1", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.47.1.tgz", @@ -3570,18 +3202,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-koa/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/instrumentation-koa/node_modules/@opentelemetry/core": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", @@ -3597,26 +3217,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/instrumentation-koa/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-koa/node_modules/@opentelemetry/semantic-conventions": { "version": "1.28.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", @@ -3641,38 +3241,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-lru-memoizer/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation-lru-memoizer/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-mongodb": { "version": "0.52.0", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.52.0.tgz", @@ -3689,38 +3257,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-mongodb/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation-mongodb/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-mongoose": { "version": "0.46.1", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.46.1.tgz", @@ -3738,18 +3274,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-mongoose/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/instrumentation-mongoose/node_modules/@opentelemetry/core": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", @@ -3765,26 +3289,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/instrumentation-mongoose/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-mongoose/node_modules/@opentelemetry/semantic-conventions": { "version": "1.28.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", @@ -3811,79 +3315,15 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-mysql/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-mysql2": { "version": "0.45.2", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.45.2.tgz", "integrity": "sha512-h6Ad60FjCYdJZ5DTz1Lk2VmQsShiViKe0G7sYikb0GHI0NVvApp2XQNRHNjEMz87roFttGPLHOYVPlfy+yVIhQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@opentelemetry/sql-common": "^0.40.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql2/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql2/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@opentelemetry/sql-common": "^0.40.1" }, "engines": { "node": ">=14" @@ -3912,18 +3352,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-pg/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/instrumentation-pg/node_modules/@opentelemetry/core": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", @@ -3939,26 +3367,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/instrumentation-pg/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-pg/node_modules/@opentelemetry/semantic-conventions": { "version": "1.28.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", @@ -3985,38 +3393,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-redis-4/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation-redis-4/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-tedious": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.18.1.tgz", @@ -4034,38 +3410,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-tedious/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation-tedious/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-undici": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.10.1.tgz", @@ -4082,18 +3426,6 @@ "@opentelemetry/api": "^1.7.0" } }, - "node_modules/@opentelemetry/instrumentation-undici/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/instrumentation-undici/node_modules/@opentelemetry/core": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", @@ -4109,26 +3441,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/instrumentation-undici/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/instrumentation-undici/node_modules/@opentelemetry/semantic-conventions": { "version": "1.28.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", @@ -4148,13 +3460,13 @@ } }, "node_modules/@opentelemetry/resources": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", - "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.1.0.tgz", + "integrity": "sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@opentelemetry/core": "2.0.1", + "@opentelemetry/core": "2.1.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4165,14 +3477,14 @@ } }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", - "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz", + "integrity": "sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@opentelemetry/core": "2.0.1", - "@opentelemetry/resources": "2.0.1", + "@opentelemetry/core": "2.1.0", + "@opentelemetry/resources": "2.1.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4183,9 +3495,9 @@ } }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.36.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.36.0.tgz", - "integrity": "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ==", + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz", + "integrity": "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -4231,13 +3543,13 @@ } }, "node_modules/@payloadcms/db-sqlite": { - "version": "3.51.0", - "resolved": "https://registry.npmjs.org/@payloadcms/db-sqlite/-/db-sqlite-3.51.0.tgz", - "integrity": "sha512-rqxjyMvgEkpz5+I/XFsMb0rsNGNEp5tr5S2TCh/CMZ+yKWHtVClw2S2CUVdPpJ3reELUEr+6Na1mFWeA9WnXbQ==", + "version": "3.58.0", + "resolved": "https://registry.npmjs.org/@payloadcms/db-sqlite/-/db-sqlite-3.58.0.tgz", + "integrity": "sha512-G5NDOnPe1EOApjvdznKz1/gShwE56sMrllwjc2q7RpIaKouznxX/oHzHdI38rUSsewy8pfLFjhdPlgnPkBSLMA==", "license": "MIT", "dependencies": { "@libsql/client": "0.14.0", - "@payloadcms/drizzle": "3.51.0", + "@payloadcms/drizzle": "3.58.0", "console-table-printer": "2.12.1", "drizzle-kit": "0.31.4", "drizzle-orm": "0.44.2", @@ -4246,13 +3558,13 @@ "uuid": "9.0.0" }, "peerDependencies": { - "payload": "3.51.0" + "payload": "3.58.0" } }, "node_modules/@payloadcms/drizzle": { - "version": "3.51.0", - "resolved": "https://registry.npmjs.org/@payloadcms/drizzle/-/drizzle-3.51.0.tgz", - "integrity": "sha512-D56zRG23B/PKIH0HRNQRsoljIjMhDqNB3vZq4mQdS3Kt0jqsjkGweh+N1jHJjR591wUjrM1Y627ar0H0kBsFNA==", + "version": "3.58.0", + "resolved": "https://registry.npmjs.org/@payloadcms/drizzle/-/drizzle-3.58.0.tgz", + "integrity": "sha512-rY9NpbHJz30AuV++9eiM/PUwnwGm+2EsA1HkgltgcDg7Ej5YS5/gk+gbHKWDd8RpGSwdVvSmr5+8thyV9Hh/6Q==", "license": "MIT", "dependencies": { "console-table-printer": "2.12.1", @@ -4263,13 +3575,13 @@ "uuid": "9.0.0" }, "peerDependencies": { - "payload": "3.51.0" + "payload": "3.58.0" } }, "node_modules/@payloadcms/graphql": { - "version": "3.51.0", - "resolved": "https://registry.npmjs.org/@payloadcms/graphql/-/graphql-3.51.0.tgz", - "integrity": "sha512-sFhQl9d6oi/9cJDvdvQVF0Nk1wgvjdFjouS9hOwAY6IZ+8TR5I3z9ynmccCgCkeiALgM/JyQTqSe34eY7x2iGA==", + "version": "3.58.0", + "resolved": "https://registry.npmjs.org/@payloadcms/graphql/-/graphql-3.58.0.tgz", + "integrity": "sha512-f8akk3of6Bl1Rp4bb6coBczll5IelUOs2mUSQyHvOGKbfz394FsNGX2WSl5DFaelEyyf/cVZpF7tgklvAN+DyA==", "license": "MIT", "dependencies": { "graphql-scalars": "1.22.2", @@ -4282,19 +3594,19 @@ }, "peerDependencies": { "graphql": "^16.8.1", - "payload": "3.51.0" + "payload": "3.58.0" } }, "node_modules/@payloadcms/next": { - "version": "3.51.0", - "resolved": "https://registry.npmjs.org/@payloadcms/next/-/next-3.51.0.tgz", - "integrity": "sha512-kLxozeWTp2ON2cWC4y6PbuU2WdG0REAilDRO/i7IvleWpKx5J8OJc80dka0VMsQLYrb0DgzLt3gOhQw1A+TvHw==", + "version": "3.58.0", + "resolved": "https://registry.npmjs.org/@payloadcms/next/-/next-3.58.0.tgz", + "integrity": "sha512-z6akwPHNz/O/9n1OcY9yQBBLbYqmjzzueeT9EG8rW0L8a1oaXx+5Poed1vzdhXZGtTccLtcpJwMKY06QalqL9A==", "license": "MIT", "dependencies": { "@dnd-kit/core": "6.0.8", - "@payloadcms/graphql": "3.51.0", - "@payloadcms/translations": "3.51.0", - "@payloadcms/ui": "3.51.0", + "@payloadcms/graphql": "3.58.0", + "@payloadcms/translations": "3.58.0", + "@payloadcms/ui": "3.58.0", "busboy": "^1.6.0", "dequal": "2.0.3", "file-type": "19.3.0", @@ -4312,7 +3624,7 @@ "peerDependencies": { "graphql": "^16.8.1", "next": "^15.2.3", - "payload": "3.51.0" + "payload": "3.58.0" } }, "node_modules/@payloadcms/next/node_modules/uuid": { @@ -4329,23 +3641,23 @@ } }, "node_modules/@payloadcms/richtext-lexical": { - "version": "3.51.0", - "resolved": "https://registry.npmjs.org/@payloadcms/richtext-lexical/-/richtext-lexical-3.51.0.tgz", - "integrity": "sha512-EJxlJqRBW3RumBtIyGtinBjh/EfuXMKrAEWfTcmyvq47fBZ0NWPsEUsKkmRrCfmJhc/yA+jSrnHHMWF+Wde4GA==", - "license": "MIT", - "dependencies": { - "@lexical/headless": "0.28.0", - "@lexical/html": "0.28.0", - "@lexical/link": "0.28.0", - "@lexical/list": "0.28.0", - "@lexical/mark": "0.28.0", - "@lexical/react": "0.28.0", - "@lexical/rich-text": "0.28.0", - "@lexical/selection": "0.28.0", - "@lexical/table": "0.28.0", - "@lexical/utils": "0.28.0", - "@payloadcms/translations": "3.51.0", - "@payloadcms/ui": "3.51.0", + "version": "3.58.0", + "resolved": "https://registry.npmjs.org/@payloadcms/richtext-lexical/-/richtext-lexical-3.58.0.tgz", + "integrity": "sha512-VX+l3rgnpXZuoHgqePlHYaoeQD1efGl/B9SuP9bXM30Iq3kl0Q+8rtCQB40pgO3kZme+gK5wtZmmRwQ7ro4+sQ==", + "license": "MIT", + "dependencies": { + "@lexical/headless": "0.35.0", + "@lexical/html": "0.35.0", + "@lexical/link": "0.35.0", + "@lexical/list": "0.35.0", + "@lexical/mark": "0.35.0", + "@lexical/react": "0.35.0", + "@lexical/rich-text": "0.35.0", + "@lexical/selection": "0.35.0", + "@lexical/table": "0.35.0", + "@lexical/utils": "0.35.0", + "@payloadcms/translations": "3.58.0", + "@payloadcms/ui": "3.58.0", "@types/uuid": "10.0.0", "acorn": "8.12.1", "bson-objectid": "2.0.4", @@ -4353,7 +3665,7 @@ "dequal": "2.0.3", "escape-html": "1.0.3", "jsox": "1.2.121", - "lexical": "0.28.0", + "lexical": "0.35.0", "mdast-util-from-markdown": "2.0.2", "mdast-util-mdx-jsx": "3.1.3", "micromark-extension-mdx-jsx": "3.0.1", @@ -4366,10 +3678,10 @@ "node": "^18.20.2 || >=20.9.0" }, "peerDependencies": { - "@faceless-ui/modal": "3.0.0-beta.2", + "@faceless-ui/modal": "3.0.0", "@faceless-ui/scroll-info": "2.0.0", - "@payloadcms/next": "3.51.0", - "payload": "3.51.0", + "@payloadcms/next": "3.58.0", + "payload": "3.58.0", "react": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020", "react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020" } @@ -4388,29 +3700,29 @@ } }, "node_modules/@payloadcms/translations": { - "version": "3.51.0", - "resolved": "https://registry.npmjs.org/@payloadcms/translations/-/translations-3.51.0.tgz", - "integrity": "sha512-2spku7152o4xLOlDJLVauclmvUT9wrzdTnYTKN2tZTkAChdMJPNVczeuXBazBwVR95mx3p0HFtN37GiEEcfhlQ==", + "version": "3.58.0", + "resolved": "https://registry.npmjs.org/@payloadcms/translations/-/translations-3.58.0.tgz", + "integrity": "sha512-e665uK7C5qOgSWfW4FLOKf60hOyCEYwvwgkp9YeRjwwFOQjzTNsNCNz6zjDiSmlg+V8Ufxlww4fqH2o6TkjWvQ==", "license": "MIT", "dependencies": { "date-fns": "4.1.0" } }, "node_modules/@payloadcms/ui": { - "version": "3.51.0", - "resolved": "https://registry.npmjs.org/@payloadcms/ui/-/ui-3.51.0.tgz", - "integrity": "sha512-DF+0VnFagPVQenufoC871zkMuoqpDDUi1PcVrIchaQIr5EnlHIw9tCzJw3Fyi8stjZiAFEdj1RKqVI3iAr/hNQ==", + "version": "3.58.0", + "resolved": "https://registry.npmjs.org/@payloadcms/ui/-/ui-3.58.0.tgz", + "integrity": "sha512-DbDVBKkVkCPZsOSaN0GnFnHIemXdAz9E38Wz2ZbCDLIpRza8cNkzLdCUcjWMHY1Nkz/gvXceH3hVvod2bvJCJA==", "license": "MIT", "dependencies": { "@date-fns/tz": "1.2.0", "@dnd-kit/core": "6.0.8", "@dnd-kit/sortable": "7.0.2", "@dnd-kit/utilities": "3.2.2", - "@faceless-ui/modal": "3.0.0-beta.2", + "@faceless-ui/modal": "3.0.0", "@faceless-ui/scroll-info": "2.0.0", "@faceless-ui/window-info": "3.0.1", "@monaco-editor/react": "4.7.0", - "@payloadcms/translations": "3.51.0", + "@payloadcms/translations": "3.58.0", "bson-objectid": "2.0.4", "date-fns": "4.1.0", "dequal": "2.0.3", @@ -4431,7 +3743,7 @@ }, "peerDependencies": { "next": "^15.2.3", - "payload": "3.51.0", + "payload": "3.58.0", "react": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020", "react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020" } @@ -4569,6 +3881,18 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/@pm2/blessed": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@pm2/blessed/-/blessed-0.1.81.tgz", + "integrity": "sha512-ZcNHqQjMuNRcQ7Z1zJbFIQZO/BDKV3KbiTckWdfbUaYhj7uNmUwb+FbdDWSCkvxNr9dBJQwvV17o6QBkAvgO0g==", + "license": "MIT", + "bin": { + "blessed": "bin/tput.js" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/@pm2/io": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@pm2/io/-/io-6.1.0.tgz", @@ -4763,38 +4087,6 @@ "@opentelemetry/api": "^1.8" } }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -6009,9 +5301,9 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", - "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -6031,9 +5323,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz", - "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", + "integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==", "cpu": [ "arm" ], @@ -6044,9 +5336,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz", - "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz", + "integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==", "cpu": [ "arm64" ], @@ -6057,9 +5349,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz", - "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz", + "integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==", "cpu": [ "arm64" ], @@ -6070,9 +5362,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz", - "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz", + "integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==", "cpu": [ "x64" ], @@ -6083,9 +5375,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz", - "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz", + "integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==", "cpu": [ "arm64" ], @@ -6096,9 +5388,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz", - "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz", + "integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==", "cpu": [ "x64" ], @@ -6109,9 +5401,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz", - "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz", + "integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==", "cpu": [ "arm" ], @@ -6122,9 +5414,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz", - "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz", + "integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==", "cpu": [ "arm" ], @@ -6135,9 +5427,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz", - "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz", + "integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==", "cpu": [ "arm64" ], @@ -6148,9 +5440,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz", - "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz", + "integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==", "cpu": [ "arm64" ], @@ -6160,10 +5452,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz", - "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz", + "integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==", "cpu": [ "loong64" ], @@ -6174,9 +5466,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz", - "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz", + "integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==", "cpu": [ "ppc64" ], @@ -6187,9 +5479,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz", - "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz", + "integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==", "cpu": [ "riscv64" ], @@ -6200,9 +5492,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz", - "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz", + "integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==", "cpu": [ "riscv64" ], @@ -6213,9 +5505,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz", - "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz", + "integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==", "cpu": [ "s390x" ], @@ -6226,9 +5518,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz", - "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz", + "integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==", "cpu": [ "x64" ], @@ -6239,9 +5531,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz", - "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz", + "integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==", "cpu": [ "x64" ], @@ -6251,10 +5543,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz", + "integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz", - "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz", + "integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==", "cpu": [ "arm64" ], @@ -6265,9 +5570,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz", - "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz", + "integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==", "cpu": [ "ia32" ], @@ -6277,10 +5582,23 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz", + "integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz", - "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz", + "integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==", "cpu": [ "x64" ], @@ -6411,9 +5729,9 @@ } }, "node_modules/@sentry/cli": { - "version": "2.52.0", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.52.0.tgz", - "integrity": "sha512-PXyo7Yv7+rVMSBGZfI/eFEzzhiKedTs25sDCjz4a3goAZ/F5R5tn3MKq30pnze5wNnoQmLujAa0uUjfNcWP+uQ==", + "version": "2.56.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.56.0.tgz", + "integrity": "sha512-br6+1nTPUV5EG1oaxLzxv31kREFKr49Y1+3jutfMUz9Nl8VyVP7o9YwakB/YWl+0Vi0NXg5vq7qsd/OOuV5j8w==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -6430,20 +5748,20 @@ "node": ">= 10" }, "optionalDependencies": { - "@sentry/cli-darwin": "2.52.0", - "@sentry/cli-linux-arm": "2.52.0", - "@sentry/cli-linux-arm64": "2.52.0", - "@sentry/cli-linux-i686": "2.52.0", - "@sentry/cli-linux-x64": "2.52.0", - "@sentry/cli-win32-arm64": "2.52.0", - "@sentry/cli-win32-i686": "2.52.0", - "@sentry/cli-win32-x64": "2.52.0" + "@sentry/cli-darwin": "2.56.0", + "@sentry/cli-linux-arm": "2.56.0", + "@sentry/cli-linux-arm64": "2.56.0", + "@sentry/cli-linux-i686": "2.56.0", + "@sentry/cli-linux-x64": "2.56.0", + "@sentry/cli-win32-arm64": "2.56.0", + "@sentry/cli-win32-i686": "2.56.0", + "@sentry/cli-win32-x64": "2.56.0" } }, "node_modules/@sentry/cli-darwin": { - "version": "2.52.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.52.0.tgz", - "integrity": "sha512-ieQs/p4yTHT27nBzy0wtAb8BSISfWlpXdgsACcwXimYa36NJRwyCqgOXUaH/BYiTdwWSHpuANbUHGJW6zljzxw==", + "version": "2.56.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.56.0.tgz", + "integrity": "sha512-CzXFWbv3GrjU0gFlUM9jt0fvJmyo5ktty4HGxRFfS/eMC6xW58Gg/sEeMVEkdvk5osKooX/YEgfLBdo4zvuWDA==", "license": "BSD-3-Clause", "optional": true, "os": [ @@ -6454,9 +5772,9 @@ } }, "node_modules/@sentry/cli-linux-arm": { - "version": "2.52.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.52.0.tgz", - "integrity": "sha512-tWMLU+hj+iip5Akx+S76biAOE1eMMWTDq8c0MqMv/ahHgb6/HiVngMcUsp59Oz3EczJGbTkcnS3vRTDodEcMDw==", + "version": "2.56.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.56.0.tgz", + "integrity": "sha512-vQCCMhZLugPmr25XBoP94dpQsFa110qK5SBUVJcRpJKyzMZd+6ueeHNslq2mB0OF4BwL1qd/ZDIa4nxa1+0rjQ==", "cpu": [ "arm" ], @@ -6472,9 +5790,9 @@ } }, "node_modules/@sentry/cli-linux-arm64": { - "version": "2.52.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.52.0.tgz", - "integrity": "sha512-RxT5uzxjCkcvplmx0bavJIEYerRex2Rg/2RAVBdVvWLKFOcmeerTn/VVxPZVuDIVMVyjlZsteWPYwfUm+Ia3wQ==", + "version": "2.56.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.56.0.tgz", + "integrity": "sha512-91d5ZlC989j/t+TXor/glPyx6SnLFS/SlJ9fIrHIQohdGKyWWSFb4VKUan8Ok3GYu9SUzKTMByryIOoYEmeGVw==", "cpu": [ "arm64" ], @@ -6490,9 +5808,9 @@ } }, "node_modules/@sentry/cli-linux-i686": { - "version": "2.52.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.52.0.tgz", - "integrity": "sha512-sKcJmIg7QWFtlNU5Bs5OZprwdIzzyYMRpFkWioPZ4TE82yvP1+2SAX31VPUlTx+7NLU6YVEWNwvSxh8LWb7iOw==", + "version": "2.56.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.56.0.tgz", + "integrity": "sha512-MZzXuq1Q/TktN81DUs6XSBU752pG3XWSJdZR+NCStIg3l8s3O/Pwh6OcDHTYqgwsYJaGBpA0fP2Afl5XeSAUNg==", "cpu": [ "x86", "ia32" @@ -6509,9 +5827,9 @@ } }, "node_modules/@sentry/cli-linux-x64": { - "version": "2.52.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.52.0.tgz", - "integrity": "sha512-aPZ7bP02zGkuEqTiOAm4np/ggfgtzrq4ti1Xze96Csi/DV3820SCfLrPlsvcvnqq7x69IL9cI3kXjdEpgrfGxw==", + "version": "2.56.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.56.0.tgz", + "integrity": "sha512-INOO2OQ90Y3UzYgHRdrHdKC/0es3YSHLv0iNNgQwllL0YZihSVNYSSrZqcPq8oSDllEy9Vt9oOm/7qEnUP2Kfw==", "cpu": [ "x64" ], @@ -6527,9 +5845,9 @@ } }, "node_modules/@sentry/cli-win32-arm64": { - "version": "2.52.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.52.0.tgz", - "integrity": "sha512-90hrB5XdwJVhRpCmVrEcYoKW8nl5/V9OfVvOGeKUPvUkApLzvsInK74FYBZEVyAn1i/NdUv+Xk9q2zqUGK1aLQ==", + "version": "2.56.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.56.0.tgz", + "integrity": "sha512-eUvkVk9KK01q6/qyugQPh7dAxqFPbgOa62QAoSwo11WQFYc3NPgJLilFWLQo+nahHGYKh6PKuCJ5tcqnQq5Hkg==", "cpu": [ "arm64" ], @@ -6543,9 +5861,9 @@ } }, "node_modules/@sentry/cli-win32-i686": { - "version": "2.52.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.52.0.tgz", - "integrity": "sha512-HXlSE4CaLylNrELx4KVmOQjV5bURCNuky6sjCWiTH7HyDqHEak2Rk8iLE0JNLj5RETWMvmaZnZZFfmyGlY1opg==", + "version": "2.56.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.56.0.tgz", + "integrity": "sha512-mpCA8hKXuvT17bl1H/54KOa5i+02VBBHVlOiP3ltyBuQUqfvX/30Zl/86Spy+ikodovZWAHv5e5FpyXbY1/mPw==", "cpu": [ "x86", "ia32" @@ -6560,9 +5878,9 @@ } }, "node_modules/@sentry/cli-win32-x64": { - "version": "2.52.0", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.52.0.tgz", - "integrity": "sha512-hJT0C3FwHk1Mt9oFqcci88wbO1D+yAWUL8J29HEGM5ZAqlhdh7sAtPDIC3P2LceUJOjnXihow47Bkj62juatIQ==", + "version": "2.56.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.56.0.tgz", + "integrity": "sha512-UV0pXNls+/ViAU/3XsHLLNEHCsRYaGEwJdY3HyGIufSlglxrX6BVApkV9ziGi4WAxcJWLjQdfcEs6V5B+wBy0A==", "cpu": [ "x64" ], @@ -6723,18 +6041,6 @@ "@opentelemetry/semantic-conventions": "^1.34.0" } }, - "node_modules/@sentry/node/node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@sentry/node/node_modules/@opentelemetry/context-async-hooks": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", @@ -6771,26 +6077,6 @@ "node": ">=14" } }, - "node_modules/@sentry/node/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@sentry/node/node_modules/@opentelemetry/resources": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", @@ -6984,6 +6270,18 @@ "webpack": ">=4.40.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -6994,9 +6292,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.85.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.2.tgz", - "integrity": "sha512-h9vFUVYKoNNgXzb5mK4dP686yawJp9CLLTMjB2PF4KA1D5AbUI2KfxnxBdEQKnLwzzMjmBpucmmsGChZK7RqcQ==", + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz", + "integrity": "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==", "license": "MIT", "funding": { "type": "github", @@ -7004,12 +6302,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.85.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.2.tgz", - "integrity": "sha512-B/sLNHkhGi3hVZg3xsH82sCSkEqdyfZXBrkumo7FeIKuXgsCRp4tmLSCC/2YU/oPyQuoVGzQhyzM8p3UjO19kw==", + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.2.tgz", + "integrity": "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.85.2" + "@tanstack/query-core": "5.90.2" }, "funding": { "type": "github", @@ -7065,9 +6363,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", - "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", "optional": true, @@ -7121,12 +6419,6 @@ "@types/ms": "*" } }, - "node_modules/@types/diff-match-patch": { - "version": "1.0.36", - "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", - "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", - "license": "MIT" - }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -7248,9 +6540,9 @@ } }, "node_modules/@types/node": { - "version": "20.19.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.10.tgz", - "integrity": "sha512-iAFpG6DokED3roLSP0K+ybeDdIX6Bc0Vd3mLW5uDqThPWtNos3E+EqOM11mPQHKzfWHqEBuLjIlsBQQ8CsISmQ==", + "version": "20.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz", + "integrity": "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -7306,22 +6598,22 @@ "optional": true }, "node_modules/@types/react": { - "version": "19.1.10", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", - "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz", + "integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==", "license": "MIT", "dependencies": { "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "19.1.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz", - "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg==", "devOptional": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/react-transition-group": { @@ -7400,17 +6692,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.1.tgz", - "integrity": "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", + "integrity": "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.39.1", - "@typescript-eslint/type-utils": "8.39.1", - "@typescript-eslint/utils": "8.39.1", - "@typescript-eslint/visitor-keys": "8.39.1", + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/type-utils": "8.45.0", + "@typescript-eslint/utils": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -7424,7 +6716,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.39.1", + "@typescript-eslint/parser": "^8.45.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -7440,16 +6732,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.1.tgz", - "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.45.0.tgz", + "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.39.1", - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/typescript-estree": "8.39.1", - "@typescript-eslint/visitor-keys": "8.39.1", + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4" }, "engines": { @@ -7465,14 +6757,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz", - "integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.45.0.tgz", + "integrity": "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.39.1", - "@typescript-eslint/types": "^8.39.1", + "@typescript-eslint/tsconfig-utils": "^8.45.0", + "@typescript-eslint/types": "^8.45.0", "debug": "^4.3.4" }, "engines": { @@ -7487,14 +6779,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz", - "integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz", + "integrity": "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/visitor-keys": "8.39.1" + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7505,9 +6797,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz", - "integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz", + "integrity": "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==", "dev": true, "license": "MIT", "engines": { @@ -7522,15 +6814,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.1.tgz", - "integrity": "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz", + "integrity": "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/typescript-estree": "8.39.1", - "@typescript-eslint/utils": "8.39.1", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/utils": "8.45.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -7547,9 +6839,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz", - "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.45.0.tgz", + "integrity": "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==", "dev": true, "license": "MIT", "engines": { @@ -7561,16 +6853,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz", - "integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz", + "integrity": "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.39.1", - "@typescript-eslint/tsconfig-utils": "8.39.1", - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/visitor-keys": "8.39.1", + "@typescript-eslint/project-service": "8.45.0", + "@typescript-eslint/tsconfig-utils": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -7646,16 +6938,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.1.tgz", - "integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.45.0.tgz", + "integrity": "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.39.1", - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/typescript-estree": "8.39.1" + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7670,13 +6962,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz", - "integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz", + "integrity": "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/types": "8.45.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -7962,6 +7254,15 @@ "win32" ] }, + "node_modules/@vercel/oidc": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.1.tgz", + "integrity": "sha512-V/YRVrJDqM6VaMBjRUrd6qRMrTKvZjHdVdEmdXsOZMulTa3iK98ijKTc3wldBmst6W5rHpqMoKllKcBAHgN7GQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -8176,29 +7477,21 @@ } }, "node_modules/ai": { - "version": "4.3.19", - "resolved": "https://registry.npmjs.org/ai/-/ai-4.3.19.tgz", - "integrity": "sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q==", + "version": "5.0.60", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.60.tgz", + "integrity": "sha512-80U/3kmdBW6g+JkLXpz/P2EwkyEaWlPlYtuLUpx/JYK9F7WZh9NnkYoh1KvUi1Sbpo0NyurBTvX0a2AG9mmbDA==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "1.1.3", - "@ai-sdk/provider-utils": "2.2.8", - "@ai-sdk/react": "1.2.12", - "@ai-sdk/ui-utils": "1.2.11", - "@opentelemetry/api": "1.9.0", - "jsondiffpatch": "0.6.0" + "@ai-sdk/gateway": "1.0.33", + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.10", + "@opentelemetry/api": "1.9.0" }, "engines": { "node": ">=18" }, "peerDependencies": { - "react": "^18 || ^19 || ^19.0.0-rc", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } + "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/ajv": { @@ -8282,9 +7575,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -8721,10 +8014,18 @@ } }, "node_modules/b4a": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", - "license": "Apache-2.0" + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } }, "node_modules/babel-plugin-macros": { "version": "3.1.0", @@ -8758,11 +8059,10 @@ "license": "MIT" }, "node_modules/bare-events": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.1.tgz", - "integrity": "sha512-AuTJkq9XmE6Vk0FJVNq5QxETrSA/vKHarWVBG5l/JbdCL1prJemiyJqUS0jrlXO0MftuPq4m3YVYhoNc5+aE/g==", - "license": "Apache-2.0", - "optional": true + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.7.0.tgz", + "integrity": "sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==", + "license": "Apache-2.0" }, "node_modules/base64-arraybuffer": { "version": "1.0.2", @@ -8794,6 +8094,15 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz", + "integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/basic-ftp": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", @@ -8815,18 +8124,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/blessed": { - "version": "0.1.81", - "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", - "integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==", - "license": "MIT", - "bin": { - "blessed": "bin/tput.js" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/bmp-js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", @@ -8869,9 +8166,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz", - "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==", + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", "funding": [ { "type": "opencollective", @@ -8888,9 +8185,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001733", - "electron-to-chromium": "^1.5.199", - "node-releases": "^2.0.19", + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { @@ -9034,9 +8332,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001735", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz", - "integrity": "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==", + "version": "1.0.30001746", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz", + "integrity": "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==", "funding": [ { "type": "opencollective", @@ -9383,9 +8681,9 @@ "license": "MIT" }, "node_modules/core-js": { - "version": "3.45.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.0.tgz", - "integrity": "sha512-c2KZL9lP4DjkN3hk/an4pWn5b5ZefhRJnAc42n6LJ19kSnbeRbdQZE5dSeE2LBol1OwJD3X1BQvFTAsa8ReeDA==", + "version": "3.45.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz", + "integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -9673,15 +8971,15 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.15", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.15.tgz", + "integrity": "sha512-MC+DfnSWiM9APs7fpiurHGCoeIx0Gdl6QZBy+5lu8MbYKN5FZEXqOgrundfibdfhGZ15o9hzmZ2xJjZnbvgKXQ==", "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -9823,12 +9121,6 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0" }, - "node_modules/diff-match-patch": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", - "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", - "license": "Apache-2.0" - }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -9866,36 +9158,18 @@ } }, "node_modules/docx/node_modules/@types/node": { - "version": "24.2.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", - "integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==", + "version": "24.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.2.tgz", + "integrity": "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==", "license": "MIT", "dependencies": { - "undici-types": "~7.10.0" - } - }, - "node_modules/docx/node_modules/nanoid": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", - "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" + "undici-types": "~7.13.0" } }, "node_modules/docx/node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", + "integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", "license": "MIT" }, "node_modules/dom": { @@ -9915,9 +9189,9 @@ } }, "node_modules/dompurify": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", - "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", "optional": true, "optionalDependencies": { @@ -9944,7 +9218,7 @@ "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.25.9", + "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { @@ -10098,9 +9372,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.200", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz", - "integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==", + "version": "1.5.229", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.229.tgz", + "integrity": "sha512-cwhDcZKGcT/rEthLRJ9eBlMDkh1sorgsuk+6dpsehV0g9CABsIqBxU4rLRjG+d/U6pYU1s37A4lSKrVc5lSQYg==", "license": "ISC" }, "node_modules/embla-carousel": { @@ -10184,9 +9458,9 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -10376,9 +9650,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", - "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -10388,32 +9662,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.9", - "@esbuild/android-arm": "0.25.9", - "@esbuild/android-arm64": "0.25.9", - "@esbuild/android-x64": "0.25.9", - "@esbuild/darwin-arm64": "0.25.9", - "@esbuild/darwin-x64": "0.25.9", - "@esbuild/freebsd-arm64": "0.25.9", - "@esbuild/freebsd-x64": "0.25.9", - "@esbuild/linux-arm": "0.25.9", - "@esbuild/linux-arm64": "0.25.9", - "@esbuild/linux-ia32": "0.25.9", - "@esbuild/linux-loong64": "0.25.9", - "@esbuild/linux-mips64el": "0.25.9", - "@esbuild/linux-ppc64": "0.25.9", - "@esbuild/linux-riscv64": "0.25.9", - "@esbuild/linux-s390x": "0.25.9", - "@esbuild/linux-x64": "0.25.9", - "@esbuild/netbsd-arm64": "0.25.9", - "@esbuild/netbsd-x64": "0.25.9", - "@esbuild/openbsd-arm64": "0.25.9", - "@esbuild/openbsd-x64": "0.25.9", - "@esbuild/openharmony-arm64": "0.25.9", - "@esbuild/sunos-x64": "0.25.9", - "@esbuild/win32-arm64": "0.25.9", - "@esbuild/win32-ia32": "0.25.9", - "@esbuild/win32-x64": "0.25.9" + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" } }, "node_modules/esbuild-register": { @@ -10477,19 +9751,19 @@ } }, "node_modules/eslint": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", - "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", + "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.33.0", + "@eslint/js": "9.36.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -11043,6 +10317,24 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -11144,21 +10436,6 @@ "pako": "^2.1.0" } }, - "node_modules/fast-png/node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)" - }, - "node_modules/fast-redact": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", - "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -11166,9 +10443,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "funding": [ { "type": "github", @@ -11210,10 +10487,13 @@ "license": "MIT" }, "node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -11449,12 +10729,12 @@ "license": "MIT" }, "node_modules/framer-motion": { - "version": "12.23.12", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz", - "integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==", + "version": "12.23.22", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz", + "integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==", "license": "MIT", "dependencies": { - "motion-dom": "^12.23.12", + "motion-dom": "^12.23.21", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, @@ -11476,9 +10756,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", - "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -11550,14 +10830,24 @@ } }, "node_modules/geist": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/geist/-/geist-1.4.2.tgz", - "integrity": "sha512-OQUga/KUc8ueijck6EbtT07L4tZ5+TZgjw8PyWfxo16sL5FWk7gNViPNU8hgCFjy6bJi9yuTP+CRpywzaGN8zw==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/geist/-/geist-1.5.1.tgz", + "integrity": "sha512-mAHZxIsL2o3ZITFaBVFBnwyDOw+zNLYum6A6nIjpzCGIO8QtC3V76XF2RnZTyLx1wlDTmMDy8jg3Ib52MIjGvQ==", "license": "SIL OPEN FONT LICENSE", "peerDependencies": { "next": ">=13.2.0" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -12260,9 +11550,9 @@ } }, "node_modules/import-in-the-middle": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.14.2.tgz", - "integrity": "sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw==", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.14.4.tgz", + "integrity": "sha512-eWjxh735SJLFJJDs5X82JQ2405OdJeAHDBnaoFCfdr5GVc7AWc9xU7KbrF+3Xd5F2ccP1aQFKtY+65X6EfKZ7A==", "license": "Apache-2.0", "dependencies": { "acorn": "^8.14.0", @@ -12587,14 +11877,15 @@ } }, "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -13172,35 +12463,6 @@ "node": ">=6" } }, - "node_modules/jsondiffpatch": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz", - "integrity": "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==", - "license": "MIT", - "dependencies": { - "@types/diff-match-patch": "^1.0.36", - "chalk": "^5.3.0", - "diff-match-patch": "^1.0.5" - }, - "bin": { - "jsondiffpatch": "bin/jsondiffpatch.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/jsondiffpatch/node_modules/chalk": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", - "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -13213,6 +12475,15 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonrepair": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.1.tgz", + "integrity": "sha512-WJeiE0jGfxYmtLwBTEk8+y/mYcaleyLXWaqp5bJu0/ZTSeG0KQq/wWQ8pmnkKenEdN6pdnn6QtcoSUkbqDHWNw==", + "license": "ISC", + "bin": { + "jsonrepair": "bin/cli.js" + } + }, "node_modules/jsox": { "version": "1.2.121", "resolved": "https://registry.npmjs.org/jsox/-/jsox-1.2.121.tgz", @@ -13223,9 +12494,9 @@ } }, "node_modules/jspdf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.2.tgz", - "integrity": "sha512-G0fQDJ5fAm6UW78HG6lNXyq09l0PrA1rpNY5i+ly17Zb1fMMFSmS+3lw4cnrAPGyouv2Y0ylujbY2Ieq3DSlKA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz", + "integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.9", @@ -13282,6 +12553,12 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/jszip/node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -13414,9 +12691,9 @@ } }, "node_modules/lexical": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.28.0.tgz", - "integrity": "sha512-dLE3O1PZg0TlZxRQo9YDpjCjDUj8zluGyBO9MHdjo21qZmMUNrxQPeCRt8fn2s5l4HKYFQ1YNgl7k1pOJB/vZQ==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.35.0.tgz", + "integrity": "sha512-3VuV8xXhh5xJA6tzvfDvE0YBCMkIZUmxtRilJQDDdCgJCc+eut6qAv2qbN+pbqvarqcQqPN1UF+8YvsjmyOZpw==", "license": "MIT" }, "node_modules/lib0": { @@ -13589,12 +12866,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/markdown-table": { @@ -14685,16 +13962,26 @@ "license": "MIT" }, "node_modules/monaco-editor": { - "version": "0.52.2", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", - "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.53.0.tgz", + "integrity": "sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/trusted-types": "^1.0.6" + } + }, + "node_modules/monaco-editor/node_modules/@types/trusted-types": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-1.0.6.tgz", + "integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==", "license": "MIT", "peer": true }, "node_modules/motion-dom": { - "version": "12.23.12", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz", - "integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==", + "version": "12.23.21", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.21.tgz", + "integrity": "sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==", "license": "MIT", "dependencies": { "motion-utils": "^12.23.6" @@ -14730,9 +14017,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", "funding": [ { "type": "github", @@ -14741,10 +14028,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/napi-postinstall": { @@ -14812,12 +14099,12 @@ } }, "node_modules/next": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.2.tgz", - "integrity": "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.4.tgz", + "integrity": "sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==", "license": "MIT", "dependencies": { - "@next/env": "15.5.2", + "@next/env": "15.5.4", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -14830,14 +14117,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.2", - "@next/swc-darwin-x64": "15.5.2", - "@next/swc-linux-arm64-gnu": "15.5.2", - "@next/swc-linux-arm64-musl": "15.5.2", - "@next/swc-linux-x64-gnu": "15.5.2", - "@next/swc-linux-x64-musl": "15.5.2", - "@next/swc-win32-arm64-msvc": "15.5.2", - "@next/swc-win32-x64-msvc": "15.5.2", + "@next/swc-darwin-arm64": "15.5.4", + "@next/swc-darwin-x64": "15.5.4", + "@next/swc-linux-arm64-gnu": "15.5.4", + "@next/swc-linux-arm64-musl": "15.5.4", + "@next/swc-linux-x64-gnu": "15.5.4", + "@next/swc-linux-x64-musl": "15.5.4", + "@next/swc-win32-arm64-msvc": "15.5.4", + "@next/swc-win32-x64-msvc": "15.5.4", "sharp": "^0.34.3" }, "peerDependencies": { @@ -14874,9 +14161,9 @@ } }, "node_modules/next/node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", - "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", "cpu": [ "arm64" ], @@ -14892,13 +14179,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.0" + "@img/sharp-libvips-darwin-arm64": "1.2.3" } }, "node_modules/next/node_modules/@img/sharp-darwin-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", - "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", "cpu": [ "x64" ], @@ -14914,13 +14201,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.0" + "@img/sharp-libvips-darwin-x64": "1.2.3" } }, "node_modules/next/node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", - "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", "cpu": [ "arm64" ], @@ -14934,9 +14221,9 @@ } }, "node_modules/next/node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", - "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", "cpu": [ "x64" ], @@ -14950,9 +14237,9 @@ } }, "node_modules/next/node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", - "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", "cpu": [ "arm" ], @@ -14966,9 +14253,9 @@ } }, "node_modules/next/node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", - "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", "cpu": [ "arm64" ], @@ -14982,9 +14269,9 @@ } }, "node_modules/next/node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", - "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", "cpu": [ "s390x" ], @@ -14998,9 +14285,9 @@ } }, "node_modules/next/node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", - "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", "cpu": [ "x64" ], @@ -15014,9 +14301,9 @@ } }, "node_modules/next/node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", - "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", "cpu": [ "arm64" ], @@ -15030,9 +14317,9 @@ } }, "node_modules/next/node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", - "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", "cpu": [ "x64" ], @@ -15046,9 +14333,9 @@ } }, "node_modules/next/node_modules/@img/sharp-linux-arm": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", - "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", "cpu": [ "arm" ], @@ -15064,13 +14351,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.0" + "@img/sharp-libvips-linux-arm": "1.2.3" } }, "node_modules/next/node_modules/@img/sharp-linux-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", - "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", "cpu": [ "arm64" ], @@ -15086,13 +14373,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.0" + "@img/sharp-libvips-linux-arm64": "1.2.3" } }, "node_modules/next/node_modules/@img/sharp-linux-s390x": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", - "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", "cpu": [ "s390x" ], @@ -15108,13 +14395,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.0" + "@img/sharp-libvips-linux-s390x": "1.2.3" } }, "node_modules/next/node_modules/@img/sharp-linux-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", - "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", "cpu": [ "x64" ], @@ -15130,13 +14417,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.0" + "@img/sharp-libvips-linux-x64": "1.2.3" } }, "node_modules/next/node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", - "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", "cpu": [ "arm64" ], @@ -15152,13 +14439,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" } }, "node_modules/next/node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", - "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", "cpu": [ "x64" ], @@ -15174,20 +14461,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" } }, "node_modules/next/node_modules/@img/sharp-wasm32": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", - "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.4.4" + "@emnapi/runtime": "^1.5.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -15197,9 +14484,9 @@ } }, "node_modules/next/node_modules/@img/sharp-win32-ia32": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", - "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", "cpu": [ "ia32" ], @@ -15216,9 +14503,9 @@ } }, "node_modules/next/node_modules/@img/sharp-win32-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", - "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", "cpu": [ "x64" ], @@ -15235,15 +14522,33 @@ } }, "node_modules/next/node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", + "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", "license": "Apache-2.0", "optional": true, "engines": { "node": ">=8" } }, + "node_modules/next/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -15273,15 +14578,15 @@ } }, "node_modules/next/node_modules/sharp": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", - "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", "hasInstallScript": true, "license": "Apache-2.0", "optional": true, "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.4", + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", "semver": "^7.7.2" }, "engines": { @@ -15291,28 +14596,28 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.3", - "@img/sharp-darwin-x64": "0.34.3", - "@img/sharp-libvips-darwin-arm64": "1.2.0", - "@img/sharp-libvips-darwin-x64": "1.2.0", - "@img/sharp-libvips-linux-arm": "1.2.0", - "@img/sharp-libvips-linux-arm64": "1.2.0", - "@img/sharp-libvips-linux-ppc64": "1.2.0", - "@img/sharp-libvips-linux-s390x": "1.2.0", - "@img/sharp-libvips-linux-x64": "1.2.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", - "@img/sharp-libvips-linuxmusl-x64": "1.2.0", - "@img/sharp-linux-arm": "0.34.3", - "@img/sharp-linux-arm64": "0.34.3", - "@img/sharp-linux-ppc64": "0.34.3", - "@img/sharp-linux-s390x": "0.34.3", - "@img/sharp-linux-x64": "0.34.3", - "@img/sharp-linuxmusl-arm64": "0.34.3", - "@img/sharp-linuxmusl-x64": "0.34.3", - "@img/sharp-wasm32": "0.34.3", - "@img/sharp-win32-arm64": "0.34.3", - "@img/sharp-win32-ia32": "0.34.3", - "@img/sharp-win32-x64": "0.34.3" + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" } }, "node_modules/node-domexception": { @@ -15360,9 +14665,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", "license": "MIT" }, "node_modules/normalize-path": { @@ -15375,9 +14680,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.21", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz", - "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==", + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", + "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", "license": "MIT" }, "node_modules/object-assign": { @@ -15517,26 +14822,20 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ollama-ai-provider": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/ollama-ai-provider/-/ollama-ai-provider-1.2.0.tgz", - "integrity": "sha512-jTNFruwe3O/ruJeppI/quoOUxG7NA6blG3ZyQj3lei4+NnJo7bi3eIRWqlVpRlu/mbzbFXeJSBuYQWF6pzGKww==", + "node_modules/ollama-ai-provider-v2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/ollama-ai-provider-v2/-/ollama-ai-provider-v2-1.4.1.tgz", + "integrity": "sha512-coo3+IvnL6O4Ku0TekUiQCEYYvLRbfLfPBcx0e30fxrfKTArEff0oinTKGZ0RRGFxvqd0LgFa6f3kxppA71f3A==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "^1.0.0", - "@ai-sdk/provider-utils": "^2.0.0", - "partial-json": "0.1.7" + "@ai-sdk/provider": "^2.0.0", + "@ai-sdk/provider-utils": "^3.0.7" }, "engines": { "node": ">=18" }, "peerDependencies": { - "zod": "^3.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } + "zod": "^4.0.16" } }, "node_modules/on-exit-leak-free": { @@ -15693,9 +14992,9 @@ "license": "BlueOak-1.0.0" }, "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, "node_modules/parent-module": { @@ -15765,12 +15064,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/partial-json": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", - "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "license": "MIT" - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -15842,13 +15135,13 @@ } }, "node_modules/payload": { - "version": "3.51.0", - "resolved": "https://registry.npmjs.org/payload/-/payload-3.51.0.tgz", - "integrity": "sha512-PJzAxzH2tbvacstb7+FX188EFesAQMD1/bTxhD74Ci8/eSRbTUFryuUBf9SBNO1qMWp14qg5slnlVUU1ztZy/w==", + "version": "3.58.0", + "resolved": "https://registry.npmjs.org/payload/-/payload-3.58.0.tgz", + "integrity": "sha512-r0vuhPkpIA72zEgnJeVPbxFjI7p47tCrfFXkyJTo4IlOPGhCE1o/cJHC3rn6id8OCJvK35oBVkkTJ3wPMVIWCQ==", "license": "MIT", "dependencies": { "@next/env": "^15.1.5", - "@payloadcms/translations": "3.51.0", + "@payloadcms/translations": "3.58.0", "@types/busboy": "1.5.4", "ajv": "8.17.1", "bson-objectid": "2.0.4", @@ -15929,7 +15222,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "license": "MIT", "dependencies": { - "esbuild": "~0.25.9", + "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -16068,20 +15361,20 @@ } }, "node_modules/pino": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.5.0.tgz", - "integrity": "sha512-xSEmD4pLnV54t0NOUN16yCl7RIB1c5UUOse5HSyEXtBp+FgFQyPeDutc+Q2ZO7/22vImV7VfEjH/1zV2QuqvYw==", + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.13.0.tgz", + "integrity": "sha512-SpTXQhkQXekIKEe7c887S3lk3v90Q+/HVRZVyNAhe98PQc++6I5ec/R0pciH8/CciXjCoVZIZfRNicbC6KZgnw==", "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0", - "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", - "process-warning": "^4.0.0", + "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", + "slow-redact": "^0.3.0", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, @@ -16147,37 +15440,37 @@ } }, "node_modules/pm2": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/pm2/-/pm2-6.0.10.tgz", - "integrity": "sha512-sbk4HsnhtJMx1wJlhFQhYfDRzHtVK+cvdrIezbjM9WjSyc7kLtQ4nZ5K7JLOdLe3AevytmRcTiOa3VvAQrve2A==", + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/pm2/-/pm2-6.0.13.tgz", + "integrity": "sha512-1hS/adMgKoDpX4S1ichJW8SiGpex+oBSZK31dP1FSYOOGtaeuemXzhXPOCefmddgIY4K6v7uu+7xNPnmEnK3ag==", "license": "AGPL-3.0", "dependencies": { "@pm2/agent": "~2.1.1", + "@pm2/blessed": "0.1.81", "@pm2/io": "~6.1.0", "@pm2/js-api": "~0.8.0", "@pm2/pm2-version-check": "latest", "ansis": "4.0.0-node10", - "async": "~3.2.6", - "blessed": "0.1.81", - "chokidar": "^3.5.3", - "cli-tableau": "^2.0.0", + "async": "3.2.6", + "chokidar": "3.6.0", + "cli-tableau": "2.0.1", "commander": "2.15.1", - "croner": "~4.1.92", - "dayjs": "~1.11.13", - "debug": "^4.3.7", + "croner": "4.1.97", + "dayjs": "1.11.15", + "debug": "4.4.3", "enquirer": "2.3.6", "eventemitter2": "5.0.1", "fclone": "1.0.11", - "js-yaml": "~4.1.0", + "js-yaml": "4.1.0", "mkdirp": "1.0.4", "needle": "2.4.0", - "pidusage": "~3.0", + "pidusage": "3.0.2", "pm2-axon": "~4.0.1", "pm2-axon-rpc": "~0.7.1", "pm2-deploy": "~1.0.2", "pm2-multimeter": "^0.1.2", - "promptly": "^2", - "semver": "^7.6.2", + "promptly": "2.2.0", + "semver": "7.7.2", "source-map-support": "0.5.21", "sprintf-js": "1.1.2", "vizion": "~2.2.1" @@ -16339,9 +15632,19 @@ } }, "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { "camelcase-css": "^2.0.1" @@ -16349,10 +15652,6 @@ "engines": { "node": "^12 || ^14 || >= 16" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, "peerDependencies": { "postcss": "^8.4.21" } @@ -16401,6 +15700,24 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -16590,9 +15907,9 @@ "license": "MIT" }, "node_modules/process-warning": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", - "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", "funding": [ { "type": "github", @@ -16796,9 +16113,9 @@ } }, "node_modules/react": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16848,21 +16165,21 @@ } }, "node_modules/react-dom": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", - "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", "dependencies": { - "scheduler": "^0.26.0" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.1.1" + "react": "^19.2.0" } }, "node_modules/react-dom/node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, "node_modules/react-dropzone": { @@ -16895,9 +16212,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.62.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", - "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", + "version": "7.63.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz", + "integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -17053,9 +16370,9 @@ } }, "node_modules/react-syntax-highlighter": { - "version": "15.6.1", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.1.tgz", - "integrity": "sha512-OqJ2/vL7lEeV5zTJyG7kmARppUjiB9h9udl4qHQjjgEos66z00Ia0OckwYfRxCSFrW8RJIBnsBwQsHZbVPspqg==", + "version": "15.6.6", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz", + "integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", @@ -17216,7 +16533,7 @@ "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", - "prismjs": "~1.30.0" + "prismjs": "~1.27.0" }, "funding": { "type": "github", @@ -17502,9 +16819,9 @@ } }, "node_modules/rollup": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", - "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", + "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -17517,26 +16834,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.46.2", - "@rollup/rollup-android-arm64": "4.46.2", - "@rollup/rollup-darwin-arm64": "4.46.2", - "@rollup/rollup-darwin-x64": "4.46.2", - "@rollup/rollup-freebsd-arm64": "4.46.2", - "@rollup/rollup-freebsd-x64": "4.46.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", - "@rollup/rollup-linux-arm-musleabihf": "4.46.2", - "@rollup/rollup-linux-arm64-gnu": "4.46.2", - "@rollup/rollup-linux-arm64-musl": "4.46.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", - "@rollup/rollup-linux-ppc64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-musl": "4.46.2", - "@rollup/rollup-linux-s390x-gnu": "4.46.2", - "@rollup/rollup-linux-x64-gnu": "4.46.2", - "@rollup/rollup-linux-x64-musl": "4.46.2", - "@rollup/rollup-win32-arm64-msvc": "4.46.2", - "@rollup/rollup-win32-ia32-msvc": "4.46.2", - "@rollup/rollup-win32-x64-msvc": "4.46.2", + "@rollup/rollup-android-arm-eabi": "4.52.3", + "@rollup/rollup-android-arm64": "4.52.3", + "@rollup/rollup-darwin-arm64": "4.52.3", + "@rollup/rollup-darwin-x64": "4.52.3", + "@rollup/rollup-freebsd-arm64": "4.52.3", + "@rollup/rollup-freebsd-x64": "4.52.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", + "@rollup/rollup-linux-arm-musleabihf": "4.52.3", + "@rollup/rollup-linux-arm64-gnu": "4.52.3", + "@rollup/rollup-linux-arm64-musl": "4.52.3", + "@rollup/rollup-linux-loong64-gnu": "4.52.3", + "@rollup/rollup-linux-ppc64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-musl": "4.52.3", + "@rollup/rollup-linux-s390x-gnu": "4.52.3", + "@rollup/rollup-linux-x64-gnu": "4.52.3", + "@rollup/rollup-linux-x64-musl": "4.52.3", + "@rollup/rollup-openharmony-arm64": "4.52.3", + "@rollup/rollup-win32-arm64-msvc": "4.52.3", + "@rollup/rollup-win32-ia32-msvc": "4.52.3", + "@rollup/rollup-win32-x64-gnu": "4.52.3", + "@rollup/rollup-win32-x64-msvc": "4.52.3", "fsevents": "~2.3.2" } }, @@ -17730,9 +17049,9 @@ "license": "MIT" }, "node_modules/schema-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", - "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.9", @@ -17916,9 +17235,9 @@ } }, "node_modules/sharp/node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", + "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", "license": "Apache-2.0", "engines": { "node": ">=8" @@ -18034,18 +17353,18 @@ "license": "ISC" }, "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", "license": "MIT", "dependencies": { "is-arrayish": "^0.3.1" } }, "node_modules/simple-swizzle/node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "license": "MIT" }, "node_modules/simple-wcswidth": { @@ -18060,6 +17379,12 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/slow-redact": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/slow-redact/-/slow-redact-0.3.0.tgz", + "integrity": "sha512-cf723wn9JeRIYP9tdtd86GuqoR5937u64Io+CYjlm2i7jvu7g0H+Cp0l0ShAf/4ZL+ISUTVT+8Qzz7RZmp9FjA==", + "license": "MIT" + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -18237,16 +17562,14 @@ } }, "node_modules/streamx": { - "version": "2.22.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", - "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", "license": "MIT", "dependencies": { + "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" } }, "node_modules/string_decoder": { @@ -18445,9 +17768,9 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -18572,6 +17895,24 @@ "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==", "license": "MIT" }, + "node_modules/styled-components/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/styled-components/node_modules/postcss": { "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", @@ -18795,9 +18136,9 @@ } }, "node_modules/systeminformation": { - "version": "5.27.7", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.7.tgz", - "integrity": "sha512-saaqOoVEEFaux4v0K8Q7caiauRwjXC4XbD2eH60dxHXbpKxQ8kH9Rf7Jh+nryKpOUSEFxtCdBlSUx0/lO6rwRg==", + "version": "5.27.10", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.10.tgz", + "integrity": "sha512-jkeOerLSwLZqJrPHCYltlKHu0PisdepIuS4GwjFFtgQUG/5AQPVZekkECuULqdP0cgrrIHW8Nl8J7WQXo5ypEg==", "license": "MIT", "optional": true, "os": [ @@ -18838,9 +18179,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", - "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -18851,7 +18192,7 @@ "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.21.6", + "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", @@ -18860,7 +18201,7 @@ "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", @@ -18912,9 +18253,9 @@ } }, "node_modules/tailwindcss/node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", "funding": [ { "type": "opencollective", @@ -18927,21 +18268,28 @@ ], "license": "MIT", "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" + "lilconfig": "^3.1.1" }, "engines": { - "node": ">= 14" + "node": ">= 18" }, "peerDependencies": { + "jiti": ">=1.21.0", "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { + "jiti": { + "optional": true + }, "postcss": { "optional": true }, - "ts-node": { + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } @@ -18951,6 +18299,8 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "license": "ISC", + "optional": true, + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -18959,12 +18309,16 @@ } }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar-stream": { @@ -18979,13 +18333,13 @@ } }, "node_modules/terser": { - "version": "5.43.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", - "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.14.0", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -19176,13 +18530,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -19387,7 +18741,7 @@ "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", "license": "MIT", "dependencies": { - "esbuild": "~0.25.9", + "esbuild": "~0.23.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -19529,9 +18883,9 @@ } }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -19543,9 +18897,9 @@ } }, "node_modules/uint8array-extras": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.1.tgz", - "integrity": "sha512-+NWHrac9dvilNgme+gP4YrBSumsaMZP0fNBtXXFIf33RLLKEcBUKaQZ7ULUbS0sBfcjxIZ4V96OTRkCbM7hxpw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", "license": "MIT", "engines": { "node": ">=18" @@ -19852,9 +19206,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -20006,9 +19360,9 @@ } }, "node_modules/webpack": { - "version": "5.101.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.1.tgz", - "integrity": "sha512-rHY3vHXRbkSfhG6fH8zYQdth/BtDgXXuR2pHF++1f/EBkI8zkgM5XWfsC3BvOoW9pr1CvZ1qQCxhCEsbNgT50g==", + "version": "5.102.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.0.tgz", + "integrity": "sha512-hUtqAR3ZLVEYDEABdBioQCIqSoguHbFn1K7WlPPWSuXmx0031BD73PSE35jKyftdSh4YLDoQNgK4pqBt5Q82MA==", "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", @@ -20019,7 +19373,7 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.24.0", + "browserslist": "^4.24.5", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", @@ -20032,9 +19386,9 @@ "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.2", - "tapable": "^2.1.1", + "tapable": "^2.2.3", "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", + "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, "bin": { @@ -20351,9 +19705,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -20522,27 +19876,18 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.24.6", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", - "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.24.1" - } - }, "node_modules/zustand": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.7.tgz", - "integrity": "sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/frontend/package.json b/frontend/package.json index 69ff8aa..5bc639e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,12 +18,15 @@ "payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload" }, "dependencies": { - "@ai-sdk/react": "^1.1.24", + "@ai-sdk/openai": "^2.0.40", + "@ai-sdk/provider": "^2.0.0", + "@ai-sdk/provider-utils": "^3.0.0", + "@ai-sdk/react": "^2.0.59", "@emoji-mart/data": "^1.2.1", - "@hookform/resolvers": "^3.10.0", - "@payloadcms/db-sqlite": "^3.51.0", - "@payloadcms/next": "^3.51.0", - "@payloadcms/richtext-lexical": "^3.51.0", + "@hookform/resolvers": "^5.2.2", + "@payloadcms/db-sqlite": "^3.58.0", + "@payloadcms/next": "^3.58.0", + "@payloadcms/richtext-lexical": "^3.58.0", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "^1.1.2", @@ -48,7 +51,7 @@ "@sentry/nextjs": "^9.0.1", "@tanstack/react-query": "^5.74.4", "@tanstack/react-table": "^8.21.2", - "ai": "^4.1.20", + "ai": "^5.0.59", "archiver": "^7.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -63,14 +66,15 @@ "geist": "^1.3.1", "graphql": "^16.10.0", "jsdom": "^26.1.0", + "jsonrepair": "^3.13.1", "jspdf": "^3.0.2", "jspdf-autotable": "^5.0.2", "lodash": "^4.17.21", "lucide-react": "^0.474.0", "next": "^15.5.2", "next-themes": "^0.4.6", - "ollama-ai-provider": "^1.2.0", - "payload": "^3.51.0", + "ollama-ai-provider-v2": "^1.4.1", + "payload": "^3.58.0", "pdf-parse": "^1.1.1", "pm2": "^6.0.10", "react": "^19.0.0", @@ -89,7 +93,7 @@ "tesseract.js": "^6.0.0", "vaul": "^1.1.2", "webpack": "^5.99.5", - "zod": "^3.24.2", + "zod": "^4.1.11", "zustand": "^5.0.3" }, "devDependencies": { @@ -108,11 +112,31 @@ "eslint-config-prettier": "^10.1.5", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.5.0", + "postcss": "^8", "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.6.12", - "postcss": "^8", "tailwindcss": "^3.4.1", "terser-webpack-plugin": "^5.3.14", "typescript": "^5" + }, + "overrides": { + "esbuild": "^0.25.10", + "pino": "^9.13.0", + "prismjs": "^1.30.0", + "@esbuild-kit/core-utils": { + "esbuild": "^0.25.10" + }, + "@esbuild-kit/esm-loader": { + "esbuild": "^0.25.10" + }, + "drizzle-kit": { + "esbuild": "^0.25.10", + "@esbuild-kit/esm-loader": { + "esbuild": "^0.25.10" + }, + "@esbuild-kit/core-utils": { + "esbuild": "^0.25.10" + } + } } -} \ No newline at end of file +} diff --git a/frontend/src/app/(app)/workspace/assessment/page.tsx b/frontend/src/app/(app)/workspace/assessment/page.tsx index c5ef5f4..618016e 100644 --- a/frontend/src/app/(app)/workspace/assessment/page.tsx +++ b/frontend/src/app/(app)/workspace/assessment/page.tsx @@ -50,6 +50,7 @@ import { DialogFooter, } from '@/components/ui/dialog' import { useCourses } from '@/lib/hooks/use-courses' +import { usePersonaStore } from '@/lib/store/persona-store' import type { Course } from '@/payload-types' // Add these helper functions at the top of the file, after the imports @@ -159,6 +160,8 @@ const formatSemester = (input: string): string => { export default function AssessmentPage() { const router = useRouter() const { data: coursesData } = useCourses() + const getPersonaLanguage = usePersonaStore((s) => s.getPersonaLanguage) + const activePersona = usePersonaStore((s) => s.activePersona) const [assessmentType, setAssessmentType] = useState('') const [difficultyLevel, setDifficultyLevel] = useState('') @@ -495,7 +498,14 @@ export default function AssessmentPage() { console.log('Sending request with metadata:', courseInfo) - const response = await fetch('/api/assessment', { + const endpoint = + assessmentType === 'project' + ? '/api/assessment/project' + : assessmentType === 'exam' + ? '/api/assessment/exam' + : '/api/assessment' + + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -507,6 +517,7 @@ export default function AssessmentPage() { difficultyLevel, numQuestions, courseInfo, + language: getPersonaLanguage(), }), }) @@ -629,6 +640,7 @@ export default function AssessmentPage() { format, metadata, }, + language: getPersonaLanguage(activePersona), }), }) @@ -686,6 +698,7 @@ export default function AssessmentPage() { format, metadata, }, + language: getPersonaLanguage(activePersona), }), }) @@ -1067,7 +1080,7 @@ export default function AssessmentPage() { setNumQuestions(value[0])} diff --git a/frontend/src/app/(app)/workspace/chat/history/page.tsx b/frontend/src/app/(app)/workspace/chat/history/page.tsx index 7e9a688..1ebf419 100644 --- a/frontend/src/app/(app)/workspace/chat/history/page.tsx +++ b/frontend/src/app/(app)/workspace/chat/history/page.tsx @@ -33,6 +33,7 @@ import { usePersonaStore } from '@/lib/store/persona-store' import { Badge } from '@/components/ui/badge' import { useContextAvailability } from '@/lib/hooks/use-context-availability' import { getSelectContextDescription } from '@/lib/utils/context-messages' +import { extractTextFromMessage } from '@/lib/utils/message' export default function ChatHistoryPage() { const [, setIsMobile] = useState(false) @@ -53,7 +54,7 @@ export default function ChatHistoryPage() { const filteredChats = chatArray.filter((chat) => chat.messages.some((message) => - message.content.toLowerCase().includes(searchTerm.toLowerCase()), + extractTextFromMessage(message).toLowerCase().includes(searchTerm.toLowerCase()), ), ) @@ -185,7 +186,7 @@ export default function ChatHistoryPage() { )} - {chat.messages[1].content} + {extractTextFromMessage(chat.messages[1])}
diff --git a/frontend/src/app/(app)/workspace/chat/page.tsx b/frontend/src/app/(app)/workspace/chat/page.tsx index 4735eaa..8c55afd 100644 --- a/frontend/src/app/(app)/workspace/chat/page.tsx +++ b/frontend/src/app/(app)/workspace/chat/page.tsx @@ -11,7 +11,7 @@ import { generateUUID } from '@/lib/utils' import Chat from '@/components/chat/chat' import { useEffect, useState } from 'react' import { ContextRequirementMessage } from '@/components/context-requirement-message' -import { Message } from '@ai-sdk/react' +import { UIMessage } from '@ai-sdk/react' import { useContextAvailability } from '@/lib/hooks/use-context-availability' import { usePersonaStore } from '@/lib/store/persona-store' @@ -20,7 +20,7 @@ export default function ChatPage() { const [, setIsMobile] = useState(false) const router = useRouter() const { getActiveContextModelName } = useContextAvailability() - const { activePersona, personas } = usePersonaStore() + const { activePersona, personas, getPersonaLanguage } = usePersonaStore() // get model name based on selected model or course const modelName = getActiveContextModelName() @@ -43,12 +43,29 @@ export default function ChatPage() { ? personas.find((persona) => persona.id === activePersona)?.name : 'Academic Assistant' - const welcomeMessage: Message[] = [ + // Localize welcome message based on selected response language (per persona) + const lang = getPersonaLanguage() + const greeting = + lang === 'id' + ? `Halo! Saya ${activePersonaName} bertenaga AI Anda. ` + : `Hello! I'm your AI ${activePersonaName}. ` + const prompts: Record<'en' | 'id', string> = { + id: 'Bagaimana saya dapat membantu Anda hari ini? ', + en: 'How can I help you today? ', + } + const prompt = (prompts[lang as 'en' | 'id'] ?? prompts.en) as string + + const welcomeMessage: UIMessage[] = [ { id: 'welcome-1', role: 'assistant', - content: `Hello! I'm your AI ${activePersonaName}. ` + `How can I help you today? `, - createdAt: new Date(), + parts: [ + { + type: 'text', + text: greeting + prompt, + }, + ], + metadata: { createdAt: Date.now() }, }, ] diff --git a/frontend/src/app/(app)/workspace/courses/create/page.tsx b/frontend/src/app/(app)/workspace/courses/create/page.tsx index 2b9b55c..391fe8d 100644 --- a/frontend/src/app/(app)/workspace/courses/create/page.tsx +++ b/frontend/src/app/(app)/workspace/courses/create/page.tsx @@ -16,7 +16,7 @@ import { Search, X, } from 'lucide-react' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { useForm } from 'react-hook-form' import { z } from 'zod' import { useModelStore } from '@/lib/store/model-store' @@ -147,7 +147,7 @@ export default function CreateCoursePage() { } const form = useForm({ - resolver: zodResolver(courseFormSchema), + resolver: standardSchemaResolver(courseFormSchema), defaultValues, mode: 'onBlur', // Validate on blur (not on every change) reValidateMode: 'onSubmit', // Only re-validate on submit, not on every change diff --git a/frontend/src/app/(app)/workspace/courses/edit/[id]/page.tsx b/frontend/src/app/(app)/workspace/courses/edit/[id]/page.tsx index 9b16429..005c5fb 100644 --- a/frontend/src/app/(app)/workspace/courses/edit/[id]/page.tsx +++ b/frontend/src/app/(app)/workspace/courses/edit/[id]/page.tsx @@ -6,7 +6,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react' import { useRouter } from 'next/navigation' import { AlertCircle, ArrowLeft, Check, Loader2, X } from 'lucide-react' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { useForm } from 'react-hook-form' import { z } from 'zod' import { useModelStore } from '@/lib/store/model-store' @@ -163,7 +163,7 @@ export default function EditCoursePage({ params }: { params: Promise<{ id: strin }, [coursesData]) const form = useForm({ - resolver: zodResolver(courseFormSchema), + resolver: standardSchemaResolver(courseFormSchema), mode: 'onBlur', // Validate on blur (not on every change) reValidateMode: 'onSubmit', // Only re-validate on submit, not on every change defaultValues: staticDefaultValues, diff --git a/frontend/src/app/(app)/workspace/faq/page.tsx b/frontend/src/app/(app)/workspace/faq/page.tsx index e48d967..ab35715 100644 --- a/frontend/src/app/(app)/workspace/faq/page.tsx +++ b/frontend/src/app/(app)/workspace/faq/page.tsx @@ -58,7 +58,7 @@ export default function FAQComponent() { const { getActiveContextModelName, getContextTypeLabel } = useContextAvailability() const selectedSources = useSourcesStore((state) => state.selectedSources) const { data: coursesData } = useCourses() - const { selectedCourseId } = usePersonaStore() + const { selectedCourseId, getPersonaLanguage } = usePersonaStore() // Function to trigger the API and fetch initial FAQs const fetchAPI = async () => { @@ -113,6 +113,7 @@ export default function FAQComponent() { continueFaqs: false, useReranker: useReranker, // Add this line courseInfo, // Add course info + language: getPersonaLanguage(), }), }) @@ -171,6 +172,7 @@ export default function FAQComponent() { continueFaqs: true, // Flag that this is a continuation request useReranker: useReranker, // Add this line courseInfo, // Add course info + language: getPersonaLanguage(), }), }) diff --git a/frontend/src/app/(app)/workspace/overview/faculty/page.tsx b/frontend/src/app/(app)/workspace/overview/faculty/page.tsx index 13f9d87..7581299 100644 --- a/frontend/src/app/(app)/workspace/overview/faculty/page.tsx +++ b/frontend/src/app/(app)/workspace/overview/faculty/page.tsx @@ -154,6 +154,7 @@ export default function FacultyOverviewPage() {
+ {/* Overview content */} {/* Stats Cards */}
@@ -196,7 +197,6 @@ export default function FacultyOverviewPage() {
- {/* Courses List */}

Courses

diff --git a/frontend/src/app/(app)/workspace/overview/faculty/settings/page.tsx b/frontend/src/app/(app)/workspace/overview/faculty/settings/page.tsx new file mode 100644 index 0000000..176eef5 --- /dev/null +++ b/frontend/src/app/(app)/workspace/overview/faculty/settings/page.tsx @@ -0,0 +1,8 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import { redirect } from 'next/navigation' + +export default function FacultySettingsRedirectPage() { + redirect('/workspace/settings?persona=faculty') +} diff --git a/frontend/src/app/(app)/workspace/overview/lecturer/page.tsx b/frontend/src/app/(app)/workspace/overview/lecturer/page.tsx index 5b1435b..ea1d7db 100644 --- a/frontend/src/app/(app)/workspace/overview/lecturer/page.tsx +++ b/frontend/src/app/(app)/workspace/overview/lecturer/page.tsx @@ -30,12 +30,14 @@ import { useModelStore } from '@/lib/store/model-store' import { useCourses } from '@/lib/hooks/use-courses' import { usePersonaStore } from '@/lib/store/persona-store' import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' export default function LecturerOverviewPage() { const router = useRouter() const { data: coursesData, isLoading: isCourseLoading } = useCourses() const { selectedCourseId, setSelectedCourseId } = usePersonaStore() const { models } = useModelStore() + const defaultTab = 'overview' // Select first course if none selected useEffect(() => { @@ -161,227 +163,197 @@ export default function LecturerOverviewPage() { {/* Scrollable Content Area */}
-
- {/* Active Course */} -
- {activeCourse ? ( - <> - - -
-
- - {activeCourse.name} - - - - {activeCourse.code} - - {activeCourse.facultyName} - -
- Active -
-
- -

{activeCourse.description}

+ + + Overview + -
-
-
- -
-
-
Status
-
- {activeCourse.model && - typeof activeCourse.model === 'object' && - 'name' in (activeCourse.model as { name: string }) && - models.some( - (model) => - model.name === (activeCourse.model as { name: string }).name, - ) ? ( - Available - ) : ( - - Unavailable + +
+ {/* Active Course */} +
+ {activeCourse ? ( + <> + + +
+
+ + {activeCourse.name} + + + + {activeCourse.code} - )} + {activeCourse.facultyName} +
+ Active
-
- -
-
- -
-
-
Total Version
-
{totalCoursesWithSameCode}
-
-
+ + +

{activeCourse.description}

-
-
- -
-
-
Version
-
{activeCourse.version}
-
-
-
- - - - - - +
+
+
+ +
+
+
Status
+
+ {activeCourse.model && + typeof activeCourse.model === 'object' && + 'name' in (activeCourse.model as { name: string }) && + models.some( + (model) => + model.name === (activeCourse.model as { name: string }).name, + ) ? ( + Available + ) : ( + + Unavailable + + )} +
+
+
-
-

Teaching Materials

-
- router.push('/workspace/slide')} - className="cursor-pointer transition-colors hover:bg-muted/50" - > - -
- Slide - -
-
- -

- Generate and manage lecture slides -

-
-
+
+
+ +
+
+
Total Version
+
{totalCoursesWithSameCode}
+
+
- router.push('/workspace/assessment')} - className="cursor-pointer transition-colors hover:bg-muted/50" - > - -
- Assessment - +
+
+ +
+
+
Version
+
{activeCourse.version}
+
+
-
- -

- Create and generate assessment questions -

+ + + +
- router.push('/workspace/quiz/generate')} - className="cursor-pointer transition-colors hover:bg-muted/50" - > - -
- Quiz - -
-
- -

Generate quizzes

-
-
+
+

Teaching Materials

+
+ router.push('/workspace/slide')} + className="cursor-pointer transition-colors hover:bg-muted/50" + > + +
+ Slide + +
+
+ +

+ Generate and manage lecture slides +

+
+
- router.push('/workspace/faq')} - className="cursor-pointer transition-colors hover:bg-muted/50" - > - -
- FAQ - -
-
- -

- Generate Frequently Ask Questions -

-
-
-
-
- - ) : ( - - - No Course Selected - Select a course from the list to view details - - - )} -
+ router.push('/workspace/assessment')} + className="cursor-pointer transition-colors hover:bg-muted/50" + > + +
+ Assessment + +
+
+ +

+ Create and generate assessment questions +

+
+
- {/* Sidebar */} -
-
-

Your Courses

- -
+ router.push('/workspace/quiz/generate')} + className="cursor-pointer transition-colors hover:bg-muted/50" + > + +
+ Quiz + +
+
+ +

Generate quizzes

+
+
-
- {coursesData?.docs?.map((course) => { - const isAvailable = models.some( - (model) => - typeof course.model === 'object' && - course.model !== null && - 'name' in course.model && - model.name === (course.model as { name: string }).name, - ) - return ( - isAvailable && setSelectedCourseId(course.id)} - > - - {course.name} - - - {course.code} - - {!isAvailable && ( - - Not Installed - - )} + router.push('/workspace/faq')} + className="cursor-pointer transition-colors hover:bg-muted/50" + > + +
+ FAQ + +
+
+ +

+ Generate Frequently Ask Questions +

+
+
+
+
+ + ) : ( + + + No Course Selected + + Select a course from the list to view details - -

- {course.facultyName} -

-
- ) - })} -
+ )} +
+ + {/* Sidebar */} +
+
+

Your Courses

+
+ +
+
- {relatedCourses && relatedCourses.length > 0 && ( - <> -

Related Courses

- {relatedCourses.map((course) => { + {coursesData?.docs?.map((course) => { const isAvailable = models.some( (model) => typeof course.model === 'object' && @@ -392,7 +364,9 @@ export default function LecturerOverviewPage() { return ( isAvailable && setSelectedCourseId(course.id)} > @@ -417,10 +391,60 @@ export default function LecturerOverviewPage() { ) })}
- - )} -
-
+ + {relatedCourses && relatedCourses.length > 0 && ( + <> +

Related Courses

+
+ {relatedCourses.map((course) => { + const isAvailable = models.some( + (model) => + typeof course.model === 'object' && + course.model !== null && + 'name' in course.model && + model.name === (course.model as { name: string }).name, + ) + return ( + isAvailable && setSelectedCourseId(course.id)} + > + + {course.name} + + + {course.code} + + {!isAvailable && ( + + Not Installed + + )} + + + +

+ {course.facultyName} +

+
+
+ ) + })} +
+ + )} +
+
+ +
diff --git a/frontend/src/app/(app)/workspace/overview/lecturer/settings/page.tsx b/frontend/src/app/(app)/workspace/overview/lecturer/settings/page.tsx new file mode 100644 index 0000000..a6c648f --- /dev/null +++ b/frontend/src/app/(app)/workspace/overview/lecturer/settings/page.tsx @@ -0,0 +1,8 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import { redirect } from 'next/navigation' + +export default function LecturerSettingsRedirectPage() { + redirect('/workspace/settings?persona=lecturer') +} diff --git a/frontend/src/app/(app)/workspace/overview/student/page.tsx b/frontend/src/app/(app)/workspace/overview/student/page.tsx index 5dc1fc1..0598b75 100644 --- a/frontend/src/app/(app)/workspace/overview/student/page.tsx +++ b/frontend/src/app/(app)/workspace/overview/student/page.tsx @@ -20,12 +20,14 @@ import { useModelStore } from '@/lib/store/model-store' import { useCourses } from '@/lib/hooks/use-courses' import { usePersonaStore } from '@/lib/store/persona-store' import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' export default function StudentOverviewPage() { const router = useRouter() const { data: coursesData, isLoading: isCourseLoading } = useCourses() const { selectedCourseId, setSelectedCourseId } = usePersonaStore() const { models } = useModelStore() + const defaultTab = 'overview' // Select first course if none selected useEffect(() => { @@ -151,187 +153,157 @@ export default function StudentOverviewPage() { {/* Scrollable Content Area */}
-
- {/* Active Course */} -
- {activeCourse ? ( - <> - - -
-
- - {activeCourse.name} - - - - {activeCourse.code} - - {activeCourse.facultyName} - -
- Active -
-
- -

{activeCourse.description}

+ + + Overview + -
-
-
- -
-
-
Status
-
- {activeCourse.model && - typeof activeCourse.model === 'object' && - 'name' in (activeCourse.model as { name: string }) && - models.some( - (model) => - model.name === (activeCourse.model as { name: string }).name, - ) ? ( - Available - ) : ( - - Unavailable + +
+ {/* Active Course */} +
+ {activeCourse ? ( + <> + + +
+
+ + {activeCourse.name} + + + + {activeCourse.code} - )} + {activeCourse.facultyName} +
+ Active
-
+ + +

{activeCourse.description}

-
-
- -
-
-
Total Version
-
{totalCoursesWithSameCode}
-
-
+
+
+
+ +
+
+
Status
+
+ {activeCourse.model && + typeof activeCourse.model === 'object' && + 'name' in (activeCourse.model as { name: string }) && + models.some( + (model) => + model.name === (activeCourse.model as { name: string }).name, + ) ? ( + Available + ) : ( + + Unavailable + + )} +
+
+
-
-
- -
-
-
Version
-
{activeCourse.version}
+
+
+ +
+
+
Total Version
+
{totalCoursesWithSameCode}
+
+
+ +
+
+ +
+
+
Version
+
{activeCourse.version}
+
+
+ + + + + + + +
+

Recent Activities

+
+ {[1, 2, 3].map((i) => ( + + +
+ + Practice Session {i} + + Coming Soon + + + +
+
+ +

+ You completed {3 * i} questions on {activeCourse.name} +

+
+
+ ))}
- - + + ) : ( + + + No Course Selected + + Select a course from the list to view details + + + + )} +
+ + {/* Sidebar */} +
+
+

Your Courses

+
- - - - -
-

Recent Activities

-
- {[1, 2, 3].map((i) => ( - - -
- - Practice Session {i} - - Coming Soon - - - -
-
- -

- You completed {3 * i} questions on {activeCourse.name} -

-
-
- ))}
- - ) : ( - - - No Course Selected - Select a course from the list to view details - - - )} -
- - {/* Sidebar */} -
-
-

Your Courses

- -
- -
- {coursesData?.docs?.map((course) => { - const isAvailable = models.some( - (model) => - typeof course.model === 'object' && - course.model !== null && - 'name' in course.model && - model.name === (course.model as { name: string }).name, - ) - return ( - isAvailable && setSelectedCourseId(course.id)} - > - - {course.name} - - - {course.code} - - {!isAvailable && ( - - Not Installed - - )} - - - -

- {course.facultyName} -

-
-
- ) - })} -
- {relatedCourses && relatedCourses.length > 0 && ( - <> -

Related Courses

- {relatedCourses.map((course) => { + {coursesData?.docs?.map((course) => { const isAvailable = models.some( (model) => typeof course.model === 'object' && @@ -342,7 +314,13 @@ export default function StudentOverviewPage() { return ( isAvailable && setSelectedCourseId(course.id)} > @@ -367,10 +345,60 @@ export default function StudentOverviewPage() { ) })}
- - )} -
-
+ + {relatedCourses && relatedCourses.length > 0 && ( + <> +

Related Courses

+
+ {relatedCourses.map((course) => { + const isAvailable = models.some( + (model) => + typeof course.model === 'object' && + course.model !== null && + 'name' in course.model && + model.name === (course.model as { name: string }).name, + ) + return ( + isAvailable && setSelectedCourseId(course.id)} + > + + {course.name} + + + {course.code} + + {!isAvailable && ( + + Not Installed + + )} + + + +

+ {course.facultyName} +

+
+
+ ) + })} +
+ + )} +
+
+ +
diff --git a/frontend/src/app/(app)/workspace/overview/student/settings/page.tsx b/frontend/src/app/(app)/workspace/overview/student/settings/page.tsx new file mode 100644 index 0000000..f699f81 --- /dev/null +++ b/frontend/src/app/(app)/workspace/overview/student/settings/page.tsx @@ -0,0 +1,8 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import { redirect } from 'next/navigation' + +export default function StudentSettingsRedirectPage() { + redirect('/workspace/settings?persona=student') +} diff --git a/frontend/src/app/(app)/workspace/programmes/create/page.tsx b/frontend/src/app/(app)/workspace/programmes/create/page.tsx index ed94c06..236eb93 100644 --- a/frontend/src/app/(app)/workspace/programmes/create/page.tsx +++ b/frontend/src/app/(app)/workspace/programmes/create/page.tsx @@ -16,7 +16,7 @@ import { AlertCircle, ChevronUp, } from 'lucide-react' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { useForm } from 'react-hook-form' import { z } from 'zod' import { Button } from '@/components/ui/button' @@ -109,7 +109,7 @@ export default function CreateProgrammePage() { } const form = useForm({ - resolver: zodResolver(programmeFormSchema), + resolver: standardSchemaResolver(programmeFormSchema), defaultValues, mode: 'onBlur', // Validate on blur (not on every change) reValidateMode: 'onSubmit', // Only re-validate on submit, not on every change diff --git a/frontend/src/app/(app)/workspace/programmes/edit/[id]/page.tsx b/frontend/src/app/(app)/workspace/programmes/edit/[id]/page.tsx index 7f94207..a5e1772 100644 --- a/frontend/src/app/(app)/workspace/programmes/edit/[id]/page.tsx +++ b/frontend/src/app/(app)/workspace/programmes/edit/[id]/page.tsx @@ -5,7 +5,7 @@ import React, { useState, useEffect } from 'react' import { useRouter } from 'next/navigation' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { useForm, FieldErrors } from 'react-hook-form' import { z } from 'zod' import { Button } from '@/components/ui/button' @@ -91,7 +91,7 @@ export default function EditProgrammePage({ params }: { params: Promise<{ id: st }, [programme, programmes, router]) const form = useForm({ - resolver: zodResolver(programmeFormSchema), + resolver: standardSchemaResolver(programmeFormSchema), mode: 'onChange', }) diff --git a/frontend/src/app/(app)/workspace/quiz/generate/page.tsx b/frontend/src/app/(app)/workspace/quiz/generate/page.tsx index 9c42f0f..c4c9442 100644 --- a/frontend/src/app/(app)/workspace/quiz/generate/page.tsx +++ b/frontend/src/app/(app)/workspace/quiz/generate/page.tsx @@ -47,6 +47,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { Alert, AlertDescription } from '@/components/ui/alert' import { Document, Paragraph, TextRun, Packer, AlignmentType } from 'docx' import { useRouter } from 'next/navigation' +import { usePersonaStore } from '@/lib/store/persona-store' interface Question { id: string @@ -93,6 +94,7 @@ export default function QuizGeneratorLecturer() { const selectedSources = useSourcesStore((state) => state.selectedSources) const { getActiveContextModelName, getContextTypeLabel } = useContextAvailability() const modelName = getActiveContextModelName() + const { activePersona, getPersonaLanguage } = usePersonaStore() const generateQuiz = async () => { if (!getActiveContextModelName()) { @@ -124,6 +126,7 @@ export default function QuizGeneratorLecturer() { difficulty, questionType, searchKeywords, // Add searchKeywords to the request body + language: getPersonaLanguage(activePersona), }), }) const data = await response.json() @@ -303,6 +306,33 @@ export default function QuizGeneratorLecturer() { const exportQuizAsWordDoc = () => { if (!currentQuiz) return + // Determine language from persona + const lang = getPersonaLanguage(activePersona) + const labels = + lang === 'id' + ? { + studentName: 'Nama Mahasiswa:', + date: 'Tanggal:', + quizPrefix: 'Kuis: ', + description: 'Deskripsi: ', + instructionLabel: 'Instruksi: ', + instructionText: + 'Kuis ini terdiri dari beberapa pertanyaan. Bacalah setiap pertanyaan dengan cermat dan berikan jawaban yang benar.', + trueLabel: 'Benar', + falseLabel: 'Salah', + } + : { + studentName: 'Student Name:', + date: 'Date:', + quizPrefix: 'Quiz: ', + description: 'Description: ', + instructionLabel: 'Instruction: ', + instructionText: + 'This quiz contains multiple questions. Read each question carefully and provide the correct answers.', + trueLabel: 'True', + falseLabel: 'False', + } + // Create document content const doc = new Document({ sections: [ @@ -312,9 +342,9 @@ export default function QuizGeneratorLecturer() { // Student info new Paragraph({ children: [ - new TextRun('Student Name:'), + new TextRun(labels.studentName), new TextRun('\t'.repeat(10)), - new TextRun('Date:'), + new TextRun(labels.date), ], spacing: { after: 100 }, }), @@ -322,7 +352,7 @@ export default function QuizGeneratorLecturer() { new Paragraph({ children: [ new TextRun({ - text: `Quiz: ${currentQuiz.title}`, + text: `${labels.quizPrefix}${currentQuiz.title}`, size: 24, bold: true, }), @@ -336,7 +366,7 @@ export default function QuizGeneratorLecturer() { ? [ new Paragraph({ children: [ - new TextRun({ text: 'Description: ' }), + new TextRun({ text: labels.description }), new TextRun({ text: currentQuiz.description }), ], spacing: { after: 100 }, @@ -347,10 +377,8 @@ export default function QuizGeneratorLecturer() { // Quiz instructions new Paragraph({ children: [ - new TextRun({ text: 'Instruction: ', bold: true }), - new TextRun({ - text: 'This quiz contains multiple questions. Read each question carefully and provide the correct answers. ', - }), + new TextRun({ text: labels.instructionLabel, bold: true }), + new TextRun({ text: ` ${labels.instructionText} ` }), ], spacing: { after: 300 }, }), @@ -375,7 +403,7 @@ export default function QuizGeneratorLecturer() { // For True/False questions ...(question.type === 'trueFalse' - ? ['True', 'False'].map( + ? [labels.trueLabel, labels.falseLabel].map( (option, idx) => new Paragraph({ text: `${String.fromCharCode(65 + idx)}. ${option}`, // A. True, B. False diff --git a/frontend/src/app/(app)/workspace/settings/page.tsx b/frontend/src/app/(app)/workspace/settings/page.tsx index 1dc968c..ce553fd 100644 --- a/frontend/src/app/(app)/workspace/settings/page.tsx +++ b/frontend/src/app/(app)/workspace/settings/page.tsx @@ -3,31 +3,129 @@ 'use client' +import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' import { usePersonaStore } from '@/lib/store/persona-store' -import { Settings2 } from 'lucide-react' -import { useRouter } from 'next/navigation' +import { Settings2, ArrowLeft } from 'lucide-react' +import { useRouter, useSearchParams } from 'next/navigation' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' export default function SettingsPage() { const router = useRouter() - const { activePersona } = usePersonaStore() + const searchParams = useSearchParams() + const { activePersona, appPersona, setActivePersona, getPersonaLanguage, setPersonaLanguage } = + usePersonaStore() + + type P = 'faculty' | 'lecturer' | 'student' + const isPersona = (v: unknown): v is P => v === 'faculty' || v === 'lecturer' || v === 'student' + const qp = searchParams.get('persona') + const persona: P = isPersona(qp) + ? qp + : isPersona(activePersona) + ? activePersona + : isPersona(appPersona) + ? appPersona + : 'faculty' + const currentLang = getPersonaLanguage(persona) + useEffect(() => { + if (activePersona !== persona) { + setActivePersona(persona) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [persona]) + const [pendingLang, setPendingLang] = useState<'en' | 'id' | null>(null) + const [confirmOpen, setConfirmOpen] = useState(false) + const title = useMemo(() => { + if (persona === 'lecturer') return 'Lecturer Settings' + if (persona === 'student') return 'Student Settings' + return 'Faculty Settings' + }, [persona]) return ( -
-
- -

Settings Coming Soon

-

- We‘re working on adding settings functionality to help you customize your - experience. Check back soon! -

-
+ +
+
+ + +

+ {currentLang === 'id' + ? 'Hanya respons yang dihasilkan AI yang akan berubah bahasa. Antarmuka aplikasi tetap dalam Bahasa Inggris.' + : 'Only AI-generated responses change language. The app UI remains in English.'} +

+
+
+ + + + + Confirm Language + + {`Change response language to ${pendingLang === 'id' ? 'Bahasa Indonesia' : 'English'} for ${title.replace(' Settings', '')} persona?`} + + + + + + + +
) } diff --git a/frontend/src/app/(app)/workspace/summary/page.tsx b/frontend/src/app/(app)/workspace/summary/page.tsx index 2e657ef..6defc39 100644 --- a/frontend/src/app/(app)/workspace/summary/page.tsx +++ b/frontend/src/app/(app)/workspace/summary/page.tsx @@ -72,7 +72,12 @@ export default function SummaryPage() { setError, setSelectedModel, } = useSummaryStore() - const { activePersona, selectedCourseId } = usePersonaStore() + const { activePersona, selectedCourseId, getPersonaLanguage } = usePersonaStore() + const lang = getPersonaLanguage(activePersona) + const labels = + lang === 'id' + ? { sourceChunk: 'Potongan Sumber', sourceId: 'ID Sumber', order: 'Urutan' } + : { sourceChunk: 'Source Chunk', sourceId: 'Source ID', order: 'Order' } const { selectedSources } = useSourcesStore() const { getActiveContextModelName, getContextTypeLabel } = useContextAvailability() const { data: coursesData } = useCourses() @@ -140,6 +145,7 @@ export default function SummaryPage() { selectedModel: modelName, selectedSources: sourcesToUse, courseInfo, + language: getPersonaLanguage(activePersona), }), }) @@ -686,11 +692,14 @@ export default function SummaryPage() { }} >
- Source Chunk #{ref.chunkIndex + 1} + {labels.sourceChunk} #{ref.chunkIndex + 1} {ref.sourceId && ( - (Source ID: {ref.sourceId}, Order:{' '} - {ref.order}) + {'('} + {labels.sourceId}: {ref.sourceId} + {', '} + {labels.order}: {ref.order} + {')'} )}
@@ -834,10 +843,14 @@ export default function SummaryPage() { }} >
- Source Chunk #{ref.chunkIndex + 1} + {labels.sourceChunk} #{ref.chunkIndex + 1} {ref.sourceId && ( - (Source ID: {ref.sourceId}, Order: {ref.order}) + {'('} + {labels.sourceId}: {ref.sourceId} + {', '} + {labels.order}: {ref.order} + {')'} )}
diff --git a/frontend/src/app/api/assessment/download-docx/route.ts b/frontend/src/app/api/assessment/download-docx/route.ts index b805daf..06454b3 100644 --- a/frontend/src/app/api/assessment/download-docx/route.ts +++ b/frontend/src/app/api/assessment/download-docx/route.ts @@ -2,1547 +2,47 @@ // SPDX-License-Identifier: Apache-2.0 import { NextResponse, type NextRequest } from 'next/server' -import * as docx from 'docx' -import { AssessmentDocxContent } from '@/lib/types/assessment-types' +import { normalizeLanguage, type Lang } from '@/lib/utils/lang' +import { generateAssessmentDocx } from '@/lib/assessment/docx/builder' -interface Criterion { - name: string - weight: number - description?: string -} - -// Update the POST handler to extract course information from the request +// Slim API route: validates body and delegates to modular DOCX generator. export async function POST(request: NextRequest) { try { - // Parse the incoming request body - const { assessmentType, difficultyLevel, courseInfo } = await request.json() - - // Extract data from courseInfo + const body = await request.json() + const { courseInfo, difficultyLevel, language } = body || {} + const lang: Lang = normalizeLanguage(language) const { assessment, format, metadata } = courseInfo || {} - - // Check for null/undefined assessment if (!assessment) { - console.error('No assessment data found in courseInfo') return NextResponse.json({ error: 'No assessment data found in courseInfo' }, { status: 400 }) } - - // Log the parsed data for debugging - console.log('Parsed request data:', { - assessmentType, - difficultyLevel, - assessment, - format, - metadata, - }) - - // Debug log to check if we have explanation data - if (assessment?.exampleQuestions?.[0]?.explanation) { - console.log( - 'Explanation data found:', - typeof assessment.exampleQuestions[0].explanation, - Array.isArray(assessment.exampleQuestions[0].explanation?.criteria) - ? `Criteria count: ${assessment.exampleQuestions[0].explanation.criteria.length}` - : 'No criteria array found', - ) - - // Check for rubricLevels - if (assessment.exampleQuestions[0].explanation?.rubricLevels) { - console.log( - 'RubricLevels found:', - Array.isArray(assessment.exampleQuestions[0].explanation.rubricLevels) - ? `Levels count: ${assessment.exampleQuestions[0].explanation.rubricLevels.length}` - : 'RubricLevels is not an array', - ) - } else { - console.log('No rubricLevels found in explanation') - } - } else { - console.log('No explanation data found in assessment') - } - - console.log(`Generating Word document for assessment (${format} format):`, assessment.type) - - // Generate the Word document based on the requested format - const docBuffer = await generateAssessmentDocx({ - assessmentIdeas: [assessment], - difficultyLevel, - format: format || 'lecturer', // Default to lecturer format if not specified - metadata: metadata || { - courseCode: '', - courseName: '', - examTitle: assessment.type + ' Assessment', + const buffer = await generateAssessmentDocx( + { + assessmentIdeas: [assessment], + difficultyLevel, + format: format === 'student' ? 'student' : 'lecturer', + metadata: metadata || { + courseCode: '', + courseName: '', + examTitle: assessment.type + ' Assessment', + }, }, - }) - - console.log('Word document generated successfully') - console.log('Assessment type:', assessment.type) - - // Ensure assessment type is properly sanitized and used in the filename + lang, + ) const sanitizedAssessmentType = assessment.type ? assessment.type.replace(/[^a-z0-9]/gi, '_').toLowerCase() : 'assessment' - - // Debug the final generated filename const filename = `${sanitizedAssessmentType}_assessment_${format || 'lecturer'}.docx` - console.log('Generated filename:', filename) - - // Return the Word document as a downloadable response - return new NextResponse(new Uint8Array(docBuffer), { + return new NextResponse(new Uint8Array(buffer), { headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, }, }) } catch (error: unknown) { - console.error('Error generating Word document:', error) - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + const message = error instanceof Error ? error.message : 'Unknown error occurred' return NextResponse.json( - { error: 'Failed to generate Word document: ' + errorMessage }, + { error: 'Failed to generate Word document: ' + message }, { status: 500 }, ) } } - -// Add a function to process text with bold formatting -function processTextWithBold(text: string): { - text: string - hasBold: boolean - boldSegments: Array<{ text: string; bold: boolean }> -} { - if (!text) return { text: '', hasBold: false, boldSegments: [] } - - const boldPattern = /\*\*(.*?)\*\*/g - const hasBold = boldPattern.test(text) - - // Reset the regex lastIndex - boldPattern.lastIndex = 0 - - if (!hasBold) { - return { text, hasBold, boldSegments: [{ text, bold: false }] } - } - - const boldSegments: Array<{ text: string; bold: boolean }> = [] - let lastIndex = 0 - let match - - while ((match = boldPattern.exec(text)) !== null) { - // Add text before the bold part - if (match.index > lastIndex) { - boldSegments.push({ - text: text.substring(lastIndex, match.index), - bold: false, - }) - } - - // Add the bold part - boldSegments.push({ - text: match[1], - bold: true, - }) - - lastIndex = match.index + match[0].length - } - - // Add any remaining text after the last bold part - if (lastIndex < text.length) { - boldSegments.push({ - text: text.substring(lastIndex), - bold: false, - }) - } - - // Clean the original text by removing the asterisks - const cleanedText = text.replace(boldPattern, '$1') - - return { text: cleanedText, hasBold, boldSegments } -} - -// Helper function to create default rubric descriptions if none are provided -function createDefaultRubricDescriptions(criterionName: string) { - const name = criterionName.toLowerCase() - - return { - excellent: `Demonstrates exceptional ${name} with comprehensive understanding and flawless execution.`, - good: `Shows strong ${name} with minor areas for improvement.`, - average: `Demonstrates adequate ${name} meeting basic requirements.`, - acceptable: `Shows minimal acceptable ${name} with significant room for improvement.`, - poor: `Fails to demonstrate adequate ${name}, falling below minimum requirements.`, - } -} - -// Update the Word document generation to support student and lecturer formats -async function generateAssessmentDocx(content: AssessmentDocxContent): Promise { - try { - const assessment = content.assessmentIdeas[0] - const format = content.format || 'lecturer' - const metadata = content.metadata || { - courseCode: '', - courseName: '', - examTitle: assessment.type + ' Assessment', - } - - console.log( - `Generating DOCX with format: ${format}, isProjectType: ${assessment.type.toLowerCase().includes('project')}`, - ) - - const isStudentFormat = format === 'student' - const isProjectType = assessment.type.toLowerCase().includes('project') - - // Create document sections with mutable array - const children: (docx.Paragraph | docx.Table)[] = [] - - // Add header with "SULIT" marking - const header = new docx.Header({ - children: [ - new docx.Paragraph({ - text: 'SULIT', - alignment: docx.AlignmentType.RIGHT, - spacing: { after: 200 }, - }), - ], - }) - - // Add footer with "SULIT" marking and page number - const footer = new docx.Footer({ - children: [ - // SULIT on the left - new docx.Paragraph({ - children: [new docx.TextRun('SULIT')], - alignment: docx.AlignmentType.LEFT, - }), - // Page number in the center - new docx.Paragraph({ - children: [ - new docx.TextRun({ - children: [docx.PageNumber.CURRENT], - }), - ], - alignment: docx.AlignmentType.CENTER, - }), - ], - }) - - // Exam title and information section - children.push( - new docx.Paragraph({ - text: metadata.examTitle || assessment.type + ' Assessment', - alignment: docx.AlignmentType.CENTER, - spacing: { after: 200 }, - heading: docx.HeadingLevel.HEADING_1, - }), - ) - - // Course information - if (metadata.courseCode || metadata.courseName) { - children.push( - new docx.Paragraph({ - text: `${metadata.courseCode || ''} – ${metadata.courseName || ''}`, - alignment: docx.AlignmentType.CENTER, - spacing: { after: 400 }, - heading: docx.HeadingLevel.HEADING_2, - }), - ) - } - - // Special handling for project type - if (isProjectType) { - // Check if project description already contains metadata fields to avoid duplication - const projectDescription = assessment.exampleQuestions[0].question || '' - - // Check which metadata fields are already in the project description - const containsSemester = projectDescription.includes(metadata.semester || '') - const containsAcademicYear = projectDescription.includes(metadata.academicYear || '') - const containsDeadline = projectDescription.includes(metadata.deadline || '') - const containsGroupSize = new RegExp(`group.*?${metadata.groupSize || 4}`, 'i').test( - projectDescription, - ) - - // Add project-specific information that's not already in the description - children.push( - new docx.Paragraph({ - text: 'PROJECT INFORMATION', - heading: docx.HeadingLevel.HEADING_3, - spacing: { before: 200, after: 100 }, - }), - ) - - // Only add metadata that's not already in the project description - if (!containsSemester && metadata.semester) { - children.push( - new docx.Paragraph({ - children: [ - new docx.TextRun({ text: 'Semester: ', bold: true }), - new docx.TextRun({ text: metadata.semester }), - ], - spacing: { after: 100 }, - }), - ) - } - - if (!containsAcademicYear && metadata.academicYear) { - children.push( - new docx.Paragraph({ - children: [ - new docx.TextRun({ text: 'Academic Year: ', bold: true }), - new docx.TextRun({ text: metadata.academicYear }), - ], - spacing: { after: 100 }, - }), - ) - } - - if (!containsDeadline && metadata.deadline) { - children.push( - new docx.Paragraph({ - children: [ - new docx.TextRun({ text: 'Submission Deadline: ', bold: true }), - new docx.TextRun({ text: metadata.deadline }), - ], - spacing: { after: 100 }, - }), - ) - } - - if (!containsGroupSize && metadata.groupSize) { - children.push( - new docx.Paragraph({ - children: [ - new docx.TextRun({ text: 'Group Size: ', bold: true }), - new docx.TextRun({ text: `${metadata.groupSize} members per group` }), - ], - spacing: { after: 100 }, - }), - ) - } - - children.push( - new docx.Paragraph({ - children: [ - new docx.TextRun({ text: 'Duration: ', bold: true }), - new docx.TextRun({ text: metadata.projectDuration || assessment.duration }), - ], - spacing: { after: 200 }, - }), - ) - - // Project description - children.push( - new docx.Paragraph({ - text: 'PROJECT DESCRIPTION', - heading: docx.HeadingLevel.HEADING_3, - spacing: { before: 400, after: 200 }, - }), - ) - - // Split the question text into sections - const questionParts: string[] = assessment.exampleQuestions[0].question.split(/\n+/) - - let currentTitle = '' - let currentContent: docx.Paragraph[] = [] - - // Process each paragraph of the project description - questionParts.forEach((part) => { - const section = part.trim() - if (!section) return - - // Process bold formatting - const { hasBold, boldSegments } = processTextWithBold(section) - - const inlineHeaderContent = section.match(/^\*\*(.+?):\*\*\s*(.+)$/) - const sectionTitleOnly = section.match(/^\*\*(.+)\*\*$/) - const sectionTitleWithColon = section.match(/^\*\*(.+?):\*\*$/) - const isBullet = /^[ \t]*([-*+•])\s/.test(section) - const isNumbered = /^[ \t]*(\d+\.|[a-z]\.|[ivxlcdm]+\.|[IVXLCDM]+\.)\s/.test(section) - - function getIndentLevel(text: string) { - const leadingWhitespace = text.match(/^[ \t]*/)?.[0] || '' - const normalized = leadingWhitespace.replace(/\t/g, ' ') // Convert tabs to 4 spaces - return Math.floor(normalized.length / 4) - } - - // Push previous section if new section starts - const pushCurrentSection = () => { - if (currentContent.length > 0) { - if (currentTitle) { - children.push( - new docx.Paragraph({ - text: currentTitle, - heading: docx.HeadingLevel.HEADING_3, - spacing: { before: 300, after: 200 }, - }), - ) - } - currentContent.forEach((p) => children.push(p)) - currentContent = [] - } - } - - // Section header only - if (sectionTitleOnly || sectionTitleWithColon) { - pushCurrentSection() - currentTitle = (sectionTitleOnly?.[1] || sectionTitleWithColon?.[1] || '').trim() - return - } - - // Inline header with content (like **Project Title:** Intel Gaudi) - if (inlineHeaderContent) { - const header = inlineHeaderContent[1].trim() - const content = inlineHeaderContent[2].trim() - currentContent.push( - new docx.Paragraph({ - children: [ - new docx.TextRun({ text: `${header}: `, bold: true }), - new docx.TextRun({ text: content }), - ], - spacing: { after: 100 }, - }), - ) - return - } - - if (isBullet || isNumbered) { - const indentLevel = getIndentLevel(part) - currentContent.push( - new docx.Paragraph({ - children: hasBold - ? boldSegments.map( - (segment) => new docx.TextRun({ text: segment.text, bold: segment.bold }), - ) - : [new docx.TextRun(section)], - numbering: { - reference: isBullet ? 'bulletPoints' : 'projectPoints', - level: indentLevel, - }, - spacing: { after: 100 }, - }), - ) - return - } - // Regular paragraph - currentContent.push( - new docx.Paragraph({ - children: hasBold - ? boldSegments.map( - (segment) => new docx.TextRun({ text: segment.text, bold: segment.bold }), - ) - : [new docx.TextRun(section)], - spacing: { after: 200 }, - }), - ) - }) - - // Push remaining content - if (currentContent.length > 0) { - if (currentTitle) { - children.push( - new docx.Paragraph({ - text: currentTitle, - heading: docx.HeadingLevel.HEADING_3, - spacing: { before: 300, after: 200 }, - }), - ) - } - currentContent.forEach((p) => children.push(p)) - } - - // Add model answer for project type (lecturer format only) - if (!isStudentFormat && assessment.exampleQuestions[0].correctAnswer) { - const modelAnswer = assessment.exampleQuestions[0].correctAnswer - if (modelAnswer) { - // Add a page break before the model answer - children.push( - new docx.Paragraph({ - children: [new docx.PageBreak()], - }), - ) - - // Add model answer heading - children.push( - new docx.Paragraph({ - text: 'MODEL ANSWER/GUIDELINES', - heading: docx.HeadingLevel.HEADING_2, - spacing: { before: 400, after: 200 }, - alignment: docx.AlignmentType.CENTER, - }), - ) - - // Process the model answer text - const modelAnswerLines = modelAnswer.split('\n') - - modelAnswerLines.forEach((line: string) => { - // Process bold formatting - const { text, hasBold, boldSegments } = processTextWithBold(line) - - const cleanedLine = text - - // Check if this is a section header (looks like a heading) - if (/^[A-Z][\w\s&\-]*:?$/.test(cleanedLine.trim()) && cleanedLine.trim().length < 50) { - children.push( - new docx.Paragraph({ - text: cleanedLine.trim(), - heading: docx.HeadingLevel.HEADING_3, - spacing: { before: 300, after: 100 }, - }), - ) - } - // Check if this looks like code (indented or has special characters) - else if ( - line.startsWith(' ') || - line.startsWith('\t') || - line.includes('def ') || - line.includes('return ') || - line.includes('```') || - line.includes('import ') - ) { - // Skip code block markers - if (line.includes('```')) { - return - } - children.push( - new docx.Paragraph({ - text: line, - style: 'code', - spacing: { before: 200, after: 100 }, - }), - ) - } - // Regular paragraph with possible bold formatting - else if (line.trim()) { - if (hasBold) { - children.push( - new docx.Paragraph({ - children: boldSegments.map( - (segment) => - new docx.TextRun({ - text: segment.text, - bold: segment.bold, - }), - ), - spacing: { before: 200, after: 100 }, - }), - ) - } else { - children.push( - new docx.Paragraph({ - text: line.trim(), - spacing: { after: 100 }, - }), - ) - } - } - }) - } - } - - // Add grading rubrics for project type (lecturer format only) - if (!isStudentFormat) { - console.log('Adding grading rubrics for lecturer format') - - // Add a page break before the rubrics - children.push( - new docx.Paragraph({ - children: [new docx.PageBreak()], - }), - ) - - // Add rubrics heading - children.push( - new docx.Paragraph({ - text: 'GRADING RUBRICS', - heading: docx.HeadingLevel.HEADING_2, - spacing: { before: 400, after: 200 }, - alignment: docx.AlignmentType.CENTER, - }), - ) - - children.push( - new docx.Paragraph({ - text: 'Marking Scale: 1 - Poor, 2 - Acceptable, 3 - Average, 4 - Good, 5- Excellent.', - spacing: { after: 200 }, - }), - ) - - // Get the explanation object - const explanation = assessment.exampleQuestions[0].explanation - console.log('Explanation type:', typeof explanation) - - // Create default criteria if none exist - let criteria = [] - if (typeof explanation === 'object' && Array.isArray(explanation.criteria)) { - criteria = explanation.criteria - console.log(`Found ${criteria.length} criteria in explanation`) - } else { - // Create default criteria - console.log('Creating default criteria') - criteria = [ - { name: 'Report - Content', weight: 20, description: 'Quality and depth of content' }, - { - name: 'Report - Analysis', - weight: 15, - description: 'Critical analysis and insights', - }, - { name: 'Report - Structure', weight: 10, description: 'Organization and clarity' }, - { name: 'Report - References', weight: 10, description: 'Use of appropriate sources' }, - { name: 'Demo - Implementation', weight: 15, description: 'Quality of implementation' }, - { name: 'Demo - Presentation', weight: 15, description: 'Clarity and effectiveness' }, - { - name: 'Individual Contribution - Participation', - weight: 15, - description: 'Level of participation', - }, - ] - } - - // Group criteria by category - const reportCriteria: Criterion[] = criteria.filter( - (c): c is Criterion => - typeof c === 'object' && - c !== null && - 'name' in c && - typeof c.name === 'string' && - c.name.includes('Report'), - ) - - const demoCriteria: Criterion[] = criteria.filter( - (c): c is Criterion => - typeof c === 'object' && - c !== null && - 'name' in c && - typeof c.name === 'string' && - c.name.includes('Demo'), - ) - - const individualCriteria: Criterion[] = criteria.filter( - (c): c is Criterion => - typeof c === 'object' && - c !== null && - 'name' in c && - typeof c.name === 'string' && - c.name.includes('Individual'), - ) - console.log( - `Criteria breakdown: Report=${reportCriteria.length}, Demo=${demoCriteria.length}, Individual=${individualCriteria.length}`, - ) - - // Check if we have rubricLevels - let rubricLevels: Array<{ level: string; criteria: { [key: string]: string } }> = [] - if (typeof explanation === 'object' && Array.isArray(explanation.rubricLevels)) { - rubricLevels = explanation.rubricLevels - console.log(`Found ${rubricLevels.length} rubric levels`) - } - - // Add Report criteria table - if (reportCriteria.length > 0) { - children.push( - new docx.Paragraph({ - text: 'REPORT (55%)', - heading: docx.HeadingLevel.HEADING_3, - spacing: { before: 300, after: 200 }, - }), - ) - - // Create table for report criteria - const reportTable = new docx.Table({ - width: { - size: 100, - type: docx.WidthType.PERCENTAGE, - }, - rows: [ - // Header row - new docx.TableRow({ - children: [ - new docx.TableCell({ - children: [new docx.Paragraph({ text: 'Criteria', style: 'strongText' })], - width: { size: 20, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Excellent (5)\nA, A-', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Good (4)\nB+, B, B-', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Average (3)\nC+, C', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Acceptable (2)\nC-, D+', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Poor (1)\nD, D-, F', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - ], - }), - // Data rows for each criterion - ...reportCriteria.map((criterion) => { - const criterionName = criterion.name.replace('Report - ', '') - - // Find detailed descriptions for this criterion in rubricLevels if available - let excellentDesc = '', - goodDesc = '', - averageDesc = '', - acceptableDesc = '', - poorDesc = '' - - // Try to find descriptions in rubricLevels - if (rubricLevels.length > 0) { - for (const level of rubricLevels) { - if (level.level?.includes('Excellent') && level.criteria?.[criterion.name]) { - excellentDesc = level.criteria[criterion.name] - } else if (level.level?.includes('Good') && level.criteria?.[criterion.name]) { - goodDesc = level.criteria[criterion.name] - } else if ( - level.level?.includes('Average') && - level.criteria?.[criterion.name] - ) { - averageDesc = level.criteria[criterion.name] - } else if ( - level.level?.includes('Acceptable') && - level.criteria?.[criterion.name] - ) { - acceptableDesc = level.criteria[criterion.name] - } else if (level.level?.includes('Poor') && level.criteria?.[criterion.name]) { - poorDesc = level.criteria[criterion.name] - } - } - } - - // If no descriptions were found, create default ones - if (!excellentDesc && !goodDesc && !averageDesc && !acceptableDesc && !poorDesc) { - const defaults = createDefaultRubricDescriptions(criterionName) - excellentDesc = defaults.excellent - goodDesc = defaults.good - averageDesc = defaults.average - acceptableDesc = defaults.acceptable - poorDesc = defaults.poor - } - - return new docx.TableRow({ - children: [ - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: criterionName, style: 'strongText' }), - criterion.description - ? new docx.Paragraph({ - text: criterion.description, - style: 'criteriaDescription', - }) - : new docx.Paragraph(''), - new docx.Paragraph({ text: `(${criterion.weight}%)`, style: 'weightText' }), - ], - }), - new docx.TableCell({ - children: [new docx.Paragraph(excellentDesc || 'Excellent performance')], - }), - new docx.TableCell({ - children: [new docx.Paragraph(goodDesc || 'Good performance')], - }), - new docx.TableCell({ - children: [new docx.Paragraph(averageDesc || 'Average performance')], - }), - new docx.TableCell({ - children: [new docx.Paragraph(acceptableDesc || 'Acceptable performance')], - }), - new docx.TableCell({ - children: [new docx.Paragraph(poorDesc || 'Poor performance')], - }), - ], - }) - }), - ], - }) - - children.push(reportTable) - - // Add spacing after table - children.push( - new docx.Paragraph({ - text: '', - spacing: { after: 200 }, - }), - ) - } - - // Add Demo criteria table - if (demoCriteria.length > 0) { - children.push( - new docx.Paragraph({ - text: 'DEMO PRESENTATION (30%)', - heading: docx.HeadingLevel.HEADING_3, - spacing: { before: 300, after: 200 }, - }), - ) - - // Create table for demo criteria - const demoTable = new docx.Table({ - width: { - size: 100, - type: docx.WidthType.PERCENTAGE, - }, - rows: [ - // Header row - new docx.TableRow({ - children: [ - new docx.TableCell({ - children: [new docx.Paragraph({ text: 'Criteria', style: 'strongText' })], - width: { size: 20, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Excellent (5)\nA, A-', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Good (4)\nB+, B, B-', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Average (3)\nC+, C', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Acceptable (2)\nC-, D+', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Poor (1)\nD, D-, F', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - ], - }), - // Data rows for each criterion - ...demoCriteria.map((criterion: Criterion) => { - const criterionName = criterion.name.replace('Demo - ', '') - - // Find detailed descriptions for this criterion in rubricLevels if available - let excellentDesc = '', - goodDesc = '', - averageDesc = '', - acceptableDesc = '', - poorDesc = '' - - // Try to find descriptions in rubricLevels - if (rubricLevels.length > 0) { - for (const level of rubricLevels) { - if (level.level?.includes('Excellent') && level.criteria?.[criterion.name]) { - excellentDesc = level.criteria[criterion.name] - } else if (level.level?.includes('Good') && level.criteria?.[criterion.name]) { - goodDesc = level.criteria[criterion.name] - } else if ( - level.level?.includes('Average') && - level.criteria?.[criterion.name] - ) { - averageDesc = level.criteria[criterion.name] - } else if ( - level.level?.includes('Acceptable') && - level.criteria?.[criterion.name] - ) { - acceptableDesc = level.criteria[criterion.name] - } else if (level.level?.includes('Poor') && level.criteria?.[criterion.name]) { - poorDesc = level.criteria[criterion.name] - } - } - } - - // If no descriptions were found, create default ones - if (!excellentDesc && !goodDesc && !averageDesc && !acceptableDesc && !poorDesc) { - const defaults = createDefaultRubricDescriptions(criterionName) - excellentDesc = defaults.excellent - goodDesc = defaults.good - averageDesc = defaults.average - acceptableDesc = defaults.acceptable - poorDesc = defaults.poor - } - - return new docx.TableRow({ - children: [ - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: criterionName, style: 'strongText' }), - criterion.description - ? new docx.Paragraph({ - text: criterion.description, - style: 'criteriaDescription', - }) - : new docx.Paragraph(''), - new docx.Paragraph({ text: `(${criterion.weight}%)`, style: 'weightText' }), - ], - }), - new docx.TableCell({ - children: [new docx.Paragraph(excellentDesc || 'Excellent performance')], - }), - new docx.TableCell({ - children: [new docx.Paragraph(goodDesc || 'Good performance')], - }), - new docx.TableCell({ - children: [new docx.Paragraph(averageDesc || 'Average performance')], - }), - new docx.TableCell({ - children: [new docx.Paragraph(acceptableDesc || 'Acceptable performance')], - }), - new docx.TableCell({ - children: [new docx.Paragraph(poorDesc || 'Poor performance')], - }), - ], - }) - }), - ], - }) - - children.push(demoTable) - - // Add spacing after table - children.push( - new docx.Paragraph({ - text: '', - spacing: { after: 200 }, - }), - ) - } - - // Add Individual Contribution criteria table - if (individualCriteria.length > 0) { - children.push( - new docx.Paragraph({ - text: 'INDIVIDUAL CONTRIBUTION (15%)', - heading: docx.HeadingLevel.HEADING_3, - spacing: { before: 300, after: 200 }, - }), - ) - - // Create table for individual criteria - const individualTable = new docx.Table({ - width: { - size: 100, - type: docx.WidthType.PERCENTAGE, - }, - rows: [ - // Header row - new docx.TableRow({ - children: [ - new docx.TableCell({ - children: [new docx.Paragraph({ text: 'Criteria', style: 'strongText' })], - width: { size: 20, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Excellent (5)\nA, A-', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Good (4)\nB+, B, B-', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Average (3)\nC+, C', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Acceptable (2)\nC-, D+', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Poor (1)\nD, D-, F', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - ], - }), - // Data rows for each criterion - ...individualCriteria.map((criterion: Criterion) => { - const criterionName = criterion.name.replace('Individual Contribution - ', '') - - // Find detailed descriptions for this criterion in rubricLevels if available - let excellentDesc = '', - goodDesc = '', - averageDesc = '', - acceptableDesc = '', - poorDesc = '' - - // Try to find descriptions in rubricLevels - if (rubricLevels.length > 0) { - for (const level of rubricLevels) { - if (level.level?.includes('Excellent') && level.criteria?.[criterion.name]) { - excellentDesc = level.criteria[criterion.name] - } else if (level.level?.includes('Good') && level.criteria?.[criterion.name]) { - goodDesc = level.criteria[criterion.name] - } else if ( - level.level?.includes('Average') && - level.criteria?.[criterion.name] - ) { - averageDesc = level.criteria[criterion.name] - } else if ( - level.level?.includes('Acceptable') && - level.criteria?.[criterion.name] - ) { - acceptableDesc = level.criteria[criterion.name] - } else if (level.level?.includes('Poor') && level.criteria?.[criterion.name]) { - poorDesc = level.criteria[criterion.name] - } - } - } - - // If no descriptions were found, create default ones - if (!excellentDesc && !goodDesc && !averageDesc && !acceptableDesc && !poorDesc) { - const defaults = createDefaultRubricDescriptions(criterionName) - excellentDesc = defaults.excellent - goodDesc = defaults.good - averageDesc = defaults.average - acceptableDesc = defaults.acceptable - poorDesc = defaults.poor - } - - return new docx.TableRow({ - children: [ - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: criterionName, style: 'strongText' }), - criterion.description - ? new docx.Paragraph({ - text: criterion.description, - style: 'criteriaDescription', - }) - : new docx.Paragraph(''), - new docx.Paragraph({ text: `(${criterion.weight}%)`, style: 'weightText' }), - ], - }), - new docx.TableCell({ - children: [new docx.Paragraph(excellentDesc || 'Excellent performance')], - }), - new docx.TableCell({ - children: [new docx.Paragraph(goodDesc || 'Good performance')], - }), - new docx.TableCell({ - children: [new docx.Paragraph(averageDesc || 'Average performance')], - }), - new docx.TableCell({ - children: [new docx.Paragraph(acceptableDesc || 'Acceptable performance')], - }), - new docx.TableCell({ - children: [new docx.Paragraph(poorDesc || 'Poor performance')], - }), - ], - }) - }), - ], - }) - - children.push(individualTable) - } - - // If no criteria were found, add a default rubric table - if ( - reportCriteria.length === 0 && - demoCriteria.length === 0 && - individualCriteria.length === 0 - ) { - console.log('No criteria found, adding default rubric table') - - // Create default criteria - const defaultCriteria = [ - { name: 'Content Quality', weight: 25, description: 'Depth and accuracy of content' }, - { name: 'Implementation', weight: 25, description: 'Quality of implementation' }, - { name: 'Presentation', weight: 25, description: 'Clarity and organization' }, - { - name: 'Individual Contribution', - weight: 25, - description: 'Individual participation and contribution', - }, - ] - - // Create table for default criteria - const defaultTable = new docx.Table({ - width: { - size: 100, - type: docx.WidthType.PERCENTAGE, - }, - rows: [ - // Header row - new docx.TableRow({ - children: [ - new docx.TableCell({ - children: [new docx.Paragraph({ text: 'Criteria', style: 'strongText' })], - width: { size: 20, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Excellent (5)\nA, A-', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Good (4)\nB+, B, B-', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Average (3)\nC+, C', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Acceptable (2)\nC-, D+', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: 'Poor (1)\nD, D-, F', style: 'strongText' }), - ], - width: { size: 16, type: docx.WidthType.PERCENTAGE }, - }), - ], - }), - // Data rows for each criterion - ...defaultCriteria.map((criterion) => { - const defaults = createDefaultRubricDescriptions(criterion.name) - - return new docx.TableRow({ - children: [ - new docx.TableCell({ - children: [ - new docx.Paragraph({ text: criterion.name, style: 'strongText' }), - criterion.description - ? new docx.Paragraph({ - text: criterion.description, - style: 'criteriaDescription', - }) - : new docx.Paragraph(''), - new docx.Paragraph({ text: `(${criterion.weight}%)`, style: 'weightText' }), - ], - }), - new docx.TableCell({ - children: [new docx.Paragraph(defaults.excellent)], - }), - new docx.TableCell({ - children: [new docx.Paragraph(defaults.good)], - }), - new docx.TableCell({ - children: [new docx.Paragraph(defaults.average)], - }), - new docx.TableCell({ - children: [new docx.Paragraph(defaults.acceptable)], - }), - new docx.TableCell({ - children: [new docx.Paragraph(defaults.poor)], - }), - ], - }) - }), - ], - }) - - children.push(defaultTable) - } - } - } else { - // Regular assessment instructions - children.push( - new docx.Paragraph({ - text: 'Instructions: Please ensure that this examination paper is complete before you begin the examination.', - spacing: { after: 200 }, - }), - ) - - children.push( - new docx.Paragraph({ - text: `Instructions: Answer all ${assessment.exampleQuestions.length} questions.`, - spacing: { after: 400 }, - }), - ) - - children.push( - new docx.Paragraph({ - text: 'You may answer the questions either in English or in Bahasa Malaysia.', - spacing: { after: 200 }, - }), - ) - - children.push( - new docx.Paragraph({ - text: 'In the event of any discrepancies, the English version shall be used.', - spacing: { after: 400 }, - }), - ) - - // Add a page break after the instructions - children.push( - new docx.Paragraph({ - children: [new docx.PageBreak()], - }), - ) - - // Questions - for (let index = 0; index < assessment.exampleQuestions.length; index++) { - const question = assessment.exampleQuestions[index] - - // Question number and text - children.push( - new docx.Paragraph({ - text: `${index + 1}.`, - spacing: { before: 400, after: 200 }, - }), - ) - - // Split the question text into parts if it contains sub-questions - const questionParts = question.question.split(/$$[a-z]$$|$$[ivx]+$$/g) - - if (questionParts.length > 1) { - // Main question text - children.push( - new docx.Paragraph({ - text: questionParts[0].trim(), - spacing: { after: 200 }, - }), - ) - - // Extract the sub-question labels - const subQuestionLabels = [] - const labelRegex = /$$([a-z]|[ivx]+)$$/g - let match - while ((match = labelRegex.exec(question.question)) !== null) { - subQuestionLabels.push(match[1]) - } - - // Sub-questions - for (let i = 1; i < questionParts.length; i++) { - if (questionParts[i] && questionParts[i].trim()) { - const label = - i - 1 < subQuestionLabels.length - ? subQuestionLabels[i - 1] - : String.fromCharCode(96 + i) - children.push( - new docx.Paragraph({ - children: [ - new docx.TextRun({ text: `(${label}) `, bold: true }), - new docx.TextRun({ text: questionParts[i].trim() }), - ], - indent: { left: 720 }, // 0.5 inch indent - spacing: { after: 200 }, - }), - ) - } - } - } else { - // Simple question without sub-parts - children.push( - new docx.Paragraph({ - text: question.question, - spacing: { after: 200 }, - }), - ) - } - - // Only include answers and marking criteria in lecturer format - if (!isStudentFormat) { - // Correct/Model Answer - if (question.correctAnswer) { - // Clean up model answer if it's in JSON format - let modelAnswer = question.correctAnswer - - // Check if the answer looks like JSON - if ( - (modelAnswer.trim().startsWith('{') && modelAnswer.trim().endsWith('}')) || - modelAnswer.includes('"modelAnswer"') - ) { - try { - // Try to parse it as JSON - const parsed = JSON.parse(modelAnswer) - if (parsed.modelAnswer) { - modelAnswer = parsed.modelAnswer - } - } catch { - // If parsing fails, try to extract with regex - const match = modelAnswer.match(/"modelAnswer"\s*:\s*"([\s\S]*?)"/) - if (match && match[1]) { - modelAnswer = match[1].replace(/\\"/g, '"') - } - } - } - - children.push( - new docx.Paragraph({ - text: 'Model Answer:', - heading: docx.HeadingLevel.HEADING_4, - spacing: { before: 200, after: 100 }, - }), - ) - - children.push( - new docx.Paragraph({ - text: modelAnswer, - spacing: { after: 200 }, - }), - ) - } - - // Explanation/Grading Criteria - if (question.explanation) { - children.push( - new docx.Paragraph({ - text: 'Marking Criteria:', - heading: docx.HeadingLevel.HEADING_4, - spacing: { before: 200, after: 100 }, - }), - ) - - if (typeof question.explanation === 'string') { - // Handle string explanation - children.push( - new docx.Paragraph({ - text: question.explanation, - spacing: { after: 200 }, - }), - ) - } else if (typeof question.explanation === 'object') { - // Handle criteria as paragraphs - if ( - Array.isArray(question.explanation.criteria) && - question.explanation.criteria.length > 0 - ) { - children.push( - new docx.Paragraph({ - text: 'Criteria:', - style: 'strongText', - spacing: { before: 100, after: 100 }, - }), - ) - - question.explanation.criteria.forEach( - (criterion: { name: string; weight: number; description?: string } | string) => { - if (typeof criterion === 'object' && criterion.name) { - children.push( - new docx.Paragraph({ - children: [ - new docx.TextRun({ text: criterion.name || 'Criterion', bold: true }), - new docx.TextRun({ text: ` (${criterion.weight || 0}%)` }), - ], - spacing: { after: 100 }, - }), - ) - - if (criterion.description) { - children.push( - new docx.Paragraph({ - text: criterion.description, - spacing: { after: 200 }, - indent: { left: 720 }, - }), - ) - } - } else if (typeof criterion === 'string') { - children.push( - new docx.Paragraph({ - text: criterion, - spacing: { after: 100 }, - }), - ) - } - }, - ) - } - - // Handle mark allocation as paragraphs - if ( - Array.isArray(question.explanation.markAllocation) && - question.explanation.markAllocation.length > 0 - ) { - children.push( - new docx.Paragraph({ - text: 'Mark Allocation:', - style: 'strongText', - spacing: { before: 200, after: 100 }, - }), - ) - - question.explanation.markAllocation.forEach( - (item: { component: string; marks: number; description?: string }) => { - children.push( - new docx.Paragraph({ - children: [ - new docx.TextRun({ text: item.component || 'Component', bold: true }), - new docx.TextRun({ text: ` (${item.marks || 0} marks)` }), - ], - spacing: { after: 100 }, - }), - ) - - if (item.description) { - children.push( - new docx.Paragraph({ - text: item.description, - spacing: { after: 200 }, - indent: { left: 720 }, - }), - ) - } - }, - ) - } - } - } - } - - // Add a page break after every question (except the last one) - if (index < assessment.exampleQuestions.length - 1) { - children.push( - new docx.Paragraph({ - children: [new docx.PageBreak()], - }), - ) - } - } - } - - // Create the document - const doc = new docx.Document({ - sections: [ - { - properties: { - page: { - margin: { - top: 1000, - right: 1000, - bottom: 1000, - left: 1000, - }, - }, - }, - headers: { - default: header, - }, - footers: { - default: footer, - }, - children: children, - }, - ], - styles: { - paragraphStyles: [ - { - id: 'footer', - name: 'Footer', - run: { - size: 20, - color: '666666', - }, - }, - { - id: 'strongText', - name: 'Strong Text', - run: { - bold: true, - }, - }, - { - id: 'criteriaDescription', - name: 'Criteria Description', - run: { - size: 20, - color: '1a56db', - italics: true, - }, - }, - { - id: 'weightText', - name: 'Weight Text', - run: { - size: 18, - color: '666666', - italics: true, - }, - }, - { - id: 'code', - name: 'Code', - run: { - font: 'Courier New', - size: 20, - }, - paragraph: { - spacing: { before: 40, after: 40 }, - indent: { left: 720 }, - shading: { - type: docx.ShadingType.SOLID, - color: 'F5F5F5', - }, - }, - }, - ], - }, - numbering: { - config: [ - { - reference: 'projectPoints', - levels: Array.from({ length: 5 }, (_, i) => ({ - level: i, - format: 'decimal', - text: '', // e.g., "1.", "1.1.", etc. - alignment: 'start', - style: { - paragraph: { - indent: { left: 240 * (i + 1), hanging: 120 }, - }, - }, - })), - }, - { - reference: 'bulletPoints', - levels: Array.from({ length: 5 }, (_, i) => ({ - level: i, - format: docx.LevelFormat.BULLET, - text: '', - alignment: 'start', - style: { - paragraph: { - indent: { left: 240 * (i + 1), hanging: 120 }, - }, - }, - })), - }, - ], - }, - }) - - // Generate buffer - return docx.Packer.toBuffer(doc) - } catch (error: unknown) { - console.error('Error generating Word document:', error) - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' - throw new Error('Failed to generate Word document: ' + errorMessage) - } -} diff --git a/frontend/src/app/api/assessment/download-pdf/route.ts b/frontend/src/app/api/assessment/download-pdf/route.ts index 49ae97c..a7b98b1 100644 --- a/frontend/src/app/api/assessment/download-pdf/route.ts +++ b/frontend/src/app/api/assessment/download-pdf/route.ts @@ -2,15 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 import { NextResponse, type NextRequest } from 'next/server' -import jsPDF from 'jspdf' -import autoTable from 'jspdf-autotable' -import type { AssessmentIdea, AssessmentDocxContent } from '@/lib/types/assessment-types' +import { normalizeLanguage, type Lang } from '@/lib/utils/lang' +import { generateAssessmentPDF, type PdfGenerationOptions } from '@/lib/assessment/pdf' -// Update the POST handler to extract course information from the request export async function POST(request: NextRequest) { try { // Parse the incoming request body - const { assessmentType, difficultyLevel, courseInfo } = await request.json() + const { assessmentType, difficultyLevel, courseInfo, language } = await request.json() + + // Validate and normalize the language parameter + const lang: Lang = normalizeLanguage(language) // Extract data from courseInfo const { assessment, format, metadata } = courseInfo || {} @@ -26,18 +27,21 @@ export async function POST(request: NextRequest) { console.log(`Generating PDF for assessment (${format} format):`, assessment.type) - // Generate the PDF file based on the requested format - const pdfBuffer = await generatePDF( + // Generate the PDF file using the modular system + const options: PdfGenerationOptions = { assessment, assessmentType, difficultyLevel, - format || 'lecturer', // Default to lecturer format if not specified - metadata || { + format: format || 'lecturer', + metadata: metadata || { courseCode: '', courseName: '', examTitle: assessment.type + ' Assessment', }, - ) + language: lang, + } + + const pdfBuffer = await generateAssessmentPDF(options) if (!pdfBuffer || !(pdfBuffer instanceof Buffer)) { console.error('Invalid PDF buffer returned:', typeof pdfBuffer) @@ -46,17 +50,19 @@ export async function POST(request: NextRequest) { console.log('PDF generated successfully, buffer size:', pdfBuffer.length) - // Return the PDF file as a downloadable response - return new NextResponse(new Uint8Array(pdfBuffer), { + // Create response with appropriate headers for PDF download + const response = new NextResponse(new Uint8Array(pdfBuffer), { + status: 200, headers: { 'Content-Type': 'application/pdf', - 'Content-Disposition': `attachment; filename="${encodeURIComponent( - assessment.type.replace(/[^a-z0-9]/gi, '_').toLowerCase(), - )}_assessment_${format || 'lecturer'}.pdf"`, + 'Content-Disposition': `attachment; filename="${assessment.type}-assessment.pdf"`, + 'Content-Length': pdfBuffer.length.toString(), }, }) - } catch (error: unknown) { - console.error('Error generating PDF:', error) + + return response + } catch (error) { + console.error('Error in PDF generation endpoint:', error) const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' return NextResponse.json( { error: 'Failed to generate PDF document: ' + errorMessage }, @@ -64,771 +70,3 @@ export async function POST(request: NextRequest) { ) } } - -async function generatePDF( - assessment: AssessmentIdea, - assessmentType: string, - difficultyLevel: string, - format: string, - metadata: AssessmentDocxContent['metadata'], -): Promise { - metadata = metadata || { - courseCode: '', - courseName: '', - examTitle: assessment.type + ' Assessment', - } - - // Create a new jsPDF instance (A4 size in portrait orientation) - const pdf = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: 'a4', - }) - - // Add custom font if needed - pdf.setFont('helvetica') - - // Define page dimensions and margins (in mm) - const pageWidth = 210 - const pageHeight = 297 - const margin = 20 - const contentWidth = pageWidth - margin * 2 - - // Define standard font sizes - const FONT_SIZE_STANDARD = 12 - const FONT_SIZE_TITLE = 14 - const FONT_SIZE_SUBTITLE = 12 - const FONT_SIZE_RUBRIC_TITLE = 16 - const FONT_SIZE_RUBRIC_SECTION = 14 - const FONT_SIZE_RUBRIC_CONTENT = 10 - - const addHeader = () => { - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setTextColor(0, 0, 0) - pdf.text('SULIT', pageWidth - margin - 10, 10) - } - - // Generate HTML content based on the requested format - const isStudentFormat = format === 'student' - const isProjectType = assessment.type.toLowerCase().includes('project') - - // Add header to first page - addHeader() - - // Add title and course information with proper styling - pdf.setFontSize(FONT_SIZE_TITLE) - pdf.setFont('helvetica', 'bold') - const title = metadata.examTitle || assessment.type + ' Assessment' - pdf.text(title, pageWidth / 2, margin, { align: 'center' }) - - pdf.setFontSize(FONT_SIZE_SUBTITLE) - pdf.setFont('helvetica', 'bold') - pdf.text( - `${metadata.courseCode || ''} – ${metadata.courseName || ''}`, - pageWidth / 2, - margin + 10, - { - align: 'center', - }, - ) - - let yPosition = margin + 20 - - // Special handling for project type - if (isProjectType) { - // Project description - const projectDescription = assessment.exampleQuestions[0].question || '' - - // Check which metadata fields are already in the project description - const containsSemester = projectDescription.includes(metadata.semester || '') - const containsAcademicYear = projectDescription.includes(metadata.academicYear || '') - const containsDeadline = projectDescription.includes(metadata.deadline || '') - const containsGroupSize = new RegExp(`group.*?${metadata.groupSize || 4}`, 'i').test( - projectDescription, - ) - - // Add project-specific information - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setFont('helvetica', 'normal') - pdf.text( - `Duration: ${metadata.projectDuration || assessment.duration}`, - pageWidth / 2, - yPosition, - { - align: 'center', - }, - ) - yPosition += 10 - - // Add project info - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setFont('helvetica', 'normal') - - let infoYPosition = yPosition - - if (!containsSemester && metadata.semester) { - pdf.text(`Semester: ${metadata.semester}`, margin, infoYPosition) - infoYPosition += 6 - } - - if (!containsAcademicYear && metadata.academicYear) { - pdf.text(`Academic Year: ${metadata.academicYear}`, margin, infoYPosition) - infoYPosition += 6 - } - - if (!containsDeadline && metadata.deadline) { - pdf.text(`Submission Deadline: ${metadata.deadline}`, margin, infoYPosition) - infoYPosition += 6 - } - - if (!containsGroupSize && metadata.groupSize) { - pdf.text(`Group Size: ${metadata.groupSize} members per group`, margin, infoYPosition) - infoYPosition += 6 - } - - yPosition = infoYPosition + 10 - - // Process project description - pdf.setFontSize(FONT_SIZE_STANDARD) - - // Split project description into sections - const sections = projectDescription.split('\n') - const lineHeight = 6 - const marginLeft = margin - const maxWidth = contentWidth - - for (const section of sections) { - const lines = section.split('\n') - - for (let rawLine of lines) { - const x = marginLeft - let indentOffset = 0 - let bulletSymbol = '' - - const bulletMatch = rawLine.match(/^(\s*)(([*\-+])|(\d+\.))\s+(.*)/) - - if (bulletMatch) { - const indentStr = bulletMatch[1] - - // Count tab/space-based indentation - let spaceCount = 0 - for (const char of indentStr) { - spaceCount += char === '\t' ? 4 : 1 - } - - const indentLevel = Math.floor(spaceCount / 4) - indentOffset = indentLevel * 10 - - // Determine symbol - const unorderedBullet = bulletMatch[3] - const numberedBullet = bulletMatch[4] - const bulletText = bulletMatch[5] - - bulletSymbol = unorderedBullet ? '• ' : numberedBullet + ' ' - - rawLine = bulletSymbol + bulletText - } - - let localX = x + indentOffset - - const parts = rawLine.split('**') - - for (let i = 0; i < parts.length; i++) { - const chunk = parts[i] - if (!chunk) continue - - const fontStyle = i % 2 === 0 ? 'normal' : 'bold' - pdf.setFont('helvetica', fontStyle) - - let remainingText = chunk - - while (remainingText.length > 0) { - const availableWidth = maxWidth - (localX - marginLeft) - const [linePart] = pdf.splitTextToSize(remainingText, availableWidth) - - if (yPosition + lineHeight > pageHeight - margin) { - pdf.addPage() - yPosition = margin - addHeader() - } - - // Draw line at correct x position - pdf.text(linePart, localX, yPosition) - - remainingText = remainingText.slice(linePart.length).trim() - - // Wraps to next line - if (remainingText.length > 0) { - yPosition += lineHeight - localX = x + indentOffset - } else { - localX += pdf.getTextWidth(linePart) - } - } - } - - yPosition += lineHeight - } - } - - // Add model answer if in lecturer format - if (!isStudentFormat && assessment.exampleQuestions[0].correctAnswer) { - const modelAnswer = cleanModelAnswer(assessment.exampleQuestions[0].correctAnswer) - - // Add page break before model answer - pdf.addPage() - yPosition = margin - addHeader() - - // Add model answer title - - // Set base font - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setFont('helvetica', 'normal') - - const answerSections = modelAnswer.split('\n') - - for (const rawSection of answerSections) { - let rawLine = rawSection - const x = margin - let indentOffset = 0 - let bulletSymbol = '' - - const bulletMatch = rawLine.match(/^(\s*)(([*\-+])|(\d+\.))\s+(.*)/) - - if (bulletMatch) { - const indentStr = bulletMatch[1] - - // Calculate indentation width - let spaceCount = 0 - for (const char of indentStr) { - spaceCount += char === '\t' ? 4 : 1 - } - - const indentLevel = Math.floor(spaceCount / 4) - indentOffset = indentLevel * 10 - - // Detect bullet type - const unorderedBullet = bulletMatch[3] - const numberedBullet = bulletMatch[4] - const bulletText = bulletMatch[5] - - bulletSymbol = unorderedBullet ? '• ' : numberedBullet + ' ' - rawLine = bulletSymbol + bulletText - } - - // Text with bold segments - let localX = x + indentOffset - const parts = rawLine.split('**') - - for (let i = 0; i < parts.length; i++) { - const chunk = parts[i] - if (!chunk) continue - - const fontStyle = i % 2 === 0 ? 'normal' : 'bold' - pdf.setFont('helvetica', fontStyle) - - let remainingText = chunk - - while (remainingText.length > 0) { - const availableWidth = contentWidth - (localX - margin) - const [linePart] = pdf.splitTextToSize(remainingText, availableWidth) - - if (yPosition + lineHeight > pageHeight - margin) { - pdf.addPage() - yPosition = margin - addHeader() - } - - // Render line - pdf.text(linePart, localX, yPosition) - - remainingText = remainingText.slice(linePart.length).trim() - - if (remainingText.length > 0) { - yPosition += lineHeight - localX = x + indentOffset // maintain indent for wrapped lines - } else { - localX += pdf.getTextWidth(linePart) - } - } - } - - yPosition += lineHeight - } - } - - // Add grading rubrics if in lecturer format - if (!isStudentFormat && assessment.exampleQuestions[0].explanation) { - // Add page break before rubrics - pdf.addPage() - yPosition = margin - addHeader() - - // Add rubrics title with centered, bold styling - pdf.setFontSize(FONT_SIZE_RUBRIC_TITLE) - pdf.setFont('helvetica', 'bold') - pdf.text('GRADING RUBRICS', pageWidth / 2, yPosition, { align: 'center' }) - yPosition += 10 - - // Add marking scale with normal font - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setFont('helvetica', 'normal') - pdf.text( - 'Marking Scale: 1 - Poor, 2 - Acceptable, 3 - Average, 4 - Good, 5- Excellent.', - margin, - yPosition, - ) - yPosition += 10 - - const explanation = assessment.exampleQuestions[0].explanation - if (typeof explanation === 'object' && Array.isArray(explanation.criteria)) { - // Group criteria by category - const reportCriteria = explanation.criteria.filter( - (c): c is { name: string; weight: number; description?: string } => - typeof c === 'object' && - c !== null && - 'name' in c && - typeof c.name === 'string' && - c.name.includes('Report'), - ) - const demoCriteria = explanation.criteria.filter( - (c): c is { name: string; weight: number; description?: string } => - typeof c === 'object' && - c !== null && - 'name' in c && - typeof c.name === 'string' && - c.name.includes('Demo'), - ) - const individualCriteria = explanation.criteria.filter( - (c): c is { name: string; weight: number; description?: string } => - typeof c === 'object' && - c !== null && - 'name' in c && - typeof c.name === 'string' && - c.name.includes('Individual'), - ) - // Add Report criteria table - if (reportCriteria.length > 0) { - // Add section title with bold font - pdf.setFontSize(FONT_SIZE_RUBRIC_SECTION) - pdf.setFont('helvetica', 'bold') - pdf.text('REPORT (55%)', margin, yPosition) - yPosition += 8 - - // Create table data - const tableHead = [ - [ - 'Criteria', - 'Excellent (5)\nA, A-', - 'Good (4)\nB+, B, B-', - 'Average (3)\nC+, C', - 'Acceptable (2)\nC-, D+', - 'Poor (1)\nD, D-, F', - ], - ] - - const tableBody = reportCriteria.map((criterion) => { - const criterionName = criterion.name.replace('Report - ', '') - return [ - `${criterionName}\n(${criterion.weight}%)`, - 'Demonstrates exceptional performance.', - 'Shows strong performance with minor areas for improvement.', - 'Demonstrates adequate performance meeting basic requirements.', - 'Shows minimal acceptable performance with significant room for improvement.', - 'Fails to demonstrate adequate performance, falling below minimum requirements.', - ] - }) - - // Add table to PDF with improved styling - autoTable(pdf, { - head: tableHead, - body: tableBody, - startY: yPosition, - margin: { left: margin, right: margin }, - styles: { - overflow: 'linebreak', - cellPadding: 3, - fontSize: FONT_SIZE_RUBRIC_CONTENT, - font: 'helvetica', - }, - headStyles: { - fillColor: [240, 240, 240], - textColor: [0, 0, 0], - fontStyle: 'bold', - fontSize: FONT_SIZE_RUBRIC_CONTENT, - }, - bodyStyles: { - fillColor: [255, 255, 255], // White background for body rows - textColor: [0, 0, 0], - fontSize: FONT_SIZE_RUBRIC_CONTENT, - }, - columnStyles: { 0: { fontStyle: 'bold', cellWidth: 40 } }, - didDrawPage: () => { - addHeader() - }, - }) - - // Get the final Y position after the table - const finalY = (pdf as unknown as { lastAutoTable: { finalY: number } }).lastAutoTable - .finalY - yPosition = finalY + 10 - - // Check if we need a new page - if (yPosition > pageHeight - margin - 40) { - pdf.addPage() - yPosition = margin - addHeader() - } - } - - // Add Demo criteria table - if (demoCriteria.length > 0) { - // Add section title with bold font - pdf.setFontSize(FONT_SIZE_RUBRIC_SECTION) - pdf.setFont('helvetica', 'bold') - pdf.text('DEMO PRESENTATION (30%)', margin, yPosition) - yPosition += 8 - - // Create table data - const tableHead = [ - [ - 'Criteria', - 'Excellent (5)\nA, A-', - 'Good (4)\nB+, B, B-', - 'Average (3)\nC+, C', - 'Acceptable (2)\nC-, D+', - 'Poor (1)\nD, D-, F', - ], - ] - - const tableBody = demoCriteria.map((criterion) => { - const criterionName = criterion.name.replace('Demo - ', '') - return [ - `${criterionName}\n(${criterion.weight}%)`, - 'Demonstrates exceptional performance.', - 'Shows strong performance with minor areas for improvement.', - 'Demonstrates adequate performance meeting basic requirements.', - 'Shows minimal acceptable performance with significant room for improvement.', - 'Fails to demonstrate adequate performance, falling below minimum requirements.', - ] - }) - - // Add table to PDF with improved styling - autoTable(pdf, { - head: tableHead, - body: tableBody, - startY: yPosition, - margin: { left: margin, right: margin }, - styles: { - overflow: 'linebreak', - cellPadding: 3, - fontSize: FONT_SIZE_RUBRIC_CONTENT, - font: 'helvetica', - }, - headStyles: { - fillColor: [240, 240, 240], - textColor: [0, 0, 0], - fontStyle: 'bold', - fontSize: FONT_SIZE_RUBRIC_CONTENT, - }, - bodyStyles: { - fillColor: [255, 255, 255], // White background for body rows - textColor: [0, 0, 0], - fontSize: FONT_SIZE_RUBRIC_CONTENT, - }, - columnStyles: { 0: { fontStyle: 'bold', cellWidth: 40 } }, - didDrawPage: () => { - addHeader() - }, - }) - - // Get the final Y position after the table - const finalY = (pdf as unknown as { lastAutoTable: { finalY: number } }).lastAutoTable - .finalY - yPosition = finalY + 10 - - // Check if we need a new page - if (yPosition > pageHeight - margin - 40) { - pdf.addPage() - yPosition = margin - addHeader() - } - } - - // Add Individual Contribution criteria table - if (individualCriteria.length > 0) { - // Add section title with bold font - pdf.setFontSize(FONT_SIZE_RUBRIC_SECTION) - pdf.setFont('helvetica', 'bold') - pdf.text('INDIVIDUAL CONTRIBUTION (15%)', margin, yPosition) - yPosition += 8 - - // Create table data - const tableHead = [ - [ - 'Criteria', - 'Excellent (5)\nA, A-', - 'Good (4)\nB+, B, B-', - 'Average (3)\nC+, C', - 'Acceptable (2)\nC-, D+', - 'Poor (1)\nD, D-, F', - ], - ] - - const tableBody = individualCriteria.map((criterion) => { - const criterionName = criterion.name.replace('Individual Contribution - ', '') - return [ - `${criterionName}\n(${criterion.weight}%)`, - 'Demonstrates exceptional performance.', - 'Shows strong performance with minor areas for improvement.', - 'Demonstrates adequate performance meeting basic requirements.', - 'Shows minimal acceptable performance with significant room for improvement.', - 'Fails to demonstrate adequate performance, falling below minimum requirements.', - ] - }) - - // Add table to PDF with improved styling - autoTable(pdf, { - head: tableHead, - body: tableBody, - startY: yPosition, - margin: { left: margin, right: margin }, - styles: { - overflow: 'linebreak', - cellPadding: 3, - fontSize: FONT_SIZE_RUBRIC_CONTENT, - font: 'helvetica', - }, - headStyles: { - fillColor: [240, 240, 240], - textColor: [0, 0, 0], - fontStyle: 'bold', - fontSize: FONT_SIZE_RUBRIC_CONTENT, - }, - bodyStyles: { - fillColor: [255, 255, 255], // White background for body rows - textColor: [0, 0, 0], - fontSize: FONT_SIZE_RUBRIC_CONTENT, - }, - columnStyles: { 0: { fontStyle: 'bold', cellWidth: 40 } }, - didDrawPage: () => { - addHeader() - }, - }) - } - } - } - } else { - // Regular assessment template (non-project) - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setFont('helvetica', 'normal') - pdf.text(`Duration: ${assessment.duration}`, pageWidth / 2, yPosition, { align: 'center' }) - yPosition += 10 - - // Add instructions - pdf.text( - 'Please ensure that this examination paper is complete before you begin the examination.', - margin, - yPosition, - ) - yPosition += 6 - pdf.text( - `Instructions: Answer all ${assessment.exampleQuestions.length} questions.`, - margin, - yPosition, - ) - yPosition += 6 - pdf.text( - 'You may answer the questions either in English or in Bahasa Malaysia.', - margin, - yPosition, - ) - yPosition += 6 - pdf.text( - 'In the event of any discrepancies, the English version shall be used.', - margin, - yPosition, - ) - yPosition += 10 - - // Add page break after instructions - pdf.addPage() - yPosition = margin - addHeader() - - // Process each question - for (let i = 0; i < assessment.exampleQuestions.length; i++) { - const question = assessment.exampleQuestions[i] - - // Add question number with bold font - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setFont('helvetica', 'bold') - pdf.text(`${i + 1}.`, margin, yPosition) - yPosition += 6 - - // Add question text with normal font - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setFont('helvetica', 'normal') - const questionLines = pdf.splitTextToSize(question.question, contentWidth - 10) - - // Check if we need a new page - if (yPosition + questionLines.length * 6 > pageHeight - margin) { - pdf.addPage() - yPosition = margin - addHeader() - } - - pdf.text(questionLines, margin + 10, yPosition) - yPosition += questionLines.length * 6 + 5 - - // Add options if available - if (question.options && question.options.length > 0) { - yPosition += 5 - pdf.setFont('helvetica', 'bold') - pdf.text('Options:', margin + 10, yPosition) - yPosition += 6 - pdf.setFont('helvetica', 'normal') - - for (let j = 0; j < question.options.length; j++) { - const optionLines = pdf.splitTextToSize(question.options[j], contentWidth - 20) - - // Check if we need a new page - if (yPosition + optionLines.length * 6 > pageHeight - margin) { - pdf.addPage() - yPosition = margin - addHeader() - } - - pdf.text(`${String.fromCharCode(65 + j)}.`, margin + 10, yPosition) - pdf.text(optionLines, margin + 20, yPosition) - yPosition += optionLines.length * 6 - } - } - - // Add model answer if in lecturer format - if (!isStudentFormat && question.correctAnswer) { - const modelAnswer = cleanModelAnswer(question.correctAnswer) - - yPosition += 10 - pdf.setFont('helvetica', 'bold') - pdf.text('Model Answer:', margin + 10, yPosition) - yPosition += 6 - pdf.setFont('helvetica', 'normal') - - const answerLines = pdf.splitTextToSize(modelAnswer, contentWidth - 20) - - // Check if we need a new page - if (yPosition + answerLines.length * 6 > pageHeight - margin) { - pdf.addPage() - yPosition = margin - addHeader() - } - - pdf.text(answerLines, margin + 10, yPosition) - yPosition += answerLines.length * 6 - } - - // Add marking criteria if in lecturer format - if (!isStudentFormat && question.explanation) { - yPosition += 10 - pdf.setFont('helvetica', 'bold') - pdf.text('Marking Criteria:', margin + 10, yPosition) - yPosition += 6 - pdf.setFont('helvetica', 'normal') - - let explanationText = '' - - if (typeof question.explanation === 'string') { - explanationText = question.explanation - } else if (typeof question.explanation === 'object') { - // Format criteria - if (Array.isArray(question.explanation.criteria)) { - explanationText += 'Criteria:\n' - for (const criterion of question.explanation.criteria) { - if (typeof criterion === 'object' && criterion !== null && 'name' in criterion) { - explanationText += `- ${criterion.name} (${criterion.weight}%): ${criterion.description || ''}\n` - } else if (typeof criterion === 'string') { - explanationText += `- ${criterion}\n` - } - } - } - - // Format mark allocation - if (Array.isArray(question.explanation.markAllocation)) { - explanationText += '\nMark Allocation:\n' - for (const item of question.explanation.markAllocation) { - explanationText += `- ${item.component} (${item.marks} marks): ${item.description || ''}\n` - } - } - } - - const explanationLines = pdf.splitTextToSize(explanationText, contentWidth - 20) - - // Check if we need a new page - if (yPosition + explanationLines.length * 6 > pageHeight - margin) { - pdf.addPage() - yPosition = margin - addHeader() - } - - pdf.text(explanationLines, margin + 10, yPosition) - yPosition += explanationLines.length * 6 - } - - // Add page break after each question except the last one - if (i < assessment.exampleQuestions.length - 1) { - pdf.addPage() - yPosition = margin - addHeader() - } - } - } - - // Count total pages - const totalPages = pdf.getNumberOfPages() - - // Add footers to all pages with correct page numbers - const addFooter = (pageNum: number, totalPages: number) => { - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setTextColor(0, 0, 0) - pdf.text('SULIT', margin, pageHeight - 10) - pdf.text(`Page ${pageNum} of ${totalPages}`, pageWidth - margin - 30, pageHeight - 10) - } - - for (let i = 1; i <= totalPages; i++) { - pdf.setPage(i) - addFooter(i, totalPages) - } - - // Convert the PDF to a Buffer - const pdfBuffer = Buffer.from(pdf.output('arraybuffer')) - return pdfBuffer -} - -// Helper function to clean model answer if it's in JSON format -function cleanModelAnswer(answer: string | undefined): string { - if (!answer) return '' - - // Check if the answer looks like JSON - if ( - (answer.trim().startsWith('{') && answer.trim().endsWith('}')) || - answer.includes('"modelAnswer"') - ) { - try { - // Try to parse it as JSON - const parsed = JSON.parse(answer) - if (parsed.modelAnswer) { - return parsed.modelAnswer - } - } catch { - // If parsing fails, try to extract with regex - const match = answer.match(/"modelAnswer"\s*:\s*"([\s\S]*?)"/) - if (match && match[1]) { - return match[1].replace(/\\"/g, '"') - } - } - } - - return answer -} diff --git a/frontend/src/app/api/assessment/exam/route.ts b/frontend/src/app/api/assessment/exam/route.ts new file mode 100644 index 0000000..46f1b23 --- /dev/null +++ b/frontend/src/app/api/assessment/exam/route.ts @@ -0,0 +1,32 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import { NextResponse, type NextRequest } from 'next/server' + +export async function POST(req: NextRequest) { + try { + const body = await req.json() + + // Force assessmentType to 'exam' for this route + const payload = { ...body, assessmentType: 'exam' } + + const response = await fetch(new URL('/api/assessment', req.url).href, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + cache: 'no-store', + }) + + // Stream through the response as-is + const buf = await response.arrayBuffer() + return new NextResponse(buf, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('Content-Type') || 'application/json', + }, + }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error' + return NextResponse.json({ error: `Exam generation failed: ${message}` }, { status: 500 }) + } +} diff --git a/frontend/src/app/api/assessment/project/route.ts b/frontend/src/app/api/assessment/project/route.ts new file mode 100644 index 0000000..df766ab --- /dev/null +++ b/frontend/src/app/api/assessment/project/route.ts @@ -0,0 +1,31 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import { NextResponse, type NextRequest } from 'next/server' + +export async function POST(req: NextRequest) { + try { + const body = await req.json() + + // Force assessmentType to 'project' for this route + const payload = { ...body, assessmentType: 'project' } + + const response = await fetch(new URL('/api/assessment', req.url).href, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + cache: 'no-store', + }) + + const buf = await response.arrayBuffer() + return new NextResponse(buf, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('Content-Type') || 'application/json', + }, + }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error' + return NextResponse.json({ error: `Project generation failed: ${message}` }, { status: 500 }) + } +} diff --git a/frontend/src/app/api/assessment/prompts/common.ts b/frontend/src/app/api/assessment/prompts/common.ts new file mode 100644 index 0000000..bea09e4 --- /dev/null +++ b/frontend/src/app/api/assessment/prompts/common.ts @@ -0,0 +1,20 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +export type Lang = 'en' | 'id' + +export const langDirective = (lang: Lang) => + lang === 'id' + ? 'PENTING: Semua output harus dalam Bahasa Indonesia yang jelas dan alami.' + : 'IMPORTANT: All output must be in clear and natural English.' + +// Minimal CourseInfo shape used by prompt builders +export interface CourseInfoLike { + courseCode?: string + courseName?: string + semester?: string + academicYear?: string + deadline?: string + groupSize?: number + duration?: string +} diff --git a/frontend/src/app/api/assessment/prompts/exam.ts b/frontend/src/app/api/assessment/prompts/exam.ts new file mode 100644 index 0000000..51f6bba --- /dev/null +++ b/frontend/src/app/api/assessment/prompts/exam.ts @@ -0,0 +1,309 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import type { Lang, CourseInfoLike } from '@/app/api/assessment/prompts/common' +import { langDirective } from '@/app/api/assessment/prompts/common' + +// Questions +export function buildExamQuestionsSystemPrompt( + difficultyLevel: string, + assessmentType: string, + courseInfo: CourseInfoLike | undefined, + language: Lang, + hasSourceMaterials: boolean, + numQuestions: number, +): string { + if (language === 'id') { + return `${langDirective(language)}\n\nAnda adalah pengembang asesmen pendidikan ahli dalam bidang ${ + courseInfo?.courseName || 'mata kuliah ini' + }. Hasilkan ${numQuestions} pertanyaan unik untuk asesmen ${assessmentType} tingkat ${difficultyLevel}. + +INSTRUKSI PENTING: +${ + hasSourceMaterials + ? `1. Anda HARUS mendasarkan seluruh konten SEPENUHNYA pada materi sumber yang disediakan. +2. Ambil konsep kunci, terminologi, contoh, dan penjelasan langsung dari materi sumber. +3. Jangan perkenalkan konsep atau informasi yang tidak ada dalam materi sumber. +4. Abaikan judul mata kuliah atau pengetahuan eksternal di luar materi sumber. +5. Seluruh keluaran HARUS menggunakan bahasa target yang diminta tanpa mencampur bahasa. +Catatan: Jangan menyalin atau mengutip teks dari materi sumber yang bukan dalam bahasa target.` + : `1. Karena tidak ada materi sumber, dasarkan pertanyaan HANYA pada judul mata kuliah "${( + courseInfo?.courseCode || '' + ).trim()} ${( + courseInfo?.courseName || 'mata kuliah ini' + ).trim()}". Jangan gunakan kurikulum standar atau sumber eksternal. +2. Fokus pada konsep inti, teori, dan aplikasi umum. +3. Pastikan tingkat akademik sesuai konteks universitas. +4. SANGAT PENTING: Seluruh keluaran HARUS dalam Bahasa Indonesia yang jelas dan alami, tanpa mencampur bahasa apa pun. Abaikan bahasa asli nama mata kuliah - tetap gunakan Bahasa Indonesia untuk semua respons.` +} +4. Pertanyaan harus beragam dan mencakup berbagai topik. +5. Respons HARUS berupa array JSON string. + +FORMAT: +[ + "Pertanyaan 1", + "Pertanyaan 2" +] + +JANGAN sertakan teks di luar array JSON.` + } + + return `${langDirective(language)}\n\nYou are an expert assessment designer in ${ + courseInfo?.courseName || 'this course' + }. Generate ${numQuestions} unique questions for a ${difficultyLevel}-level ${assessmentType} assessment. + +CRITICAL INSTRUCTIONS: +${ + hasSourceMaterials + ? `1. You MUST base ALL content ENTIRELY on the provided source materials. +2. Derive key concepts, terminology, examples, and explanations directly from the sources. +3. Do not introduce concepts or information not present in the sources. +4. Ignore the course title and any outside knowledge beyond the source materials. +5. The output MUST be entirely in the requested target language with no language mixing. +Note: Do not copy or quote any text from the source materials that is not in the target language.` + : `1. Since there are no source materials, base the questions ONLY on the course title "${( + courseInfo?.courseCode || '' + ).trim()} ${( + courseInfo?.courseName || 'this course' + ).trim()}". Do not use standard curriculum or external sources. +2. Focus on core concepts, theories, and common applications. +3. Ensure the academic level fits a university context. +4. CRITICAL: All output MUST be in clear, natural English without mixing any languages. Ignore the original language of the course name - always use English for all responses.` +} +4. Questions should be diverse and cover multiple topics. +5. The response MUST be a JSON array of strings. + +FORMAT: +[ + "Question 1", + "Question 2" +] + +DO NOT include any text outside the JSON array.` +} + +export function buildExamQuestionsUserPrompt( + hasSourceMaterials: boolean, + courseInfo: CourseInfoLike | undefined, + language: Lang, + numQuestions: number, + assessmentType: string, +): string { + if (language === 'id') { + return hasSourceMaterials + ? `Hasilkan ${numQuestions} pertanyaan unik untuk asesmen ${assessmentType}. Ikuti format yang diminta.` + : `Hasilkan ${numQuestions} pertanyaan unik untuk asesmen ${assessmentType} pada mata kuliah ${ + courseInfo?.courseCode || '' + } ${courseInfo?.courseName || 'mata kuliah ini'}. Jawab dalam format yang diminta.` + } + return hasSourceMaterials + ? `Generate ${numQuestions} unique questions for the ${assessmentType} assessment. Follow the requested output format.` + : `Generate ${numQuestions} unique questions for the ${assessmentType} assessment in the course ${ + courseInfo?.courseCode || '' + } ${courseInfo?.courseName || 'this course'}. Follow the requested output format.` +} + +// Model Answer +export function buildExamModelAnswerSystemPrompt( + assessmentType: string, + courseInfo: CourseInfoLike | undefined, + language: Lang, + hasSourceMaterials: boolean, + question: string, +): string { + if (language === 'id') { + return `${langDirective(language)}\n\nAnda adalah pengembang asesmen pendidikan ahli. Buat jawaban model untuk pertanyaan berikut ${ + hasSourceMaterials + ? 'berdasarkan SECARA KETAT materi sumber yang disediakan.' + : `untuk ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'mata kuliah ini'}` + }. + +INSTRUKSI PENTING: +${ + hasSourceMaterials + ? `1. Gunakan hanya materi sumber. +2. Ambil konsep dan contoh secara langsung dari materi sumber. +3. Jangan tambah informasi eksternal. +4. Abaikan judul mata kuliah atau pengetahuan eksternal di luar materi sumber. +5. Seluruh keluaran HARUS menggunakan bahasa target yang diminta tanpa mencampur bahasa. +Catatan: Jangan menyalin atau mengutip teks dari materi sumber yang bukan dalam bahasa target.` + : `1. Gunakan pengetahuan standar kurikulum. +2. Fokus pada konsep inti, teori, dan aplikasi relevan. +3. Pastikan akademik dan tepat. +4. Seluruh keluaran HARUS menggunakan bahasa target yang diminta tanpa mencampur bahasa.` +} +4. Jawaban harus komprehensif dan akurat. +5. Respons HARUS berupa teks polos saja. + +PERTANYAAN: ${question} + +JANGAN sertakan format markdown atau penjelasan tambahan.` + } + + return `${langDirective(language)}\n\nYou are an expert assessment designer. Create a model answer for the following question ${ + hasSourceMaterials + ? 'STRICTLY based on the provided source materials.' + : `for ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this course'}` + }. + +CRITICAL INSTRUCTIONS: +${ + hasSourceMaterials + ? `1. Use only the source materials. +2. Derive concepts and examples directly from them. +3. Do not add external information. +4. Ignore the course title and any outside knowledge beyond the source materials. +5. The output MUST be entirely in the requested target language with no language mixing. +Note: Do not copy or quote any text from the source materials that is not in the target language.` + : `1. Use standard curriculum knowledge. +2. Focus on core concepts, theory, and relevant applications. +3. Keep it academic and precise. +4. The output MUST be entirely in the requested target language with no language mixing.` +} +4. The answer must be comprehensive and accurate. +5. The response MUST be plain text only. + +QUESTION: ${question} + +DO NOT include markdown formatting or extra explanations.` +} + +export function buildExamModelAnswerUserPrompt( + hasSourceMaterials: boolean, + courseInfo: CourseInfoLike | undefined, + language: Lang, +): string { + if (language === 'id') { + return hasSourceMaterials + ? `Buat jawaban model untuk pertanyaan tersebut.` + : `Buat jawaban model untuk pertanyaan tersebut pada ${courseInfo?.courseCode || ''} ${ + courseInfo?.courseName || 'mata kuliah ini' + }.` + } + return hasSourceMaterials + ? `Create a model answer for the question.` + : `Create a model answer for the question in ${courseInfo?.courseCode || ''} ${ + courseInfo?.courseName || 'this course' + }.` +} + +// Marking Criteria +export function buildExamMarkingCriteriaSystemPrompt( + assessmentType: string, + courseInfo: CourseInfoLike | undefined, + language: Lang, + hasSourceMaterials: boolean, + question: string, + modelAnswer: string, +): string { + if (language === 'id') { + return `${langDirective(language)}\n\nAnda adalah pengembang asesmen pendidikan ahli. Buat kriteria penilaian untuk pertanyaan berikut berdasarkan jawaban model ${ + hasSourceMaterials + ? 'dan SECARA KETAT materi sumber.' + : `untuk ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'mata kuliah ini'}` + }. + +INSTRUKSI PENTING: +${ + hasSourceMaterials + ? `1. Gunakan hanya materi sumber. +2. Ambil elemen penilaian yang relevan dari jawaban model. +3. Abaikan judul mata kuliah atau pengetahuan eksternal di luar materi sumber. +4. Seluruh keluaran HARUS menggunakan bahasa target yang diminta tanpa mencampur bahasa. +Catatan: Jangan menyalin atau mengutip teks dari materi sumber yang bukan dalam bahasa target.` + : `1. Gunakan prinsip penilaian akademik standar. +2. Fokus pada pemahaman, aplikasi, dan analisis kritis. +3. Seluruh keluaran HARUS menggunakan bahasa target yang diminta tanpa mencampur bahasa.` +} +3. Berikan rubrik terstruktur dengan bobot jelas. +4. Respons HARUS berupa JSON valid. + +PERTANYAAN: ${question} + +JAWABAN MODEL: ${modelAnswer} + +FORMAT: +{ + "criteria": [ + { + "name": "Kriteria 1", + "weight": 40, + "description": "Deskripsi kriteria 1" + } + ], + "markAllocation": [ + { + "component": "Komponen 1", + "marks": 5, + "description": "Deskripsi komponen 1" + } + ] +} + +JANGAN sertakan teks di luar objek JSON.` + } + + return `${langDirective(language)}\n\nYou are an expert assessment designer. Create marking criteria for the following question based on the model answer ${ + hasSourceMaterials + ? 'and STRICTLY the source materials.' + : `for ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this course'}` + }. + +CRITICAL INSTRUCTIONS: +${ + hasSourceMaterials + ? `1. Use only the source materials. +2. Derive relevant assessment elements from the model answer. +3. Ignore the course title and any outside knowledge beyond the source materials. +4. The output MUST be entirely in the requested target language with no language mixing. +Note: Do not copy or quote any text from the source materials that is not in the target language.` + : `1. Use standard academic assessment principles. +2. Focus on understanding, application, and critical analysis. +3. The output MUST be entirely in the requested target language with no language mixing.` +} +3. Provide a structured rubric with clear weights. +4. The response MUST be valid JSON. + +QUESTION: ${question} + +MODEL ANSWER: ${modelAnswer} + +FORMAT: +{ + "criteria": [ + { + "name": "Criterion 1", + "weight": 40, + "description": "Description of criterion 1" + } + ], + "markAllocation": [ + { + "component": "Component 1", + "marks": 5, + "description": "Description of component 1" + } + ] +} + +DO NOT include any text outside the JSON object.` +} + +export function buildExamMarkingCriteriaUserPrompt( + hasSourceMaterials: boolean, + courseInfo: CourseInfoLike | undefined, + language: Lang, +): string { + if (language === 'id') { + return hasSourceMaterials + ? `Buat kriteria penilaian (rubrik) untuk pertanyaan ini berdasarkan jawaban model.` + : `Buat kriteria penilaian (rubrik) untuk pertanyaan ini berdasarkan jawaban model pada ${ + courseInfo?.courseCode || '' + } ${courseInfo?.courseName || 'mata kuliah ini'}.` + } + return hasSourceMaterials + ? `Create marking criteria (rubric) for this question based on the model answer.` + : `Create marking criteria (rubric) for this question based on the model answer in ${ + courseInfo?.courseCode || '' + } ${courseInfo?.courseName || 'this course'}.` +} diff --git a/frontend/src/app/api/assessment/prompts/project.ts b/frontend/src/app/api/assessment/prompts/project.ts new file mode 100644 index 0000000..14d4924 --- /dev/null +++ b/frontend/src/app/api/assessment/prompts/project.ts @@ -0,0 +1,157 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import type { Lang, CourseInfoLike } from '@/app/api/assessment/prompts/common' +import { langDirective } from '@/app/api/assessment/prompts/common' + +export function buildProjectDescriptionSystemPrompt( + difficultyLevel: string, + courseInfo: CourseInfoLike, + language: Lang, + hasSourceMaterials: boolean, +): string { + if (language === 'id') { + return `${langDirective(language)}\n\nAnda adalah pengembang asesmen pendidikan ahli. ${ + hasSourceMaterials + ? 'Buat deskripsi proyek komprehensif berdasarkan SECARA KETAT materi sumber yang diberikan. Abaikan judul mata kuliah atau informasi eksternal lainnya.' + : `Buat deskripsi proyek komprehensif untuk mata kuliah tingkat ${difficultyLevel} "${ + courseInfo.courseName || 'Big Data Storage and Management' + }" dengan dasar HANYA pada judul mata kuliah \"${(courseInfo.courseCode || '').trim()} ${(courseInfo.courseName || 'Big Data Storage and Management').trim()}\" (tanpa kurikulum standar atau sumber eksternal)` + }. + +INSTRUKSI PENTING: +${ + hasSourceMaterials + ? `1. Anda HARUS mendasarkan seluruh konten SEPENUHNYA pada materi sumber yang disediakan. +2. Ambil konsep kunci, terminologi, contoh, dan penjelasan langsung dari materi sumber. +3. Jangan perkenalkan konsep atau informasi yang tidak ada dalam materi sumber. +4. Abaikan sepenuhnya judul mata kuliah, kode mata kuliah, atau pengetahuan eksternal di luar materi sumber. +5. Rancang proyek berdasarkan hanya pada apa yang dicakup dalam materi sumber.` + : `1. Karena tidak ada materi sumber, dasarkan proyek HANYA pada judul mata kuliah \"${( + courseInfo.courseCode || '' + ).trim()} ${(courseInfo.courseName || 'Big Data Storage and Management').trim()}\". +2. Jangan gunakan kurikulum standar atau sumber eksternal. +3. Pastikan tingkat akademik sesuai konteks universitas.` +} +4. Buat deskripsi proyek rinci dengan deliverables dan persyaratan jelas. +5. Sertakan instruksi spesifik untuk komponen laporan dan presentasi.${ + hasSourceMaterials + ? '' + : ` +6. Proyek dirancang untuk kelompok beranggotakan ${courseInfo.groupSize || 4} mahasiswa. +7. Durasi pengerjaan: ${courseInfo.duration || '2 minggu'}.` + } +${hasSourceMaterials ? '6' : '8'}. Gunakan bagian-bagian berikut (judul harus dicetak tebal, gunakan **): + - Instruksi + - Deskripsi Proyek + - Deliverables (Hasil) + - Struktur Laporan + - Persyaratan Presentasi + - Panduan Pengumpulan + - Informasi Tenggat +${hasSourceMaterials ? '7' : '9'}. Format dalam Markdown (hanya gunakan bold, tanpa heading #). +${hasSourceMaterials ? '8' : '10'}. Respons BUKAN JSON. Tulis dokumen lengkap yang terstruktur rapi. +Catatan: Jangan menyalin atau mengutip teks dari materi sumber yang bukan dalam bahasa target.` + } + + return `${langDirective(language)}\n\nYou are an expert assessment designer. ${ + hasSourceMaterials + ? 'Create a comprehensive project description STRICTLY based on the provided source materials. Ignore any course title or external information.' + : `Create a comprehensive project description for a ${difficultyLevel}-level course "${ + courseInfo.courseName || 'Big Data Storage and Management' + }" based ONLY on the course title \"${(courseInfo.courseCode || '').trim()} ${(courseInfo.courseName || 'Big Data Storage and Management').trim()}\" (do not use standard curriculum or external sources)` + }. + +CRITICAL INSTRUCTIONS: +${ + hasSourceMaterials + ? `1. You MUST base ALL content ENTIRELY on the provided source materials. +2. Derive key concepts, terminology, examples, and explanations directly from the sources. +3. Do not introduce concepts or information not present in the sources. +4. Completely ignore any course title, course code, or external knowledge beyond the source materials. +5. Design the project based solely on what is covered in the source materials.` + : `1. Since there are no source materials, base the project ONLY on the course title \"${( + courseInfo.courseCode || '' + ).trim()} ${(courseInfo.courseName || 'Big Data Storage and Management').trim()}\". +2. Do not use standard curriculum or external sources. +3. Ensure the academic level fits a university context.` +} +4. Provide a detailed project description with clear deliverables and requirements. +5. Include specific instructions for the report and presentation components.${ + hasSourceMaterials + ? '' + : ` +6. The project is designed for groups of ${courseInfo.groupSize || 4} students. +7. Work duration: ${courseInfo.duration || '2 weeks'}.` + } +${hasSourceMaterials ? '6' : '8'}. Use the following sections (titles should be bold using **): + - Instructions + - Project Description + - Deliverables + - Report Structure + - Presentation Requirements + - Submission Guidelines + - Deadline Information +${hasSourceMaterials ? '7' : '9'}. Format in Markdown (use bold only, no # headings). +${hasSourceMaterials ? '8' : '10'}. The response is NOT JSON. Write a well-structured, complete document. +Note: Do not copy or quote any text from the source materials that is not in the target language.` +} + +export function buildProjectDescriptionUserPrompt( + courseInfo: CourseInfoLike, + language: Lang, +): string { + if (language === 'id') { + return `Hasilkan deskripsi proyek komprehensif untuk ${courseInfo.courseCode || 'CDS502'} ${ + courseInfo.courseName || 'Big Data Storage and Management' + } pada ${courseInfo.semester || 'Semester 1'} ${ + courseInfo.academicYear || '2023/2024' + } dengan tenggat ${courseInfo.deadline || '10 Januari 2024, pukul 18:15'}.` + } + + return `Generate a comprehensive project description for ${courseInfo.courseCode || 'CDS502'} ${ + courseInfo.courseName || 'Big Data Storage and Management' + } in ${courseInfo.semester || 'Semester 1'} ${ + courseInfo.academicYear || '2023/2024' + } with a deadline of ${courseInfo.deadline || 'January 10, 2024, 6:15 pm'}.` +} + +// New: Project model answer/guidelines prompts +export function buildProjectModelAnswerSystemPrompt( + courseInfo: CourseInfoLike | undefined, + language: Lang, + hasSourceMaterials: boolean, +): string { + if (language === 'id') { + return `${langDirective(language)}\n\nAnda adalah dosen yang memberikan tugas proyek. Buat JAWABAN CONTOH/PANDUAN untuk proyek berikut. ${ + hasSourceMaterials + ? 'ANDA HARUS mendasarkan seluruh konten SEPENUHNYA pada materi sumber yang disediakan; ambil konsep, contoh, dan ekspektasi langsung dari sumber tersebut. Jangan perkenalkan informasi di luar materi sumber.' + : 'Karena tidak ada materi sumber, sesuaikan panduan berdasarkan judul mata kuliah dan konteks universitas.' + }\n\nFokus pada:\n- Rencana kerja langkah demi langkah\n- Struktur dan konten laporan yang diharapkan\n- Ekspektasi presentasi/demo\n- Ekspektasi kualitas dan penilaian tingkat tinggi\n\nTulis sebagai pedoman praktis untuk mahasiswa (bukan pengulangan soal).` + } + return `${langDirective(language)}\n\nYou are an instructor assigning a project. Create the MODEL ANSWER/GUIDELINES for the project below. ${ + hasSourceMaterials + ? 'You MUST base ALL content ENTIRELY on the provided source materials; derive concepts, examples, and expectations directly from them. Do not introduce information beyond the sources.' + : 'Since there are no source materials, tailor the guidance based on the course title and a university context.' + }\n\nFocus on:\n- Step-by-step work plan\n- Expected report structure and content\n- Presentation/demo expectations\n- High-level quality and marking expectations\n\nWrite practical guidance for students (not a restatement of the prompt).` +} + +export function buildProjectModelAnswerUserPrompt( + question: string, + courseInfo: CourseInfoLike | undefined, + language: Lang, + hasSourceMaterials: boolean, +): string { + if (language === 'id') { + return `PROYEK:\n${question}\n\n${ + hasSourceMaterials + ? 'GUNAKAN HANYA materi sumber terlampir untuk menyusun panduan.' + : 'Tidak ada materi sumber; gunakan konteks mata kuliah.' + }\n\nTULISKAN JAWABAN CONTOH/PANDUAN yang berfokus pada langkah kerja, struktur laporan, ekspektasi presentasi, dan kualitas yang diharapkan.` + } + return `PROJECT:\n${question}\n\n${ + hasSourceMaterials + ? 'USE ONLY the attached source materials to craft the guidance.' + : 'No source materials; use course context.' + }\n\nWRITE THE MODEL ANSWER/GUIDELINES focusing on work plan, report structure, presentation expectations, and expected quality.` +} diff --git a/frontend/src/app/api/assessment/route.ts b/frontend/src/app/api/assessment/route.ts index 20366f2..41ab5b8 100644 --- a/frontend/src/app/api/assessment/route.ts +++ b/frontend/src/app/api/assessment/route.ts @@ -3,8 +3,9 @@ import type { Source } from '../../../payload-types' import { NextResponse } from 'next/server' -import { createOllama } from 'ollama-ai-provider' -import { type CoreMessage, generateText } from 'ai' +import { createOllama } from 'ollama-ai-provider-v2' +import { type ModelMessage, generateText, generateObject } from 'ai' +import { jsonrepair } from 'jsonrepair' import { getStoredChunks } from '@/lib/chunk/get-stored-chunks' import type { ClientSource } from '@/lib/types/client-source' import type { AssessmentQuestion, ExplanationObject } from '@/lib/types/assessment-types' @@ -40,7 +41,21 @@ const TEMPERATURE = Number.parseFloat(process.env.RAG_TEMPERATURE || '0.1') const TOKEN_MAX = Number.parseInt(process.env.RAG_TOKEN_MAX ?? '2048') const TOKEN_RESPONSE_RATIO = Number.parseFloat(process.env.RESPONSE_TOKEN_PERCENTAGE || '0.7') const TOKEN_RESPONSE_BUDGET = Math.floor(TOKEN_MAX * TOKEN_RESPONSE_RATIO) -const TOKEN_CONTEXT_BUDGET = 500 +const TOKEN_CONTEXT_BUDGET = 1200 // Increased to allow more source content while keeping response budget reasonable +const ASSESSMENT_CONCURRENCY = Math.max( + 1, + Number.parseInt(process.env.ASSESSMENT_CONCURRENCY || '3'), +) +const ASSESSMENT_REQUEST_TIMEOUT_MS = Math.max( + 5000, + Number.parseInt(process.env.ASSESSMENT_REQUEST_TIMEOUT_MS || '125000'), +) + +// Language directive helper +const langDirective = (lang: 'en' | 'id') => + lang === 'id' + ? 'PENTING: Semua output harus dalam Bahasa Indonesia yang jelas dan alami.' + : 'IMPORTANT: All output must be in clear and natural English.' // Update the getDefaultDuration function to ensure exam duration is 2 hours const getDefaultDuration = (assessmentType: string): string => { @@ -90,11 +105,70 @@ function truncateToTokenLimit(text: string, maxTokens: number): string { return result.trim() + '...' } +// Helper: wrap a promise with a timeout +async function withTimeout( + promise: Promise, + ms: number, + onTimeout?: () => T | Promise, +): Promise { + let timeoutId: NodeJS.Timeout + return await Promise.race | T>([ + promise, + new Promise((resolve) => { + timeoutId = setTimeout(async () => { + if (onTimeout) { + try { + const fallback = await onTimeout() + resolve(fallback) + } catch { + // noop; will hang without a resolution, but onTimeout should not throw + } + } + }, ms) + }), + ]).finally(() => clearTimeout(timeoutId)) +} + +// Helper: process an array with limited concurrency, preserving order +async function mapWithConcurrency( + items: T[], + concurrency: number, + mapper: (item: T, index: number) => Promise, +): Promise { + const results: R[] = new Array(items.length) + let nextIndex = 0 + const workers: Promise[] = [] + + const worker = async () => { + while (true) { + const current = nextIndex++ + if (current >= items.length) break + try { + results[current] = await mapper(items[current], current) + } catch (e) { + // In case of unexpected error, rethrow after annotation + throw e + } + } + } + + for (let i = 0; i < Math.min(concurrency, items.length); i++) { + workers.push(worker()) + } + await Promise.all(workers) + return results +} + +// Note: We intentionally do not gate documents by their detected language. +// When documents are selected, we always use them as the knowledge base +// and enforce target-language-only outputs in the prompts regardless of +// the source language. + // Improve the extractJsonFromText function to be more robust function extractJsonFromText(text: string): string | null { try { // Clean up the text first - remove markdown code block markers - const cleanedText = text.replace(/```json|```/g, '').trim() + const cleanedText = stripCodeFences(text) // First, try to parse the entire text as JSON directly try { @@ -105,6 +179,22 @@ function extractJsonFromText(text: string): string | null { console.log('Direct parsing failed, trying alternative extraction methods') } + // Try jsonrepair on the whole cleaned text as an early fallback + try { + let repairedWhole: string + try { + repairedWhole = jsonrepair(cleanedText) + } catch (e) { + console.log('jsonrepair failed on entire text in extractJsonFromText:', e) + repairedWhole = cleanedText + } + JSON.parse(repairedWhole) + console.log('jsonrepair succeeded on entire text in extractJsonFromText') + return repairedWhole + } catch { + // continue + } + // Look for JSON array pattern with more flexible regex const arrayRegex = /(\[[\s\S]*?\])/g const arrayMatches = cleanedText.match(arrayRegex) @@ -119,8 +209,22 @@ function extractJsonFromText(text: string): string | null { console.log('Found valid JSON array in text fragment') return sanitized } catch { - // Continue to next match if this one isn't valid - continue + // Try jsonrepair for this fragment + try { + let repaired: string + try { + repaired = jsonrepair(match) + } catch (e) { + console.log('jsonrepair failed on array fragment, continuing:', e) + throw e + } + JSON.parse(repaired) + console.log('jsonrepair succeeded on array fragment') + return repaired + } catch { + // Continue to next match if this one isn't valid + continue + } } } } @@ -139,8 +243,22 @@ function extractJsonFromText(text: string): string | null { console.log('Found valid JSON in text fragment') return sanitized } catch { - // Continue to next match if this one isn't valid - continue + // Try jsonrepair for this fragment + try { + let repaired: string + try { + repaired = jsonrepair(match) + } catch (e) { + console.log('jsonrepair failed on object fragment, continuing:', e) + throw e + } + JSON.parse(repaired) + console.log('jsonrepair succeeded on object fragment') + return repaired + } catch { + // Continue to next match if this one isn't valid + continue + } } } } @@ -162,6 +280,20 @@ function extractJsonFromText(text: string): string | null { return sanitized } catch (e) { console.log('Failed to parse JSON object extracted using brace positions:', e) + try { + let repaired: string + try { + repaired = jsonrepair(jsonCandidate) + } catch (e2) { + console.log('jsonrepair failed on object extracted by braces:', e2) + throw e2 + } + JSON.parse(repaired) + console.log('jsonrepair succeeded on object extracted by braces') + return repaired + } catch (e2) { + console.log('jsonrepair also failed on object extracted by braces:', e2) + } } } @@ -175,6 +307,20 @@ function extractJsonFromText(text: string): string | null { return sanitized } catch (e) { console.log('Failed to parse JSON array extracted using bracket positions:', e) + try { + let repaired: string + try { + repaired = jsonrepair(jsonCandidate) + } catch (e2) { + console.log('jsonrepair failed on array extracted by brackets:', e2) + throw e2 + } + JSON.parse(repaired) + console.log('jsonrepair succeeded on array extracted by brackets') + return repaired + } catch (e2) { + console.log('jsonrepair also failed on array extracted by brackets:', e2) + } } } @@ -256,104 +402,348 @@ function sanitizeJsonString(jsonString: string): string { } } -// Define the default project rubric with a more robust structure -const getDefaultProjectRubric = (): ProjectRubric => ({ - categories: { - report: [ - { - name: 'Content and Organization', - weight: 20, - description: 'Clarity, structure, and logical flow of the report.', - levels: { - excellent: 'Excellent organization with a clear and logical flow.', - good: 'Good organization with a mostly clear flow.', - average: 'Adequate organization, but some areas lack clarity.', - acceptable: 'Poor organization, difficult to follow.', - poor: 'No discernible organization.', - }, +// Helper: strip markdown code fences (```json ... ``` or ``` ... ```) +function stripCodeFences(text: string): string { + if (!text) return text + const fenceRegex = /```(?:json)?\s*([\s\S]*?)\s*```/i + const match = text.match(fenceRegex) + return match ? match[1].trim() : text.replace(/```json|```/g, '').trim() +} + +// Heuristic language detection for English vs Bahasa Indonesia +function detectLikelyLanguage(text: string): 'en' | 'id' | 'unknown' { + const lower = text.toLowerCase() + const idWords = [ + 'dan', + 'yang', + 'untuk', + 'dengan', + 'pada', + 'adalah', + 'tidak', + 'ini', + 'itu', + 'atau', + 'dari', + 'ke', + 'dalam', + 'sebuah', + 'contoh', + 'misalnya', + 'menggunakan', + ] + const enWords = [ + 'and', + 'the', + 'for', + 'with', + 'on', + 'is', + 'not', + 'this', + 'that', + 'or', + 'from', + 'to', + 'in', + 'example', + 'using', + ] + let idScore = 0 + let enScore = 0 + for (const w of idWords) if (lower.includes(` ${w} `)) idScore++ + for (const w of enWords) if (lower.includes(` ${w} `)) enScore++ + if (idScore >= enScore + 2) return 'id' + if (enScore >= idScore + 2) return 'en' + return 'unknown' +} + +// Ensure plain text outputs adhere to selected language by a brief rewrite prompt (no translation wording) +async function ensureTargetLanguageText( + text: string, + language: 'en' | 'id', + ollama: OllamaFn, + selectedModel: string, + options?: { force?: boolean }, +): Promise { + const detected = detectLikelyLanguage(text) + // When force=true, only skip if it already matches target; otherwise rewrite even if unknown + if (detected === language) return text + if (detected === 'unknown' && !options?.force) return text + + const directive = + language === 'id' + ? 'PENTING: Tulis ulang seluruh konten berikut dalam Bahasa Indonesia yang jelas dan alami. Jangan gunakan kata dari bahasa lain. Jangan ubah struktur atau makna.' + : 'IMPORTANT: Rewrite all of the following content in clear and natural English only. Do not use any words from other languages. Do not change the structure or meaning.' + + const systemMessage: ModelMessage = { role: 'system', content: directive } + const userMessage: ModelMessage = { role: 'user', content: text } + try { + const resp = await generateText({ + model: ollama(selectedModel), + messages: [systemMessage, userMessage], + temperature: Math.max(0, TEMPERATURE - 0.05), + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET), + }) + return stripThinkTags(resp.text) + } catch { + return text + } +} + +// Decide if a rubric array likely needs language enforcement based on a small sample +function needLanguageEnforcementForCriteria( + criteria: ProjectRubricCriterion[], + language: 'en' | 'id', +): boolean { + try { + const sample = criteria + .slice(0, 3) + .map((c) => [c.name, c.description, c.levels?.excellent, c.levels?.good].join(' ')) + .join(' ') + const detected = detectLikelyLanguage(sample) + // Enforce when sample language doesn't match the target (including 'unknown' cases) + return detected !== language + } catch { + return false + } +} + +// Batch-enforce rubric language in one model call to reduce latency +async function enforceRubricLanguage( + criteria: ProjectRubricCriterion[], + language: 'en' | 'id', + ollama: OllamaFn, + selectedModel: string, +): Promise { + const directive = + language === 'id' + ? `${langDirective(language)}\n\nTugas: Ubah SEMUA nilai string di dalam array JSON berikut menjadi Bahasa Indonesia yang jelas dan alami.\nJANGAN ubah kunci, bentuk array, urutan item, tipe data numerik, atau bobot.\nKembalikan HANYA JSON (array) dengan struktur yang sama.` + : `${langDirective(language)}\n\nTask: Rewrite ALL string values in the following JSON array into clear and natural English.\nDO NOT change keys, array shape, item order, numeric types, or weights.\nReturn JSON ONLY (the array) with exactly the same structure.` + + const systemMessage: ModelMessage = { role: 'system', content: directive } + const userMessage: ModelMessage = { role: 'user', content: JSON.stringify(criteria) } + + try { + const resp = await generateText({ + model: ollama(selectedModel), + messages: [systemMessage, userMessage], + temperature: Math.max(0, TEMPERATURE - 0.05), + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 4), + }) + const cleaned = stripThinkTags(resp.text) + const jsonStr = extractJsonFromText(cleaned) || cleaned + const parsed = JSON.parse(jsonStr) + if (Array.isArray(parsed)) return parsed as ProjectRubricCriterion[] + } catch (e) { + console.log('Batch rubric language enforcement failed, returning original criteria:', e) + } + return criteria +} + +// Define the default project rubric with a language-aware structure (English default) +const getDefaultProjectRubric = (language: 'en' | 'id' = 'en'): ProjectRubric => { + if (language === 'id') { + return { + categories: { + report: [ + { + name: 'Konten dan Organisasi', + weight: 20, + description: 'Kejelasan, struktur, dan alur logis laporan.', + levels: { + excellent: + 'Organisasi sangat baik dengan alur jelas dan logis; transisi antar bagian mulus dan konsisten.', + good: 'Organisasi baik dengan alur cukup jelas; sebagian besar transisi efektif dan relevan.', + average: + 'Organisasi cukup, tetapi beberapa bagian kurang jelas atau tidak terhubung kuat.', + acceptable: + 'Organisasi lemah; alur sulit diikuti dan beberapa bagian tampak tidak terstruktur.', + poor: 'Tidak ada struktur organisasi yang jelas.', + }, + }, + { + name: 'Ketepatan Teknis', + weight: 20, + description: 'Ketepatan dan kedalaman informasi teknis yang disajikan.', + levels: { + excellent: + 'Menunjukkan pemahaman teknis sangat mendalam dan akurat tanpa kesalahan berarti.', + good: 'Menunjukkan pemahaman teknis baik dengan hanya sedikit ketidakakuratan minor.', + average: + 'Pemahaman teknis cukup namun terdapat beberapa ketidakakuratan atau bagian dangkal.', + acceptable: 'Pemahaman teknis lemah; banyak kesalahan atau penjelasan dangkal.', + poor: 'Tidak menunjukkan pemahaman teknis yang memadai.', + }, + }, + { + name: 'Analisis dan Interpretasi Data', + weight: 15, + description: 'Kualitas analisis data dan kekuatan interpretasi hasil.', + levels: { + excellent: 'Analisis mendalam, wawasan kuat, interpretasi didukung bukti relevan.', + good: 'Analisis baik dengan interpretasi wajar dan sebagian besar didukung data.', + average: 'Analisis dasar; interpretasi sebagian benar tetapi kurang kedalaman.', + acceptable: 'Analisis minim; interpretasi lemah atau tidak didukung bukti cukup.', + poor: 'Tidak ada analisis atau interpretasi yang bermakna.', + }, + }, + ], + demo: [ + { + name: 'Kejelasan Presentasi', + weight: 10, + description: 'Kejelasan dan efektivitas penyampaian presentasi.', + levels: { + excellent: + 'Presentasi sangat jelas, terstruktur, menarik, dan mudah dipahami audiens.', + good: 'Presentasi jelas dan umumnya mudah dipahami.', + average: 'Presentasi cukup jelas namun kurang menarik atau kurang fokus.', + acceptable: 'Presentasi sulit dipahami; struktur tidak konsisten.', + poor: 'Tidak ada presentasi yang layak atau tidak disampaikan.', + }, + }, + { + name: 'Demonstrasi Teknis', + weight: 10, + description: 'Kualitas dan fungsionalitas demonstrasi teknis.', + levels: { + excellent: + 'Seluruh fitur/komponen utama berfungsi optimal dan ditunjukkan dengan jelas.', + good: 'Sebagian besar fitur utama berfungsi dengan baik; beberapa aspek minor kurang optimal.', + average: 'Hanya sebagian fitur berfungsi; ada beberapa masalah teknis.', + acceptable: 'Demonstrasi tidak lengkap atau sering gagal saat ditunjukkan.', + poor: 'Tidak ada demonstrasi teknis yang fungsional.', + }, + }, + ], + individual: [ + { + name: 'Kontribusi Individu', + weight: 15, + description: 'Upaya dan kontribusi anggota terhadap proyek.', + levels: { + excellent: 'Kontribusi luar biasa, proaktif, konsisten, dan bernilai tinggi.', + good: 'Kontribusi signifikan dan konsisten dengan kualitas baik.', + average: 'Kontribusi cukup; beberapa bagian dikerjakan tetapi tidak menonjol.', + acceptable: 'Kontribusi minimal; peran kurang jelas atau tidak konsisten.', + poor: 'Tidak tampak kontribusi nyata terhadap proyek.', + }, + }, + ], }, - { - name: 'Technical Accuracy', - weight: 20, - description: 'Correctness and depth of technical information.', - levels: { - excellent: 'Demonstrates a deep and accurate understanding of technical concepts.', - good: 'Demonstrates a good understanding of technical concepts with minor inaccuracies.', - average: - 'Demonstrates an adequate understanding of technical concepts with some inaccuracies.', - acceptable: - 'Demonstrates a poor understanding of technical concepts with significant inaccuracies.', - poor: 'Demonstrates no understanding of technical concepts.', + markingScale: + 'Skala Penilaian: 1 - Sangat Kurang, 2 - Cukup, 3 - Sedang, 4 - Baik, 5 - Sangat Baik.', + totalMarks: 100, + reportWeight: 55, + demoWeight: 30, + individualWeight: 15, + } + } + + // English default + return { + categories: { + report: [ + { + name: 'Content and Organization', + weight: 20, + description: 'Clarity, structure, and logical flow of the report.', + levels: { + excellent: + 'Exceptional organization with clear, logical flow; smooth and consistent transitions between sections.', + good: 'Good organization with mostly clear flow; most transitions are effective and relevant.', + average: 'Adequate organization, though some sections are unclear or weakly connected.', + acceptable: 'Weak organization; difficult to follow with some unstructured parts.', + poor: 'No clear organizational structure.', + }, }, - }, - { - name: 'Data Analysis and Interpretation', - weight: 15, - description: 'Quality of data analysis and interpretation of results.', - levels: { - excellent: 'Provides insightful and well-supported data analysis.', - good: 'Provides good data analysis with reasonable interpretations.', - average: 'Provides adequate data analysis, but interpretations are superficial.', - acceptable: 'Provides poor data analysis with unsupported interpretations.', - poor: 'No data analysis provided.', + { + name: 'Technical Accuracy', + weight: 20, + description: 'Accuracy and depth of the technical information presented.', + levels: { + excellent: + 'Demonstrates very deep and accurate technical understanding with no significant errors.', + good: 'Demonstrates good technical understanding with only minor inaccuracies.', + average: 'Adequate technical understanding with some inaccuracies or shallow sections.', + acceptable: 'Weak technical understanding; many errors or shallow explanations.', + poor: 'Does not demonstrate sufficient technical understanding.', + }, }, - }, - ], - demo: [ - { - name: 'Presentation Clarity', - weight: 10, - description: 'Clarity and effectiveness of the presentation.', - levels: { - excellent: 'Presents information clearly and engagingly.', - good: 'Presents information clearly.', - average: 'Presentation is understandable, but lacks clarity.', - acceptable: 'Presentation is difficult to understand.', - poor: 'No presentation provided.', + { + name: 'Data Analysis and Interpretation', + weight: 15, + description: 'Quality of data analysis and strength of interpretation of results.', + levels: { + excellent: + 'In-depth analysis with strong insights; interpretations supported by relevant evidence.', + good: 'Good analysis with reasonable interpretations mostly supported by data.', + average: 'Basic analysis; interpretations partially correct but lacking depth.', + acceptable: 'Minimal analysis; weak or insufficiently supported interpretations.', + poor: 'No meaningful analysis or interpretation.', + }, }, - }, - { - name: 'Technical Demonstration', - weight: 10, - description: 'Quality and functionality of the technical demonstration.', - levels: { - excellent: 'Demonstrates all technical aspects flawlessly.', - good: 'Demonstrates most technical aspects effectively.', - average: 'Demonstrates some technical aspects, but with issues.', - acceptable: 'Demonstration is incomplete or non-functional.', - poor: 'No demonstration provided.', + ], + demo: [ + { + name: 'Presentation Clarity', + weight: 10, + description: 'Clarity and effectiveness of the presentation delivery.', + levels: { + excellent: + 'Very clear, well-structured, engaging, and easy to understand for the audience.', + good: 'Clear presentation and generally easy to understand.', + average: 'Adequately clear but may lack engagement or focus.', + acceptable: 'Hard to understand; inconsistent structure.', + poor: 'No satisfactory presentation or not delivered.', + }, }, - }, - ], - individual: [ - { - name: 'Individual Contribution', - weight: 15, - description: 'Demonstrated effort and contribution to the project.', - levels: { - excellent: 'Demonstrates exceptional effort and contribution.', - good: 'Demonstrates significant effort and contribution.', - average: 'Demonstrates adequate effort and contribution.', - acceptable: 'Demonstrates minimal effort and contribution.', - poor: 'No discernible contribution.', + { + name: 'Technical Demonstration', + weight: 10, + description: 'Quality and functionality of the technical demonstration.', + levels: { + excellent: + 'All key features/components function optimally and are clearly demonstrated.', + good: 'Most key features function well; some minor aspects are suboptimal.', + average: 'Only some features work; several technical issues present.', + acceptable: 'Incomplete demonstration or frequent failures during demonstration.', + poor: 'No functional technical demonstration.', + }, }, - }, - ], - }, - markingScale: 'Marking Scale: 1 - Poor, 2 - Acceptable, 3 - Average, 4 - Good, 5- Excellent.', - totalMarks: 100, - reportWeight: 55, - demoWeight: 30, - individualWeight: 15, -}) + ], + individual: [ + { + name: 'Individual Contribution', + weight: 15, + description: 'Effort and contributions of members to the project.', + levels: { + excellent: 'Outstanding, proactive, consistent, and high-value contributions.', + good: 'Significant and consistent contributions of good quality.', + average: 'Adequate contributions; some parts completed but not standout.', + acceptable: 'Minimal contributions; unclear or inconsistent role.', + poor: 'No evident contribution to the project.', + }, + }, + ], + }, + markingScale: 'Grading Scale: 1 - Poor, 2 - Acceptable, 3 - Average, 4 - Good, 5 - Excellent.', + totalMarks: 100, + reportWeight: 55, + demoWeight: 30, + individualWeight: 15, + } +} // Modify the generateProjectRubric function to separate generation and combination async function generateProjectRubric( difficultyLevel: string, ollama: OllamaFn, selectedModel: string, - assistantMessage: CoreMessage, + assistantMessage: ModelMessage, courseInfo: CourseInfo, + language: 'en' | 'id', ): Promise { console.log(`Generating project rubric for ${difficultyLevel} level course...`) @@ -366,6 +756,7 @@ async function generateProjectRubric( selectedModel, assistantMessage, courseInfo, + language, ) // Step 2: Generate demo criteria @@ -376,6 +767,7 @@ async function generateProjectRubric( selectedModel, assistantMessage, courseInfo, + language, ) // Step 3: Generate individual criteria @@ -386,10 +778,11 @@ async function generateProjectRubric( selectedModel, assistantMessage, courseInfo, + language, ) // Step 4: Combine all criteria into a complete rubric - const defaultRubric = getDefaultProjectRubric() + const defaultRubric = getDefaultProjectRubric(language) const combinedRubric: ProjectRubric = { categories: { @@ -404,7 +797,10 @@ async function generateProjectRubric( ? individualCriteria : defaultRubric.categories.individual, }, - markingScale: 'Marking Scale: 1 - Poor, 2 - Acceptable, 3 - Average, 4 - Good, 5- Excellent.', + markingScale: + language === 'id' + ? 'Skala Penilaian: 1 - Sangat Kurang, 2 - Cukup, 3 - Sedang, 4 - Baik, 5 - Sangat Baik.' + : 'Grading Scale: 1 - Poor, 2 - Acceptable, 3 - Average, 4 - Good, 5 - Excellent.', totalMarks: 100, reportWeight: 55, demoWeight: 30, @@ -416,8 +812,79 @@ async function generateProjectRubric( } catch (error) { console.error('Error generating complete project rubric:', error) // Return default rubric if generation fails - return getDefaultProjectRubric() + return getDefaultProjectRubric(language) + } +} + +// Helper function to manually extract criteria from text when JSON parsing fails +function extractCriteriaFromText(text: string, language: 'en' | 'id'): ProjectRubricCriterion[] { + const criteria: ProjectRubricCriterion[] = [] + + // Look for numbered items or bullet points that might contain criteria + const lines = text.split('\n') + let currentCriterion: Partial | null = null + + for (const line of lines) { + const trimmed = line.trim() + + // Look for numbered criteria (1., 2., etc.) or bullet points + const criterionMatch = trimmed.match( + /^(?:\d+\.|\*|-)\s*\*?\*?([^(]+?)(?:\s*\((\d+)%?\))?\*?\*?/, + ) + if (criterionMatch) { + // Save previous criterion if exists + if (currentCriterion && currentCriterion.name) { + criteria.push({ + name: currentCriterion.name, + weight: currentCriterion.weight || 20, + levels: currentCriterion.levels || getDefaultLevels(language), + }) + } + + // Start new criterion + currentCriterion = { + name: criterionMatch[1].trim(), + weight: criterionMatch[2] ? parseInt(criterionMatch[2]) : 20, + levels: getDefaultLevels(language), + } + } + } + + // Add last criterion if exists + if (currentCriterion && currentCriterion.name) { + criteria.push({ + name: currentCriterion.name, + weight: currentCriterion.weight || 20, + levels: currentCriterion.levels || getDefaultLevels(language), + }) } + + return criteria +} + +// Helper function to get default level descriptions +function getDefaultLevels(language: 'en' | 'id'): { + excellent: string + good: string + average: string + acceptable: string + poor: string +} { + return language === 'id' + ? { + excellent: 'Menunjukkan pemahaman yang luar biasa dan penerapan yang sangat baik', + good: 'Menunjukkan pemahaman yang baik dan penerapan yang tepat', + average: 'Menunjukkan pemahaman yang memadai dengan beberapa kekurangan', + acceptable: 'Menunjukkan pemahaman dasar dengan kekurangan yang jelas', + poor: 'Menunjukkan pemahaman yang terbatas atau tidak memadai', + } + : { + excellent: 'Demonstrates exceptional understanding and excellent application', + good: 'Shows good understanding and appropriate application', + average: 'Shows adequate understanding with some shortcomings', + acceptable: 'Shows basic understanding with clear deficiencies', + poor: 'Shows limited or inadequate understanding', + } } // Add a new function to generate each section of the rubric separately @@ -426,103 +893,209 @@ async function generateRubricSection( difficultyLevel: string, ollama: OllamaFn, selectedModel: string, - assistantMessage: CoreMessage, + assistantMessage: ModelMessage, courseInfo: CourseInfo, + language: 'en' | 'id', ): Promise { console.log(`Generating ${section} criteria for ${difficultyLevel} level course...`) + const hasSourceMaterials = (assistantMessage.content as string).includes('SOURCE MATERIALS:') - const sectionTitles = { - report: 'Report', - demo: 'Demo Presentation', - individual: 'Individual Contribution', - } + const sectionTitles = + language === 'id' + ? { + report: 'Laporan', + demo: 'Presentasi Demo', + individual: 'Kontribusi Individu', + } + : { + report: 'Report', + demo: 'Demo Presentation', + individual: 'Individual Contribution', + } - const systemPrompt = `You are an expert educational assessment developer for a university course. Create ${sectionTitles[section]} criteria for a ${difficultyLevel} level course in ${courseInfo.courseName || 'Big Data Storage and Management'}. + const systemPrompt = + language === 'id' + ? `${langDirective(language)}\n\nAnda adalah pengembang asesmen pendidikan ahli untuk mata kuliah universitas. Buat kriteria ${sectionTitles[section]} untuk mata kuliah tingkat ${difficultyLevel} pada ${courseInfo.courseName || 'Big Data Storage and Management'}. -IMPORTANT INSTRUCTIONS: -1. Focus ONLY on creating criteria for the ${sectionTitles[section]} section. -2. For each criterion, provide detailed descriptions for each level: Excellent (5), Good (4), Average (3), Acceptable (2), and Poor (1). -3. Your response MUST be valid JSON only. +INSTRUKSI PENTING: +${hasSourceMaterials ? '0. Gunakan HANYA materi sumber yang diberikan sebagai dasar kriteria.\n' : ''} +1. Fokus HANYA membuat kriteria untuk bagian ${sectionTitles[section]}. +2. Untuk setiap kriteria, berikan deskripsi rinci untuk tiap level: Sangat Baik (5), Baik (4), Sedang (3), Cukup (2), dan Sangat Kurang (1). +3. Seluruh keluaran HARUS menggunakan bahasa target yang diminta tanpa mencampur bahasa. +4. Respons HARUS berupa JSON valid saja - TIDAK ADA markdown, heading, atau teks lain. +5. MULAI langsung dengan '[' dan AKHIRI dengan ']' tanpa ada teks tambahan. +Catatan: Jangan menyalin atau mengutip teks dari materi sumber yang bukan dalam bahasa target. -RESPONSE FORMAT: +FORMAT RESPON (HANYA JSON): [ { - "name": "Criterion 1", + "name": "Kriteria 1", "weight": 10, + "description": "Deskripsi singkat kriteria", "levels": { - "excellent": "Description for excellent performance", - "good": "Description for good performance", - "average": "Description for average performance", - "acceptable": "Description for acceptable performance", - "poor": "Description for poor performance" + "excellent": "Deskripsi performa tingkat sangat baik", + "good": "Deskripsi performa tingkat baik", + "average": "Deskripsi performa tingkat sedang", + "acceptable": "Deskripsi performa tingkat cukup", + "poor": "Deskripsi performa tingkat sangat kurang" } - }, + } +] + +PENTING: Jangan gunakan format markdown (**, ##, dll). Hanya JSON murni.` + : `${langDirective(language)}\n\nYou are an expert assessment designer for university courses. Create ${sectionTitles[section]} criteria for a ${difficultyLevel} level course ${ + courseInfo.courseName || 'Big Data Storage and Management' + }. + +CRITICAL INSTRUCTIONS: +${hasSourceMaterials ? '0. Use ONLY the provided source materials as the basis for the criteria.\n' : ''} +1. Focus ONLY on criteria for the ${sectionTitles[section]} section. +2. For each criterion, provide detailed descriptions for each level: Excellent (5), Good (4), Average (3), Acceptable (2), and Poor (1). +3. The output MUST be entirely in the requested target language with no language mixing. +4. The response MUST be valid JSON only - NO markdown, headings, or other text. +5. START directly with '[' and END with ']' with no additional text. +Note: Do not copy or quote any text from the source materials that is not in the target language. + +RESPONSE FORMAT (JSON ONLY): +[ { - "name": "Criterion 2", + "name": "Criterion 1", "weight": 10, + "description": "One-sentence description of the criterion", "levels": { - "excellent": "Description for excellent performance", - "good": "Description for good performance", - "average": "Description for average performance", - "acceptable": "Description for acceptable performance", - "poor": "Description for poor performance" + "excellent": "Description of excellent performance", + "good": "Description of good performance", + "average": "Description of average performance", + "acceptable": "Description of acceptable performance", + "poor": "Description of poor performance" } } ] -DO NOT include any text, markdown, explanations, or other content outside the JSON array.` +IMPORTANT: Do not use markdown formatting (**, ##, etc.). Pure JSON only.` - const systemMessage: CoreMessage = { + const systemMessage: ModelMessage = { role: 'system', content: systemPrompt, } - const userMessage: CoreMessage = { + const userMessage: ModelMessage = { role: 'user', - content: `Generate ${sectionTitles[section]} criteria for ${courseInfo.courseCode || 'CDS502'} ${courseInfo.courseName || 'Big Data Storage and Management'}.`, + content: + language === 'id' + ? `Hasilkan kriteria ${sectionTitles[section]} untuk ${courseInfo.courseCode || 'CDS502'} ${courseInfo.courseName || 'Big Data Storage and Management'}.` + : `Generate ${sectionTitles[section]} criteria for ${courseInfo.courseCode || 'CDS502'} ${courseInfo.courseName || 'Big Data Storage and Management'}.`, } try { + // Try structured generation first for better JSON compliance + try { + const { object } = await generateObject({ + model: ollama(selectedModel), + output: 'no-schema', + messages: [systemMessage, assistantMessage, userMessage], + temperature: TEMPERATURE, + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 3), + }) + + if (object && Array.isArray(object)) { + console.log(`Successfully generated ${section} criteria via generateObject`) + const criteria = object as unknown as ProjectRubricCriterion[] + + // Batch enforcement to reduce latency + const languageEnforcedCriteria = needLanguageEnforcementForCriteria(criteria, language) + ? await enforceRubricLanguage(criteria, language, ollama, selectedModel) + : criteria + console.log(`Language enforcement (batched) completed for ${section} criteria`) + return languageEnforcedCriteria + } + console.log( + `generateObject returned unexpected shape for ${section}, falling back to text parsing`, + ) + } catch (e) { + console.log(`generateObject failed for ${section} criteria, falling back to generateText:`, e) + } + + // Fallback to text generation const response = await generateText({ model: ollama(selectedModel), messages: [systemMessage, assistantMessage, userMessage], temperature: TEMPERATURE, - maxTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 3), // Ensure integer by using Math.floor + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 3), // Ensure integer by using Math.floor }) - console.log(`${section} criteria response:`, response.text.substring(0, 100) + '...') + const cleaned = stripThinkTags(response.text) + console.log(`${section} criteria response:`, cleaned.substring(0, 100) + '...') + + // Strip markdown formatting that might interfere with JSON parsing + const jsonContent = cleaned + .replace(/^\s*#.*$/gm, '') // Remove headings + .replace(/\*\*(.*?)\*\*/g, '$1') // Remove bold formatting + .replace(/^\s*\d+\.\s*/gm, '') // Remove numbered lists + .replace(/^\s*-\s*/gm, '') // Remove bullet points + .trim() + + console.log( + `${section} criteria after markdown cleanup:`, + jsonContent.substring(0, 200) + '...', + ) - // Try to parse the response as JSON try { - const criteria = JSON.parse(response.text) + const criteria = JSON.parse(jsonContent) if (Array.isArray(criteria)) { console.log(`Successfully parsed ${section} criteria directly`) - return criteria + + const languageEnforcedCriteria = needLanguageEnforcementForCriteria(criteria, language) + ? await enforceRubricLanguage(criteria, language, ollama, selectedModel) + : criteria + console.log(`Language enforcement (batched) completed for ${section} criteria`) + return languageEnforcedCriteria } } catch { console.log(`Direct parsing of ${section} criteria failed, trying JSON extraction`) } - // Try to extract JSON from the response - const jsonStr = extractJsonFromText(response.text) + const jsonStr = extractJsonFromText(jsonContent) if (jsonStr) { try { const criteria = JSON.parse(jsonStr) if (Array.isArray(criteria)) { console.log(`Successfully extracted and parsed ${section} criteria JSON`) - return criteria + + const languageEnforcedCriteria = needLanguageEnforcementForCriteria(criteria, language) + ? await enforceRubricLanguage(criteria, language, ollama, selectedModel) + : criteria + console.log(`Language enforcement (batched) completed for extracted ${section} criteria`) + return languageEnforcedCriteria } } catch (e) { console.error(`Failed to parse extracted ${section} criteria JSON:`, e) } } + // If all extraction methods fail, try to extract criteria from the text manually + console.log(`Attempting manual criteria extraction for ${section}`) + const manualCriteria = extractCriteriaFromText(jsonContent, language) + if (manualCriteria.length > 0) { + console.log( + `Successfully extracted ${manualCriteria.length} criteria manually for ${section}`, + ) + + const languageEnforcedCriteria = needLanguageEnforcementForCriteria(manualCriteria, language) + ? await enforceRubricLanguage(manualCriteria, language, ollama, selectedModel) + : manualCriteria + console.log( + `Language enforcement (batched) completed for manually extracted ${section} criteria`, + ) + return languageEnforcedCriteria + } + // If all extraction methods fail, return default criteria for this section console.log(`Using default ${section} criteria due to parsing failure`) - return getDefaultProjectRubric().categories[section] + return getDefaultProjectRubric(language).categories[section] } catch (error) { console.error(`Error generating ${section} criteria:`, error) - return getDefaultProjectRubric().categories[section] + return getDefaultProjectRubric(language).categories[section] } } @@ -531,53 +1104,41 @@ async function generateProjectDescription( difficultyLevel: string, ollama: OllamaFn, selectedModel: string, - assistantMessage: CoreMessage, + assistantMessage: ModelMessage, courseInfo: CourseInfo, + language: 'en' | 'id', ): Promise { console.log(`Generating project description for ${difficultyLevel} level course...`) - // Determine if we have source materials or need to use course info only const hasSourceMaterials = (assistantMessage.content as string).includes('SOURCE MATERIALS:') - - const systemPrompt = `You are an expert educational assessment developer for a university course. Create a comprehensive project description for a ${difficultyLevel} level course in ${courseInfo.courseName || 'Big Data Storage and Management'} ${hasSourceMaterials ? 'based STRICTLY on the provided source materials' : 'based on standard curriculum for this subject'}. - -IMPORTANT INSTRUCTIONS: -${ - hasSourceMaterials - ? ` -1. You MUST base your content ENTIRELY on the source materials provided. -2. Extract key concepts, terminology, examples, and explanations directly from the source materials. -3. Do not introduce concepts or information that is not present in the source materials.` - : ` -1. As no source materials are provided, base your project on standard curriculum content for ${courseInfo.courseName || 'this subject'}. -2. Focus on core concepts, theories, and applications typically covered in ${courseInfo.courseCode || ''} ${courseInfo.courseName || 'this type of course'}. -3. Ensure the project is academically rigorous and appropriate for university-level education.` -} -4. Create a detailed project description with clear deliverables and requirements. -5. Include specific instructions for both report and presentation components. -6. The project should be designed for groups of ${courseInfo.groupSize || 4} students. -7. The project should be challenging but achievable within ${courseInfo.duration || '2 weeks'}. -8. Include the following sections exactly, each of the following titles should be bold: - - Instruction - - Project Description - - Deliverables - - Report Structure - - Presentation Requirements - - Submission Guidelines - - Deadline Information -9. Format the output in Markdown. -10. For text formatting, use only bold for headings, "**" is allowed but not "#". - -FORMAT YOUR RESPONSE AS A COMPLETE PROJECT DESCRIPTION DOCUMENT, not as JSON. Include all necessary formatting, headers, and sections.` - - const systemMessage: CoreMessage = { + console.log('=== PROJECT DESCRIPTION GENERATION ===') + console.log('Has source materials:', hasSourceMaterials) + console.log('Language:', language) + console.log('Will use source-based prompts:', hasSourceMaterials) + console.log('Assistant message content length:', (assistantMessage.content as string).length) + console.log( + 'Assistant message preview:', + (assistantMessage.content as string).substring(0, 200) + '...', + ) + console.log('=== END PROJECT DESCRIPTION DEBUG ===') + + // Use modular prompt builder for project description + const projectPrompts = await import('./prompts/project') + const systemPrompt = projectPrompts.buildProjectDescriptionSystemPrompt( + difficultyLevel, + courseInfo, + language, + hasSourceMaterials, + ) + + const systemMessage: ModelMessage = { role: 'system', content: systemPrompt, } - const userMessage: CoreMessage = { + const userMessage: ModelMessage = { role: 'user', - content: `Generate a comprehensive project description for ${courseInfo.courseCode || 'CDS502'} ${courseInfo.courseName || 'Big Data Storage and Management'} ${hasSourceMaterials ? 'based STRICTLY on the provided source materials' : 'based on standard curriculum for this subject'}. The project should be for ${courseInfo.semester || 'Semester 1'}, ${courseInfo.academicYear || '2023/2024'} with a deadline of ${courseInfo.deadline || '10th January 2024, by 6:15 pm'}.`, + content: projectPrompts.buildProjectDescriptionUserPrompt(courseInfo, language), } try { @@ -585,64 +1146,178 @@ FORMAT YOUR RESPONSE AS A COMPLETE PROJECT DESCRIPTION DOCUMENT, not as JSON. In model: ollama(selectedModel), messages: [systemMessage, assistantMessage, userMessage], temperature: TEMPERATURE + 0.1, - maxTokens: Math.floor(TOKEN_RESPONSE_BUDGET), + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET), }) + let cleaned = stripThinkTags(response.text) + console.log( + 'Raw project description (before language enforcement):', + cleaned.substring(0, 200) + '...', + ) + + // Always ensure final text adheres to the selected language (regardless of source language) + // But be careful not to override source-based content with course-based content + if (hasSourceMaterials) { + console.log( + 'Skipping language enforcement for source-based content to preserve source fidelity', + ) + // For source-based content, only do minimal language enforcement to avoid content drift + const detected = detectLikelyLanguage(cleaned) + if (detected !== language && detected !== 'unknown') { + console.log( + `Language mismatch detected (${detected} vs ${language}), but preserving source-based content`, + ) + } + } else { + cleaned = await ensureTargetLanguageText(cleaned, language, ollama, selectedModel) + console.log('Applied language enforcement for course-based content') + } + + console.log('Final project description:', cleaned.substring(0, 200) + '...') console.log('Project description generated successfully') - return response.text.trim() + return cleaned } catch (error) { console.error('Error generating project description:', error) - return ` -School of Computer Sciences, Universiti Sains Malaysia + + // If sources were available but generation failed, return a more generic fallback + if (hasSourceMaterials) { + return language === 'id' + ? `**Instruksi Proyek** + +Berdasarkan materi sumber yang disediakan, buatlah proyek yang menunjukkan pemahaman mendalam terhadap konsep dan teknologi yang dibahas dalam materi tersebut. + +**Deliverables:** +• Laporan komprehensif yang menganalisis dan menerapkan konsep dari materi sumber +• Implementasi praktis atau demonstrasi teknis +• Presentasi yang menjelaskan metodologi dan temuan + +**Persyaratan:** +• Gunakan pendekatan yang sesuai dengan teknologi dan metodologi yang dijelaskan dalam materi sumber +• Analisis mendalam terhadap masalah yang diidentifikasi +• Rekomendasi berdasarkan temuan + +Durasi: ${courseInfo.duration || '2 minggu'} +` + : `**Project Instructions** + +Based on the provided source materials, create a project that demonstrates deep understanding of the concepts and technologies discussed in the materials. + +**Deliverables:** +• Comprehensive report analyzing and applying concepts from source materials +• Practical implementation or technical demonstration +• Presentation explaining methodology and findings + +**Requirements:** +• Use approaches consistent with technologies and methodologies explained in source materials +• In-depth analysis of identified problems +• Recommendations based on findings + +Duration: ${courseInfo.duration || '2 weeks'} +` + } + + // Course-based fallback when no sources were available + return language === 'id' + ? ` +Sekolah Ilmu Komputer, Universiti Sains Malaysia -Deadline for submission is ${courseInfo.deadline || '10th January 2024'}, by 6:15 pm. Online submission via elearn. +Batas waktu pengumpulan adalah ${courseInfo.deadline || '10 Januari 2024'}, pukul 6:15 sore. Pengumpulan daring melalui e-learn. ${courseInfo.courseCode || 'CDS502'} ${courseInfo.courseName || 'Big Data Storage and Management'} ${courseInfo.semester || 'Semester 1'}, ${courseInfo.academicYear || '2023/2024'} -PROJECT (20%) – REPORT & PRESENTATION -(Group Work: Up to ${courseInfo.groupSize || 4} members per group) +PROYEK (20%) – LAPORAN & PRESENTASI +(Kerja Kelompok: Maksimal ${courseInfo.groupSize || 4} anggota per kelompok) -Instruction: The project will be evaluated based on group work and as well as individual performance via written report and group presentation. Every group must submit a written report and provide a group presentation. Group formation is conducted via Padlet link provided in the elearn portal. +Instruksi: Proyek akan dievaluasi berdasarkan kerja kelompok serta kontribusi individu melalui laporan tertulis dan presentasi kelompok. Setiap kelompok harus menyerahkan laporan tertulis dan melakukan presentasi. Pembentukan kelompok dilakukan melalui tautan Padlet yang tersedia di portal e-learn. -Deadline: ${courseInfo.deadline || '10th January 2024'} (6:15 pm), submit your softcopy of your report/slides & source codes through e-learning portal during the class time. Group presentation will be conducted in the class for two weeks. Random drawing will be made to determine which groups to present. +Batas Waktu: ${courseInfo.deadline || '10 Januari 2024'} (6:15 sore). Serahkan softcopy laporan/slide & kode sumber melalui portal e-learning pada waktu kelas. Presentasi kelompok akan dilaksanakan di kelas selama dua minggu. Penentuan urutan presentasi dilakukan secara acak. -Project Description: Each group should identify a dataset related to the course content. Build appropriate data storage and processing solutions based on the course materials. You may need to download and install necessary software or use cloud services. You may use your local machine i.e laptop or cloud services i.e. Google Cloud, Amazon etc, or container technology i.e. Docker to set up your environment. Enter the data set into the databases. Run at least four meaningful queries that are best describing the data. Compare and discuss their performance in terms of ease of use, creating queries and data processing speed. +Deskripsi Proyek: Setiap kelompok harus mengidentifikasi sebuah dataset yang relevan dengan konten mata kuliah. Bangun solusi penyimpanan dan pemrosesan data yang sesuai berdasarkan materi kuliah. Anda mungkin perlu mengunduh dan memasang perangkat lunak yang diperlukan atau menggunakan layanan cloud. Anda dapat memakai mesin lokal (laptop) atau layanan cloud (Google Cloud, Amazon, dll.) atau teknologi kontainer (Docker) untuk menyiapkan lingkungan. Masukkan dataset ke dalam basis data. Jalankan minimal empat kueri yang bermakna yang paling menggambarkan data. Bandingkan dan diskusikan performanya dalam hal kemudahan penggunaan, pembuatan kueri, dan kecepatan pemrosesan data. -Your deliverables must include the following requirements: -• Chosen platform for implementation -• Installation process and data entry -• At least five meaningful queries or operations -• Compare and discuss their performance -• Recommendation & lesson learned +Deliverables wajib mencakup: +• Platform yang dipilih untuk implementasi +• Proses instalasi dan pemasukan data +• Minimal lima kueri atau operasi bermakna +• Perbandingan dan diskusi performa +• Rekomendasi & pelajaran yang dipetik -Below are some points that guide you in preparing the report: -i. Abstract -ii. Introduction -iii. Project Content - 1. Brief description of the given dataset - 2. Selection of implementation platform - 3. Installation process, system construction and data entry - 4. At least 4 meaningful operations - 5. Comparison, discussion and recommendation - 6. Concluding remarks -iv. Lesson learned from the project -v. Clear division of group members' roles -vi. Conclusion -vii. References (At least 8 references which include 4 journal papers) -viii. Appendices (If any) +Panduan penyusunan laporan: +i. Abstrak +ii. Pendahuluan +iii. Konten Proyek + 1. Deskripsi singkat dataset + 2. Pemilihan platform implementasi + 3. Proses instalasi, konstruksi sistem, dan pemasukan data + 4. Minimal 4 operasi bermakna + 5. Perbandingan, diskusi, dan rekomendasi + 6. Pernyataan penutup +iv. Pelajaran yang dipetik dari proyek +v. Pembagian peran anggota kelompok secara jelas +vi. Kesimpulan +vii. Referensi (Minimal 8 referensi termasuk 4 artikel jurnal) +viii. Lampiran (Jika ada) -Marking Scheme: refer to the rubrics posted on the elearn +Skema Penilaian: lihat rubrik yang diunggah pada e-learn. -For the in class presentation, each group is allocated about 15 minutes including Q & A: -• Everyone in the group is expected to present some portion of the project +Untuk presentasi di kelas, setiap kelompok mendapat waktu sekitar 15 menit termasuk tanya jawab: +• Setiap anggota diharapkan mempresentasikan bagian tugasnya. -Submit the following together with well formatted report (One submission per group): -• IEEE format (refer to the elearn for the sample template) -• Soft copy - (Report + source codes and slides): e-learning +Kumpulkan berikut ini bersama laporan yang diformat baik (satu pengumpulan per kelompok): +• Format IEEE (lihat contoh templat di e-learn) +• Soft copy - (Laporan + kode sumber dan slide): e-learning -Note: -The report should include an appendix indicating detailed descriptions on contributions of each group member in the project. In the event that parts of the report are directly copied from others without references, F grade is given. +Catatan: +Laporan harus menyertakan lampiran yang menjelaskan secara rinci kontribusi setiap anggota kelompok. Jika bagian laporan disalin langsung tanpa referensi, nilai F akan diberikan. +` + : ` +School of Computer Science, Universiti Sains Malaysia + +Submission deadline is ${courseInfo.deadline || 'January 10, 2024'}, at 6:15 pm. Online submission via e-learn. + +${courseInfo.courseCode || 'CDS502'} ${courseInfo.courseName || 'Big Data Storage and Management'} +${courseInfo.semester || 'Semester 1'}, ${courseInfo.academicYear || '2023/2024'} + +PROJECT (20%) – REPORT & PRESENTATION +(Group Work: Maximum ${courseInfo.groupSize || 4} members per group) + +Instructions: The project will be evaluated based on group work and individual contributions through a written report and a group presentation. Each group must submit a written report and deliver a presentation. Group formation is done via the Padlet link available on the e-learn portal. + +Deadline: ${courseInfo.deadline || 'January 10, 2024'} (6:15 pm). Submit the soft copy of the report/slides & source code via the e-learning portal during class time. Group presentations will be conducted in class over two weeks. The presentation order will be randomized. + +Project Description: Each group must identify a dataset relevant to the course content. Build an appropriate data storage and processing solution based on the course material. You may need to download and install required software or use cloud services. You may use a local machine (laptop) or cloud services (Google Cloud, Amazon, etc.) or container technologies (Docker) to set up the environment. Ingest the dataset into the database. Execute at least four meaningful queries that best represent the data. Compare and discuss performance in terms of ease of use, query creation, and data processing speed. + +Required Deliverables: +• Platform chosen for implementation +• Installation process and data ingestion +• At least five meaningful queries or operations +• Performance comparison and discussion +• Recommendations & lessons learned + +Report Guidelines: +i. Abstract +ii. Introduction +iii. Project Content + 1. Brief dataset description + 2. Implementation platform selection + 3. Installation, system construction, and data ingestion process + 4. At least 4 meaningful operations + 5. Comparison, discussion, and recommendations + 6. Closing statement +iv. Lessons learned from the project +v. Clear division of group member roles +vi. Conclusion +vii. References (Minimum 8 references including 4 journal articles) +viii. Appendix (If any) + +Assessment Scheme: refer to the rubric uploaded on e-learn. + +For in-class presentations, each group has approximately 15 minutes including Q&A: +• Each member is expected to present their task component. + +Submit the following along with a well-formatted report (one submission per group): +• IEEE format (see template example in e-learn) +• Soft copy - (Report + source code and slides): e-learning ` } } @@ -653,8 +1328,9 @@ async function generateQuestions( numQuestions: number, ollama: OllamaFn, selectedModel: string, - assistantMessage: CoreMessage, + assistantMessage: ModelMessage, courseInfo?: CourseInfo, + language: 'en' | 'id' = 'en', ): Promise { console.log(`Generating ${numQuestions} questions for ${assessmentType} assessment...`) @@ -679,6 +1355,7 @@ async function generateQuestions( selectedModel, assistantMessage, courseInfo, + language, ) console.log('Project description generated successfully') @@ -695,21 +1372,21 @@ async function generateQuestions( return [ { question: ` -Project Title: ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || ''} Project +Judul Proyek: ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || ''} Project -Instructions: This project is designed to assess your understanding of the course material. -Please work in groups of ${courseInfo?.groupSize || 4} to complete this project. +Instruksi: Proyek ini dirancang untuk menilai pemahaman Anda terhadap materi kuliah. +Silakan bekerja dalam kelompok beranggotakan ${courseInfo?.groupSize || 4} untuk menyelesaikan proyek ini. -Project Description: -Create a comprehensive project that demonstrates your understanding of the key concepts covered in this course. -Your project should include both implementation and documentation components. +Deskripsi Proyek: +Buat sebuah proyek komprehensif yang menunjukkan pemahaman Anda terhadap konsep-konsep kunci yang dibahas dalam mata kuliah ini. +Proyek harus mencakup komponen implementasi dan dokumentasi. -Deliverables: -1. A detailed report explaining your approach, methodology, and findings -2. Source code or implementation files -3. A presentation summarizing your project +Deliverables (Hasil yang Harus Dikumpulkan): +1. Laporan rinci yang menjelaskan pendekatan, metodologi, dan temuan +2. Kode sumber atau berkas implementasi +3. Presentasi yang merangkum proyek -Deadline: ${courseInfo?.deadline || 'End of semester'} +Batas Waktu: ${courseInfo?.deadline || 'Akhir semester'} `, type: 'project', }, @@ -721,40 +1398,104 @@ Deadline: ${courseInfo?.deadline || 'End of semester'} const hasSourceMaterials = (assistantMessage.content as string).includes('SOURCE MATERIALS:') // Standard question generation for non-project assessments - const systemPrompt = `You are an expert educational assessment developer specializing in ${courseInfo?.courseName || 'the subject area'}. Generate ${numQuestions} unique questions for a ${difficultyLevel} level ${assessmentType} assessment. + // Use modular prompt builder only for exam; keep inline prompts otherwise + let systemPrompt: string + if (assessmentType.toLowerCase() === 'exam') { + const examPrompts = await import('./prompts/exam') + systemPrompt = examPrompts.buildExamQuestionsSystemPrompt( + difficultyLevel, + assessmentType, + courseInfo, + language, + hasSourceMaterials, + numQuestions, + ) + } else { + systemPrompt = + language === 'id' + ? `${langDirective(language)}\n\nAnda adalah pengembang asesmen pendidikan ahli dalam bidang ${ + courseInfo?.courseName || 'mata kuliah ini' + }. Hasilkan ${numQuestions} pertanyaan unik untuk asesmen ${assessmentType} tingkat ${difficultyLevel}. -IMPORTANT INSTRUCTIONS: +INSTRUKSI PENTING: ${ hasSourceMaterials - ? ` -1. You MUST base your content ENTIRELY on the source materials provided. -2. Extract key concepts, terminology, examples, and explanations directly from the source materials. -3. Do not introduce concepts or information that is not present in the source materials.` - : ` -1. As no source materials are provided, base your questions on standard curriculum content for ${courseInfo?.courseName || 'this subject'}. -2. Focus on core concepts, theories, and applications typically covered in ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this type of course'}. -3. Ensure questions are academically rigorous and appropriate for university-level education.` + ? `1. Anda HARUS mendasarkan seluruh konten SEPENUHNYA pada materi sumber yang disediakan. +2. Ambil konsep kunci, terminologi, contoh, dan penjelasan langsung dari materi sumber. +3. Jangan perkenalkan konsep atau informasi yang tidak ada dalam materi sumber. +4. Seluruh keluaran HARUS menggunakan bahasa target yang diminta tanpa mencampur bahasa. +Catatan: Jangan menyalin atau mengutip teks dari materi sumber yang bukan dalam bahasa target.` + : `1. Karena tidak ada materi sumber, dasarkan pertanyaan pada kurikulum standar untuk ${ + courseInfo?.courseName || 'mata kuliah ini' + }. +2. Fokus pada konsep inti, teori, dan aplikasi umum. +3. Pastikan tingkat akademik sesuai konteeks universitas. +4. Seluruh keluaran HARUS menggunakan bahasa target yang diminta tanpa mencampur bahasa.` } -4. Ensure that the questions are diverse and cover a range of topics. -5. Your response MUST be a JSON array of strings. +4. Pertanyaan harus beragam dan mencakup berbagai topik. +5. Respons HARUS berupa array JSON string. -RESPONSE FORMAT: +FORMAT: +[ + "Pertanyaan 1", + "Pertanyaan 2" +] + +JANGAN sertakan teks di luar array JSON.` + : `${langDirective(language)}\n\nYou are an expert assessment designer in ${ + courseInfo?.courseName || 'this course' + }. Generate ${numQuestions} unique questions for a ${difficultyLevel}-level ${assessmentType} assessment. + +CRITICAL INSTRUCTIONS: +${ + hasSourceMaterials + ? `1. You MUST base ALL content ENTIRELY on the provided source materials. +2. Derive key concepts, terminology, examples, and explanations directly from the sources. +3. Do not introduce concepts or information not present in the sources. +4. The output MUST be entirely in the requested target language with no language mixing. +Note: Do not copy or quote any text from the source materials that is not in the target language.` + : `1. Since there are no source materials, base the questions on the standard curriculum for ${ + courseInfo?.courseName || 'this course' + }. +2. Focus on core concepts, theories, and common applications. +3. Ensure the academic level fits a university context. +4. The output MUST be entirely in the requested target language with no language mixing.` +} +4. Questions should be diverse and cover multiple topics. +5. The response MUST be a JSON array of strings. + +FORMAT: [ "Question 1", - "Question 2", - "Question 3" + "Question 2" ] -DO NOT include any text, markdown, explanations, or other content outside the JSON array.` +DO NOT include any text outside the JSON array.` + } - const systemMessage: CoreMessage = { + const systemMessage: ModelMessage = { role: 'system', content: systemPrompt, } - const userMessage: CoreMessage = { + const userMessage: ModelMessage = { role: 'user', - content: `Generate ${numQuestions} unique questions for the ${assessmentType} assessment for ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this course'}.`, + content: + assessmentType.toLowerCase() === 'exam' + ? (await import('./prompts/exam')).buildExamQuestionsUserPrompt( + hasSourceMaterials, + courseInfo, + language, + numQuestions, + assessmentType, + ) + : language === 'id' + ? `Hasilkan ${numQuestions} pertanyaan unik untuk asesmen ${assessmentType} pada mata kuliah ${ + courseInfo?.courseCode || '' + } ${courseInfo?.courseName || 'mata kuliah ini'}. Jawab dalam format yang diminta.` + : `Generate ${numQuestions} unique questions for the ${assessmentType} assessment in the course ${ + courseInfo?.courseCode || '' + } ${courseInfo?.courseName || 'this course'}. Follow the requested output format.`, } try { @@ -762,14 +1503,14 @@ DO NOT include any text, markdown, explanations, or other content outside the JS model: ollama(selectedModel), messages: [systemMessage, assistantMessage, userMessage], temperature: TEMPERATURE, - maxTokens: Math.floor(TOKEN_MAX / 2), + maxOutputTokens: Math.floor(TOKEN_MAX / 2), }) - console.log('Questions response:', response.text) + const cleaned = stripThinkTags(response.text) + console.log('Questions response:', cleaned) - // Try to parse the response as JSON try { - const questions = JSON.parse(response.text) + const questions = JSON.parse(cleaned) if (Array.isArray(questions)) { console.log('Successfully parsed questions directly') return questions @@ -778,8 +1519,7 @@ DO NOT include any text, markdown, explanations, or other content outside the JS console.log('Direct parsing failed, trying JSON extraction') } - // Try to extract JSON from the response - const jsonStr = extractJsonFromText(response.text) + const jsonStr = extractJsonFromText(cleaned) if (jsonStr) { try { const questions = JSON.parse(jsonStr) @@ -795,7 +1535,7 @@ DO NOT include any text, markdown, explanations, or other content outside the JS // If all extraction methods fail, extract questions from the response text console.log('Extracting questions from response text') const questionsArray: string[] = [] - const lines = response.text.split('\n') + const lines = cleaned.split('\n') for (const line of lines) { const trimmedLine = line.trim() if (trimmedLine.length > 0) { @@ -810,10 +1550,18 @@ DO NOT include any text, markdown, explanations, or other content outside the JS // If all extraction methods fail, return a default question console.log('Using default question due to parsing failure') - return ['What are the key concepts covered in this course?'] + return [ + language === 'id' + ? 'Apa saja konsep kunci yang dibahas dalam mata kuliah ini?' + : 'What are the key concepts covered in this course?', + ] } catch (error) { console.error('Error generating questions:', error) - return ['What are the key concepts covered in this course?'] + return [ + language === 'id' + ? 'Apa saja konsep kunci yang dibahas dalam mata kuliah ini?' + : 'What are the key concepts covered in this course?', + ] } } @@ -822,17 +1570,26 @@ async function generateAssessmentMetadata( difficultyLevel: string, ollama: OllamaFn, selectedModel: string, - assistantMessage: CoreMessage, - courseInfo?: CourseInfo, + assistantMessage: ModelMessage, + courseInfo: CourseInfo | undefined, + language: 'en' | 'id', ): Promise { console.log(`Generating metadata for ${assessmentType} assessment...`) // For project assessments, use specialized metadata if (assessmentType.toLowerCase() === 'project' && courseInfo) { + if (language === 'id') { + return { + type: 'Proyek', + duration: courseInfo.duration || '2 minggu', + description: + `${courseInfo.courseCode || ''} ${courseInfo.courseName || ''} Penilaian Proyek`.trim(), + } + } + // English default return { type: 'Project', duration: courseInfo.duration || '2 weeks', - // Ensure clean description without duplication description: `${courseInfo.courseCode || ''} ${courseInfo.courseName || ''} Project Assessment`.trim(), } @@ -852,39 +1609,76 @@ async function generateAssessmentMetadata( // Determine if we have source materials const hasSourceMaterials = (assistantMessage.content as string).includes('SOURCE MATERIALS:') - // Rest of the function remains the same... - const systemPrompt = `You are an expert educational assessment developer. Create metadata for a ${assessmentType} assessment for a ${difficultyLevel} level course ${hasSourceMaterials ? 'based STRICTLY on the provided source materials' : `for ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this course'}`}. + const systemPrompt = + language === 'id' + ? `${langDirective(language)}\n\nAnda adalah pengembang asesmen pendidikan ahli. Buat metadata untuk asesmen ${assessmentType} tingkat ${difficultyLevel} ${ + hasSourceMaterials + ? 'berdasarkan SECARA KETAT materi sumber yang disediakan.' + : `untuk ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'mata kuliah ini'}` + }. + +INSTRUKSI PENTING: +${ + hasSourceMaterials + ? `1. Dasarkan seluruh konten pada materi sumber. +2. Ambil konsep kunci dan istilah langsung dari materi sumber. +3. Seluruh keluaran HARUS menggunakan bahasa target yang diminta tanpa mencampur bahasa.` + : `1. Dasarkan metadata pada standar kurikulum untuk ${ + courseInfo?.courseName || 'mata kuliah ini' + }. +2. Fokus pada konsep inti dan tujuan asesmen. +3. Seluruh keluaran HARUS menggunakan bahasa target yang diminta tanpa mencampur bahasa.` +} +3. Buat type, duration, dan description. +4. Respons HARUS berupa JSON valid. + +FORMAT: +{ + "type": "${assessmentType.charAt(0).toUpperCase() + assessmentType.slice(1)}", + "duration": "Durasi yang sesuai (misal '2 hours')", + "description": "Deskripsi singkat tujuan asesmen" +} + +JANGAN sertakan teks di luar objek JSON.` + : `${langDirective(language)}\n\nYou are an expert assessment designer. Create metadata for a ${difficultyLevel}-level ${assessmentType} assessment ${ + hasSourceMaterials + ? 'STRICTLY based on the provided source materials.' + : `for ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this course'}` + }. -IMPORTANT INSTRUCTIONS: +CRITICAL INSTRUCTIONS: ${ hasSourceMaterials - ? ` -1. You MUST base your content ENTIRELY on the source materials provided. -2. Extract key concepts, terminology, examples, and explanations directly from the source materials.` - : ` -1. As no source materials are provided, base your metadata on standard curriculum content for ${courseInfo?.courseName || 'this subject'}. -2. Focus on core concepts, theories, and applications typically covered in ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this type of course'}.` + ? `1. Base all content on the source materials. +2. Use key concepts and terminology directly from the sources. +3. The output MUST be entirely in the requested target language with no language mixing.` + : `1. Base the metadata on standard curriculum for ${courseInfo?.courseName || 'this course'}. +2. Focus on core concepts and purpose of the assessment. +3. The output MUST be entirely in the requested target language with no language mixing.` } -3. Create a title, duration, and description for the assessment. -4. Your response MUST be valid JSON only. +3. Produce type, duration, and description. +4. The response MUST be valid JSON. -RESPONSE FORMAT: +FORMAT: { "type": "${assessmentType.charAt(0).toUpperCase() + assessmentType.slice(1)}", - "duration": "Appropriate time for completion (e.g., '2 hours')", - "description": "Brief description of the assessment and its purpose" + "duration": "A suitable duration (e.g., '2 hours')", + "description": "A brief description of the assessment purpose" } -DO NOT include any text, markdown, explanations, or other content outside the JSON object.` +DO NOT include any text outside the JSON object.` - const systemMessage: CoreMessage = { + const systemMessage: ModelMessage = { role: 'system', content: systemPrompt, } - const userMessage: CoreMessage = { + const userMessage: ModelMessage = { role: 'user', - content: `Generate metadata for a ${assessmentType} assessment for ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this course'}.`, + content: + language === 'id' + ? `Hasilkan metadata untuk asesmen ${assessmentType} pada ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'mata kuliah ini'}.` + : `Generate metadata for the ${assessmentType} assessment in ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this course'}.`, } try { @@ -892,14 +1686,14 @@ DO NOT include any text, markdown, explanations, or other content outside the JS model: ollama(selectedModel), messages: [systemMessage, assistantMessage, userMessage], temperature: TEMPERATURE, - maxTokens: Math.floor(TOKEN_MAX / 4), + maxOutputTokens: Math.floor(TOKEN_MAX / 4), }) - console.log('Metadata response:', response.text) + const cleaned = stripThinkTags(response.text) + console.log('Metadata response:', cleaned) - // Try to parse the response as JSON try { - const metadata = JSON.parse(response.text) + const metadata = JSON.parse(cleaned) console.log('Successfully parsed metadata directly') // Override duration for exam type @@ -917,8 +1711,7 @@ DO NOT include any text, markdown, explanations, or other content outside the JS console.log('Direct parsing failed, trying JSON extraction') } - // Try to extract JSON from the response - const jsonStr = extractJsonFromText(response.text) + const jsonStr = extractJsonFromText(cleaned) if (jsonStr) { try { const metadata = JSON.parse(jsonStr) @@ -945,7 +1738,7 @@ DO NOT include any text, markdown, explanations, or other content outside the JS // Extract type from the response text let extractedType = assessmentType.charAt(0).toUpperCase() + assessmentType.slice(1) - const typeMatch = response.text.match(/"type"\s*:\s*"([^"]+)"/) + const typeMatch = cleaned.match(/"type"\s*:\s*"([^"]+)"/) if (typeMatch && typeMatch[1]) { extractedType = typeMatch[1] } @@ -954,7 +1747,7 @@ DO NOT include any text, markdown, explanations, or other content outside the JS let extractedDuration = assessmentType.toLowerCase() === 'exam' ? '2 hours' : getDefaultDuration(assessmentType) if (assessmentType.toLowerCase() !== 'exam') { - const durationMatch = response.text.match(/"duration"\s*:\s*"([^"]+)"/) + const durationMatch = cleaned.match(/"duration"\s*:\s*"([^"]+)"/) if (durationMatch && durationMatch[1]) { extractedDuration = durationMatch[1] } @@ -965,7 +1758,7 @@ DO NOT include any text, markdown, explanations, or other content outside the JS if (courseInfo?.courseCode && courseInfo?.courseName) { extractedDescription = `${courseInfo.courseCode} ${courseInfo.courseName} ${assessmentType.charAt(0).toUpperCase() + assessmentType.slice(1)} Assessment` } else { - const descMatch = response.text.match(/"description"\s*:\s*"([^"]+)"/) + const descMatch = cleaned.match(/"description"\s*:\s*"([^"]+)"/) if (descMatch && descMatch[1]) { extractedDescription = descMatch[1] } @@ -1004,43 +1797,58 @@ async function generateModelAnswer( difficultyLevel: string, ollama: OllamaFn, selectedModel: string, - assistantMessage: CoreMessage, + assistantMessage: ModelMessage, courseInfo?: CourseInfo, + language: 'en' | 'id' = 'en', ): Promise { console.log(`Generating model answer for question: ${question.substring(0, 100)}...`) // Determine if we have source materials const hasSourceMaterials = (assistantMessage.content as string).includes('SOURCE MATERIALS:') - const systemPrompt = `You are an expert educational assessment developer. Generate a model answer for the following question ${hasSourceMaterials ? 'based STRICTLY on the provided source materials' : `for ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this course'}`}. + // Choose prompts based on assessment type + let systemMessage: ModelMessage + let userMessage: ModelMessage -IMPORTANT INSTRUCTIONS: -${ - hasSourceMaterials - ? ` -1. You MUST base your content ENTIRELY on the source materials provided. -2. Extract key concepts, terminology, examples, and explanations directly from the source materials. -3. Do not introduce concepts or information that is not present in the source materials.` - : ` -1. As no source materials are provided, base your answer on standard curriculum content for ${courseInfo?.courseName || 'this subject'}. -2. Focus on core concepts, theories, and applications typically covered in ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this type of course'}. -3. Ensure the answer is academically rigorous and appropriate for university-level education.` -} -4. Provide a comprehensive and accurate answer to the question. -5. Your response MUST be a plain text answer only. - -QUESTION: ${question} - -DO NOT include any text, markdown, explanations, or other content outside the answer.` - - const systemMessage: CoreMessage = { - role: 'system', - content: systemPrompt, - } - - const userMessage: CoreMessage = { - role: 'user', - content: `Generate a model answer for the question for ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this course'}.`, + if (assessmentType.toLowerCase() === 'project') { + const projectPrompts = await import('./prompts/project') + systemMessage = { + role: 'system', + content: projectPrompts.buildProjectModelAnswerSystemPrompt( + courseInfo, + language, + hasSourceMaterials, + ), + } + userMessage = { + role: 'user', + content: projectPrompts.buildProjectModelAnswerUserPrompt( + question, + courseInfo, + language, + hasSourceMaterials, + ), + } + } else { + const examPrompts = await import('./prompts/exam') + systemMessage = { + role: 'system', + content: examPrompts.buildExamModelAnswerSystemPrompt( + assessmentType, + courseInfo, + language, + hasSourceMaterials, + question, + ), + } + userMessage = { + role: 'user', + content: examPrompts.buildExamModelAnswerUserPrompt( + hasSourceMaterials && assessmentType.toLowerCase() === 'exam', + courseInfo, + language, + ), + } } try { @@ -1048,11 +1856,19 @@ DO NOT include any text, markdown, explanations, or other content outside the an model: ollama(selectedModel), messages: [systemMessage, assistantMessage, userMessage], temperature: TEMPERATURE, - maxTokens: Math.floor(TOKEN_RESPONSE_BUDGET), + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET), }) - console.log('Model answer response:', response.text.substring(0, 100) + '...') - return response.text.trim() + let cleaned = stripThinkTags(response.text) + + // For project assessments, always ensure the language matches the selected language + if (assessmentType.toLowerCase() === 'project') { + cleaned = await ensureTargetLanguageText(cleaned, language, ollama, selectedModel) + console.log('Project model answer language enforced') + } + + console.log('Model answer response:', cleaned.substring(0, 100) + '...') + return cleaned } catch (error) { console.error('Error generating model answer:', error) return `Unable to generate a model answer due to an error: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -1066,106 +1882,93 @@ async function generateMarkingCriteria( difficultyLevel: string, ollama: OllamaFn, selectedModel: string, - assistantMessage: CoreMessage, + assistantMessage: ModelMessage, courseInfo?: CourseInfo, + language: 'en' | 'id' = 'en', ): Promise { console.log(`Generating marking criteria for question: ${question.substring(0, 100)}...`) // Determine if we have source materials const hasSourceMaterials = (assistantMessage.content as string).includes('SOURCE MATERIALS:') - const systemPrompt = `You are an expert educational assessment developer. Create marking criteria for the following question based on the model answer ${hasSourceMaterials ? 'and STRICTLY on the provided source materials' : `for ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this course'}`}. - -IMPORTANT INSTRUCTIONS: -${ - hasSourceMaterials - ? ` -1. You MUST base your content ENTIRELY on the source materials provided. -2. Extract key concepts, terminology, examples, and explanations directly from the source materials. -3. Do not introduce concepts or information that is not present in the source materials.` - : ` -1. As no source materials are provided, base your criteria on standard curriculum content for ${courseInfo?.courseName || 'this subject'}. -2. Focus on core concepts, theories, and applications typically covered in ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this type of course'}. -3. Ensure the criteria are academically rigorous and appropriate for university-level education.` -} -4. Provide a detailed grading rubric with specific criteria and mark allocation. -5. Your response MUST be valid JSON only. - -QUESTION: ${question} - -MODEL ANSWER: ${modelAnswer} - -RESPONSE FORMAT: -{ - "criteria": [ - { - "name": "Criterion 1", - "weight": 40, - "description": "Description of criterion 1" - }, - { - "name": "Criterion 2", - "weight": 30, - "description": "Description of criterion 2" - }, - { - "name": "Criterion 3", - "weight": 30, - "description": "Description of criterion 3" - } - ], - "markAllocation": [ - { - "component": "Component 1", - "marks": 5, - "description": "Description of component 1" - }, - { - "component": "Component 2", - "marks": 10, - "description": "Description of component 2" - }, - { - "component": "Component 3", - "marks": 5, - "description": "Description of component 3" - } - ] -} - -DO NOT include any text, markdown, explanations, or other content outside the JSON object.` - - const systemMessage: CoreMessage = { + // Use modular prompt builders for exam marking criteria + const examPrompts = await import('./prompts/exam') + const systemPrompt = examPrompts.buildExamMarkingCriteriaSystemPrompt( + assessmentType, + courseInfo, + language, + hasSourceMaterials, + question, + modelAnswer, + ) + + const systemMessage: ModelMessage = { role: 'system', content: systemPrompt, } - const userMessage: CoreMessage = { + const userMessage: ModelMessage = { role: 'user', - content: `Generate marking criteria for the question based on the model answer for ${courseInfo?.courseCode || ''} ${courseInfo?.courseName || 'this course'}.`, + content: examPrompts.buildExamMarkingCriteriaUserPrompt( + hasSourceMaterials && assessmentType.toLowerCase() === 'exam', + courseInfo, + language, + ), } try { + // Prefer structured generation to minimize parsing errors + try { + const { object } = await generateObject({ + model: ollama(selectedModel), + output: 'no-schema', + messages: [systemMessage, assistantMessage, userMessage], + temperature: TEMPERATURE, + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET), + providerOptions: { + ollama: { + mode: 'json', + options: { + numCtx: TOKEN_RESPONSE_BUDGET, + }, + }, + }, + }) + if ( + object && + typeof object === 'object' && + 'criteria' in object && + 'markAllocation' in object + ) { + console.log('Successfully generated marking criteria via generateObject') + return object as ExplanationObject + } + console.log('generateObject returned unexpected shape, falling back to text parsing') + } catch (e) { + console.log('generateObject failed for marking criteria, falling back to generateText:', e) + } + + // Fallback to text generation and robust parsing const response = await generateText({ model: ollama(selectedModel), messages: [systemMessage, assistantMessage, userMessage], temperature: TEMPERATURE, - maxTokens: Math.floor(TOKEN_RESPONSE_BUDGET), + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET), }) - console.log('Marking criteria response:', response.text.substring(0, 100) + '...') + const cleanedRaw = stripThinkTags(response.text) + const cleaned = stripCodeFences(cleanedRaw) + console.log('Marking criteria response:', cleaned.substring(0, 100) + '...') - // Try to parse the response as JSON try { - const markingCriteria = JSON.parse(response.text) + const markingCriteria = JSON.parse(cleaned) console.log('Successfully parsed marking criteria directly') return markingCriteria } catch { console.log('Direct parsing of marking criteria failed, trying JSON extraction') } - // Try to extract JSON from the response - const jsonStr = extractJsonFromText(response.text) + const jsonStr = extractJsonFromText(cleaned) if (jsonStr) { try { const markingCriteria = JSON.parse(jsonStr) @@ -1176,6 +1979,22 @@ DO NOT include any text, markdown, explanations, or other content outside the JS } } + // Last resort: try jsonrepair on the whole cleaned text + try { + let repaired: string + try { + repaired = jsonrepair(cleaned) + } catch (e) { + console.error('jsonrepair threw while repairing marking criteria:', e) + throw e + } + const repairedObj = JSON.parse(repaired) + console.log('Successfully repaired and parsed marking criteria JSON with jsonrepair') + return repairedObj + } catch (e) { + console.error('jsonrepair failed to repair marking criteria JSON:', e) + } + // If all extraction methods fail, return default marking criteria console.log('Using default marking criteria due to parsing failure') return { @@ -1225,6 +2044,15 @@ DO NOT include any text, markdown, explanations, or other content outside the JS } } +// Helper: strip ... sections before parsing or sending to frontend +function stripThinkTags(text: string): string { + if (!text) return text + return text + .replace(/[\s\S]*?<\/think>/gi, '') // remove enclosed content + .replace(/<\/?think>/gi, '') // safety: stray tags + .trim() +} + // Update the processQuestion function to better handle project assessments async function processQuestion( questionText: GeneratedQuestion, @@ -1232,9 +2060,10 @@ async function processQuestion( difficultyLevel: string, ollama: OllamaFn, selectedModel: string, - assistantMessage: CoreMessage, + assistantMessage: ModelMessage, questionIndex: number, courseInfo?: CourseInfo, + language: 'en' | 'id' = 'en', ): Promise { // Handle case where questionText might be an object instead of a string let questionString: string @@ -1271,6 +2100,7 @@ async function processQuestion( selectedModel, assistantMessage, courseInfo, + language, ) // Get the default project rubric @@ -1280,8 +2110,39 @@ async function processQuestion( selectedModel, assistantMessage, courseInfo || { courseName: 'Project Assessment' }, // Use courseInfo if provided + language, ) + // Localized labels for project components + const labels = + language === 'id' + ? { + report: 'Laporan', + demo: 'Presentasi Demo', + individual: 'Kontribusi Individu', + reportDesc: 'Komponen laporan tertulis', + demoDesc: 'Komponen presentasi', + individualDesc: 'Komponen penilaian individu', + levels: { + excellent: 'Sangat Baik (5)', + good: 'Baik (4)', + average: 'Sedang (3)', + }, + } + : { + report: 'Report', + demo: 'Demo', + individual: 'Individual Contribution', + reportDesc: 'Written report component', + demoDesc: 'Presentation component', + individualDesc: 'Individual assessment component', + levels: { + excellent: 'Excellent (5)', + good: 'Good (4)', + average: 'Average (3)', + }, + } + // Return the project assessment question with the model answer and rubric return { question: questionString, @@ -1289,41 +2150,41 @@ async function processQuestion( explanation: { criteria: [ ...projectRubric.categories.report.map((c) => ({ - name: `Report - ${c.name}`, + name: `${labels.report} - ${c.name}`, weight: c.weight, description: c.description, })), ...projectRubric.categories.demo.map((c) => ({ - name: `Demo - ${c.name}`, + name: `${labels.demo} - ${c.name}`, weight: c.weight, description: c.description, })), ...projectRubric.categories.individual.map((c) => ({ - name: `Individual Contribution - ${c.name}`, + name: `${labels.individual} - ${c.name}`, weight: c.weight, description: c.description, })), ], markAllocation: [ { - component: 'Report', + component: labels.report, marks: projectRubric.reportWeight, - description: 'Written report component', + description: labels.reportDesc, }, { - component: 'Demo', + component: labels.demo, marks: projectRubric.demoWeight, - description: 'Presentation component', + description: labels.demoDesc, }, { - component: 'Individual Contribution', + component: labels.individual, marks: projectRubric.individualWeight, - description: 'Individual assessment component', + description: labels.individualDesc, }, ], rubricLevels: [ { - level: 'Excellent (5)', + level: labels.levels.excellent, criteria: Object.fromEntries( [ ...projectRubric.categories.report, @@ -1335,7 +2196,7 @@ async function processQuestion( ), }, { - level: 'Good (4)', + level: labels.levels.good, criteria: Object.fromEntries( [ ...projectRubric.categories.report, @@ -1347,7 +2208,7 @@ async function processQuestion( ), }, { - level: 'Average (3)', + level: labels.levels.average, criteria: Object.fromEntries( [ ...projectRubric.categories.report, @@ -1367,29 +2228,87 @@ async function processQuestion( } // For non-project assessments, proceed with the standard approach - // Step 2: Generate model answer + // Step 2: Generate model answer (with timeout and fallback) console.log(`Generating model answer for ${assessmentType} question...`) - const modelAnswer = await generateModelAnswer( - questionString, - assessmentType, - difficultyLevel, - ollama, - selectedModel, - assistantMessage, - courseInfo, + const modelAnswer = await withTimeout( + generateModelAnswer( + questionString, + assessmentType, + difficultyLevel, + ollama, + selectedModel, + assistantMessage, + courseInfo, + language, + ), + ASSESSMENT_REQUEST_TIMEOUT_MS, + async () => + language === 'id' + ? 'Jawaban model tidak tersedia karena batas waktu.' + : 'Model answer unavailable due to timeout.', ) - // Step 3: Generate marking criteria + // Step 3: Generate marking criteria (with timeout and fallback) console.log(`Generating marking criteria for ${assessmentType} question...`) - const markingCriteria = await generateMarkingCriteria( - questionString, - modelAnswer, - assessmentType, - difficultyLevel, - ollama, - selectedModel, - assistantMessage, - courseInfo, + const markingCriteria = await withTimeout( + generateMarkingCriteria( + questionString, + modelAnswer, + assessmentType, + difficultyLevel, + ollama, + selectedModel, + assistantMessage, + courseInfo, + language, + ), + ASSESSMENT_REQUEST_TIMEOUT_MS, + async () => { + if (language === 'id') { + return { + criteria: [ + { + name: 'Pemahaman konsep', + weight: 40, + description: 'Menunjukkan pemahaman konsep kunci dari mata kuliah', + }, + { + name: 'Penerapan pengetahuan', + weight: 30, + description: 'Menerapkan pengetahuan pada konteks spesifik pertanyaan', + }, + { + name: 'Analisis kritis', + weight: 30, + description: 'Menunjukkan pemikiran kritis dan analisis materi', + }, + ], + markAllocation: [], + error: 'Kriteria penilaian default digunakan karena batas waktu.', + } + } + return { + criteria: [ + { + name: 'Understanding of concepts', + weight: 40, + description: 'Demonstrates understanding of key concepts from the course', + }, + { + name: 'Application of knowledge', + weight: 30, + description: 'Applies knowledge to the specific context of the question', + }, + { + name: 'Critical analysis', + weight: 30, + description: 'Shows critical thinking and analysis of the subject matter', + }, + ], + markAllocation: [], + error: 'Default marking criteria used due to timeout.', + } + }, ) // Step 4: Combine into a complete question @@ -1442,6 +2361,7 @@ export async function POST(req: Request) { difficultyLevel, numQuestions, courseInfo, + language = 'en', } = await req.json() if (!assessmentType || !difficultyLevel) { @@ -1451,18 +2371,30 @@ export async function POST(req: Request) { console.log('=== ASSESSMENT GENERATION STARTED ===') console.log('Parameters:', { assessmentType, difficultyLevel, numQuestions }) console.log('Course Info:', courseInfo) - - // Make selected sources optional - const hasSelectedSources = - selectedSources && - Array.isArray(selectedSources) && - selectedSources.filter((s: ClientSource) => s.selected).length > 0 + console.log('Selected Sources Raw:', selectedSources) + console.log('Selected Sources Type:', typeof selectedSources) + console.log('Selected Sources Array?:', Array.isArray(selectedSources)) + + // Selected sources handling: if items include an explicit `selected` flag, use it; + // otherwise, treat any non-empty array as selected (backward compatibility with clients + // that only send selected items without the flag) + let hasSelectedSources = false + let effectiveSources: ClientSource[] = [] + if (Array.isArray(selectedSources) && selectedSources.length > 0) { + const anyHasSelectedFlag = selectedSources.some((s: ClientSource) => 'selected' in s) + effectiveSources = anyHasSelectedFlag + ? (selectedSources as ClientSource[]).filter((s) => s.selected) + : (selectedSources as ClientSource[]) + hasSelectedSources = effectiveSources.length > 0 + } console.log( 'Selected sources count:', - hasSelectedSources - ? selectedSources.filter((s: ClientSource) => s.selected).length - : 'No sources selected', + hasSelectedSources ? effectiveSources.length : 'No sources selected', + ) + console.log( + 'Effective sources:', + effectiveSources.map((s) => ({ id: s.id, name: s.name, selected: s.selected })), ) // Get the Ollama URL from environment variables @@ -1481,8 +2413,23 @@ export async function POST(req: Request) { // Only retrieve chunks if there are selected sources if (hasSelectedSources) { // Use the getStoredChunks function to retrieve chunks from selected sources - const retrievedChunks = await getStoredChunks(selectedSources as ClientSource[]) + console.log( + 'Attempting to retrieve chunks for sources:', + effectiveSources.map((s) => s.id), + ) + const retrievedChunks = await getStoredChunks(effectiveSources) console.log('Retrieved chunks:', retrievedChunks.length) + console.log('Retrieved chunks sample:', retrievedChunks.slice(0, 2)) + console.log( + 'First few chunks preview:', + retrievedChunks.slice(0, 3).map((c) => ({ + sourceId: c.sourceId, + chunkLength: c.chunk?.length || 0, + chunkPreview: c.chunk?.substring(0, 100) + '...', + sourceType: c.sourceType, + hasSourceName: !!c.sourceName, + })), + ) if (retrievedChunks.length > 0) { // Process chunks to create a structured context @@ -1510,7 +2457,13 @@ export async function POST(req: Request) { a.order !== undefined && b.order !== undefined ? a.order - b.order : 0, ) - sortedChunks.forEach((chunkObj) => { + // For large chunk sets, prioritize the first chunks which typically contain introduction/overview + const chunksToInclude = sortedChunks.slice(0, Math.min(sortedChunks.length, 50)) + console.log( + `Including ${chunksToInclude.length} chunks out of ${sortedChunks.length} total chunks for source: ${sourceName}`, + ) + + chunksToInclude.forEach((chunkObj) => { structuredContent += `EXCERPT ${chunkIndex}:\n${chunkObj.chunk}\n\n` chunkIndex++ }) @@ -1521,64 +2474,161 @@ export async function POST(req: Request) { // If the content is too large, we need to summarize it to fit within context window if (countTokens(structuredContent) > TOKEN_CONTEXT_BUDGET) { console.log( - `Content too large (${countTokens(structuredContent)} tokens), summarizing to fit context window`, + `Content too large (${countTokens(structuredContent)} tokens), truncating to fit context window (${TOKEN_CONTEXT_BUDGET} tokens)`, ) + const originalContent = structuredContent structuredContent = truncateToTokenLimit(structuredContent, TOKEN_CONTEXT_BUDGET) + console.log('Original content length:', originalContent.length) + console.log('Truncated content length:', structuredContent.length) + console.log('Truncated content preview:', structuredContent.substring(0, 500) + '...') + } else { + console.log( + `Content fits within context budget (${countTokens(structuredContent)} tokens <= ${TOKEN_CONTEXT_BUDGET} tokens)`, + ) } console.log(`Final context size: ${countTokens(structuredContent)} tokens`) - assistantContent = structuredContent + console.log('Structured content preview:', structuredContent.substring(0, 500) + '...') + console.log( + 'Structured content includes SOURCE MATERIALS marker:', + structuredContent.includes('SOURCE MATERIALS:'), + ) + assistantContent = `${langDirective(language)}\n\n${structuredContent}` + console.log( + 'Final assistantContent includes SOURCE MATERIALS:', + assistantContent.includes('SOURCE MATERIALS:'), + ) + console.log('Final assistantContent length:', assistantContent.length) + } else { + console.log('No chunks retrieved despite having selected sources') } } - // If no sources were selected or no chunks were retrieved, use a course-specific prompt - if (!assistantContent) { - console.log('No source content available, using course-specific prompt') - // Replace the generic prompt with a course-specific one + // Handle case where sources were selected but assistantContent is still empty + if (hasSelectedSources && !assistantContent) { + // Sources were selected but produced zero chunks; still indicate SOURCE MATERIALS to prevent course-title fallback + console.log( + 'Sources selected but no chunks retrieved; setting minimal SOURCE MATERIALS context', + ) + assistantContent = `${langDirective(language)}\n\nSOURCE MATERIALS:\n\n` // minimal marker to trigger source-only behavior in downstream prompts + } + + // If no sources were selected, use a course-specific prompt + if (!hasSelectedSources) { + console.log('No sources selected, using course-specific prompt') if (courseInfo?.courseCode && courseInfo?.courseName) { - assistantContent = `Generate a ${difficultyLevel} level ${assessmentType} assessment for the course "${courseInfo.courseCode} ${courseInfo.courseName}". - -As an expert in this subject area, create content that would be appropriate for a university-level course on this topic. - -For this ${assessmentType}: -1. Include questions that test understanding of core concepts in ${courseInfo.courseName} -2. Cover a range of topics typically included in a ${courseInfo.courseName} curriculum -3. Ensure the difficulty level is appropriate for ${difficultyLevel} students -4. Include both theoretical and practical aspects of the subject where appropriate -5. Ensure questions are clear, unambiguous, and academically rigorous - -The assessment should reflect standard academic expectations for a course with this title at university level.` + assistantContent = + language === 'id' + ? `${langDirective(language)}\n\nHasilkan asesmen ${assessmentType} tingkat ${difficultyLevel} untuk mata kuliah "${courseInfo.courseCode} ${courseInfo.courseName}". + +Sebagai ahli di bidang ini, buat konten yang sesuai untuk tingkat universitas. + +Untuk asesmen ${assessmentType} ini: +1. Sertakan pertanyaan yang menguji pemahaman konsep inti ${courseInfo.courseName} +2. Cakup berbagai topik yang umum dalam kurikulum ${courseInfo.courseName} +3. Sesuaikan tingkat kesulitan untuk tingkat ${difficultyLevel} +4. Gabungkan aspek teoritis dan praktis jika relevan +5. Pastikan pertanyaan jelas, tidak ambigu, dan akademik. +` + : `${langDirective(language)}\n\nGenerate a ${assessmentType} assessment at ${difficultyLevel} level for the course "${courseInfo.courseCode} ${courseInfo.courseName}". + +As an expert in the field, create content suitable for a university context. + +For this ${assessmentType} assessment: +1. Include questions that test understanding of core ${courseInfo.courseName} concepts. +2. Cover a range of topics commonly found in the ${courseInfo.courseName} curriculum. +3. Calibrate difficulty to the ${difficultyLevel} level. +4. Combine theoretical and practical aspects where relevant. +5. Ensure questions are clear, unambiguous, and academic in tone. +` } else { - assistantContent = `Generate a ${difficultyLevel} level ${assessmentType} assessment based on your knowledge of the subject.` + assistantContent = + language === 'id' + ? `${langDirective(language)}\n\nHasilkan asesmen ${assessmentType} tingkat ${difficultyLevel} berdasarkan pengetahuan kurikulum standar untuk mata kuliah ini. + +Instruksi: +1. Gunakan konsep inti dan teori umum. +2. Pastikan keragaman topik. +3. Jaga konsistensi tingkat kesulitan. +` + : `${langDirective(language)}\n\nGenerate a ${assessmentType} assessment at ${difficultyLevel} level based on standard curriculum knowledge for this course. + +Instructions: +1. Use core concepts and common theories. +2. Ensure a diversity of topics. +3. Maintain consistent difficulty. +` } } } catch (error) { console.error('Error retrieving knowledge:', error) - // Use course-specific prompt even in error case - if (courseInfo?.courseCode && courseInfo?.courseName) { - assistantContent = `Generate a ${difficultyLevel} level ${assessmentType} assessment for the course "${courseInfo.courseCode} ${courseInfo.courseName}". - -As an expert in this subject area, create content that would be appropriate for a university-level course on this topic. - -For this ${assessmentType}: -1. Include questions that test understanding of core concepts in ${courseInfo.courseName} -2. Cover a range of topics typically included in a ${courseInfo.courseName} curriculum -3. Ensure the difficulty level is appropriate for ${difficultyLevel} students -4. Include both theoretical and practical aspects of the subject where appropriate -5. Ensure questions are clear, unambiguous, and academically rigorous - -The assessment should reflect standard academic expectations for a course with this title at university level.` + // Handle error case: if sources were selected, maintain SOURCE MATERIALS context + if (hasSelectedSources) { + console.log( + 'Error retrieving sources but sources were selected; setting minimal SOURCE MATERIALS context', + ) + assistantContent = `${langDirective(language)}\n\nSOURCE MATERIALS:\n\n` // minimal marker to trigger source-only behavior } else { - assistantContent = `Generate a ${difficultyLevel} level ${assessmentType} assessment based on your knowledge of the subject.` + // Use course-specific prompt only when no sources were selected + if (courseInfo?.courseCode && courseInfo?.courseName) { + assistantContent = + language === 'id' + ? `${langDirective(language)}\n\nHasilkan asesmen ${assessmentType} tingkat ${difficultyLevel} untuk mata kuliah "${courseInfo.courseCode} ${courseInfo.courseName}". + +Sebagai ahli di bidang ini, buat konten yang sesuai untuk tingkat universitas. + +Untuk asesmen ${assessmentType} ini: +1. Sertakan pertanyaan yang menguji pemahaman konsep inti ${courseInfo.courseName} +2. Cakup berbagai topik yang umum dalam kurikulum ${courseInfo.courseName} +3. Sesuaikan tingkat kesulitan untuk tingkat ${difficultyLevel} +4. Gabungkan aspek teoritis dan praktis jika relevan +5. Pastikan pertanyaan jelas, tidak ambigu, dan akademik. +` + : `${langDirective(language)}\n\nGenerate a ${assessmentType} assessment at ${difficultyLevel} level for the course "${courseInfo.courseCode} ${courseInfo.courseName}". + +As an expert in the field, create content suitable for a university context. + +For this ${assessmentType} assessment: +1. Include questions that test understanding of core ${courseInfo.courseName} concepts. +2. Cover a range of topics commonly found in the ${courseInfo.courseName} curriculum. +3. Calibrate difficulty to the ${difficultyLevel} level. +4. Combine theoretical and practical aspects where relevant. +5. Ensure questions are clear, unambiguous, and academic in tone. +` + } else { + assistantContent = + language === 'id' + ? `${langDirective(language)}\n\nHasilkan asesmen ${assessmentType} tingkat ${difficultyLevel} berdasarkan pengetahuan kurikulum standar untuk mata kuliah ini. + +Instruksi: +1. Gunakan konsep inti dan teori umum. +2. Pastikan keragaman topik. +3. Jaga konsistensi tingkat kesulitan. +` + : `${langDirective(language)}\n\nGenerate a ${assessmentType} assessment at ${difficultyLevel} level based on standard curriculum knowledge for this course. + +Instructions: +1. Use core concepts and common theories. +2. Ensure a diversity of topics. +3. Maintain consistent difficulty. +` + } } } // Create assistant message with the source content - const assistantMessage: CoreMessage = { + const assistantMessage: ModelMessage = { role: 'assistant', content: assistantContent, } + console.log('=== ASSISTANT MESSAGE CONTENT DEBUG ===') + console.log('Assistant content length:', assistantContent.length) + console.log('Contains SOURCE MATERIALS:', assistantContent.includes('SOURCE MATERIALS:')) + console.log('Language directive present:', assistantContent.includes(langDirective(language))) + console.log('Assistant content preview:', assistantContent.substring(0, 300) + '...') + console.log('=== END ASSISTANT MESSAGE DEBUG ===') + // Generate assessment metadata using the new function const assessmentMetadata = await generateAssessmentMetadata( assessmentType, @@ -1587,6 +2637,7 @@ The assessment should reflect standard academic expectations for a course with t selectedModel, assistantMessage, courseInfo, + language, ) console.log('Final metadata:', assessmentMetadata) @@ -1600,28 +2651,31 @@ The assessment should reflect standard academic expectations for a course with t selectedModel, assistantMessage, courseInfo, + language, ) console.log(`Generated ${questionTexts.length} unique questions`) - // Step 5: Process each question sequentially - const generatedQuestions: AssessmentQuestion[] = [] - - for (let i = 0; i < questionTexts.length; i++) { - const processedQuestion = await processQuestion( - questionTexts[i], - assessmentType, - difficultyLevel, - ollama, - selectedModel, - assistantMessage, - i, - courseInfo, - ) - - generatedQuestions.push(processedQuestion) - console.log(`Completed processing question ${i + 1} of ${questionTexts.length}`) - } + // Step 5: Process each question with limited concurrency to reduce total latency + const generatedQuestions: AssessmentQuestion[] = await mapWithConcurrency( + questionTexts, + ASSESSMENT_CONCURRENCY, + async (q, i) => { + const processed = await processQuestion( + q, + assessmentType, + difficultyLevel, + ollama, + selectedModel, + assistantMessage, + i, + courseInfo, + language, + ) + console.log(`Completed processing question ${i + 1} of ${questionTexts.length}`) + return processed + }, + ) console.log(`Successfully processed ${generatedQuestions.length} questions`) diff --git a/frontend/src/app/api/chat/route.ts b/frontend/src/app/api/chat/route.ts index ee1c9d7..482579b 100644 --- a/frontend/src/app/api/chat/route.ts +++ b/frontend/src/app/api/chat/route.ts @@ -1,12 +1,13 @@ // Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 -import { createOllama } from 'ollama-ai-provider' -import { streamText, convertToCoreMessages, CoreMessage, TextPart } from 'ai' +import { createOllama } from 'ollama-ai-provider-v2' +import { streamText, ModelMessage } from 'ai' import { errorHandler } from '@/lib/handler/error-handler' import { retrieveContextByEmbedding } from '@/lib/assistant/retrieve-context-by-embedding' import { effectiveTokenCountForText } from '@/lib/utils' import { getMostRelevantImage } from '@/lib/embedding/get-most-relevant-image' +import { extractTextFromMessage } from '@/lib/utils/message' import { getPayload } from 'payload' import config from '@payload-config' @@ -33,9 +34,13 @@ const TOKEN_CONTEXT_BUDGET = TOKEN_MAX - TOKEN_RESPONSE_BUDGET * @returns A promise that resolves to a response object. */ export async function POST(req: Request) { - const { messages, selectedModel, selectedSources } = await req.json() + const { messages, selectedModel, selectedSources, data, language } = await req.json() console.log('DEBUG: CHAT API selectedModel:', selectedModel) + // Handle legacy image data for backward compatibility + const legacyImages = data?.images || [] + console.log('DEBUG: Legacy images count:', legacyImages.length) + // Prepare: Ollama provider const ollamaUrl = process.env.OLLAMA_URL if (!ollamaUrl) { @@ -54,20 +59,77 @@ export async function POST(req: Request) { console.log(`DEBUG: TOKEN_MAX: ${TOKEN_MAX}`) console.log(`DEBUG: TOKEN_RESPONSE_RATIO: ${TOKEN_RESPONSE_RATIO}`) - // Step 1: Convert to coreMessages - // console.log('DEBUG: messages:', messages); - const coreMessages: CoreMessage[] = convertToCoreMessages(messages) + // Step 1: Extract attachments and convert to coreMessages + console.log('DEBUG: Raw messages received:', JSON.stringify(messages, null, 2)) + + // Extract image attachments from messages + const messageImages: string[] = [] + messages.forEach((msg: unknown, idx: number) => { + const m = msg as Record + + if (m.attachments && Array.isArray(m.attachments)) { + m.attachments.forEach((attachment: unknown) => { + const att = attachment as Record + if ( + att.contentType && + typeof att.contentType === 'string' && + att.contentType.startsWith('image/') + ) { + if (att.url && typeof att.url === 'string') { + messageImages.push(att.url) + } + } + }) + } + + console.log(`DEBUG: Message ${idx}:`, { + role: m.role, + hasContent: !!m.content, + hasParts: !!m.parts, + hasAttachments: !!m.attachments, + attachmentsCount: Array.isArray(m.attachments) ? m.attachments.length : 0, + partsLength: Array.isArray(m.parts) ? m.parts.length : 0, + partsTypes: Array.isArray(m.parts) + ? m.parts.map((p: unknown) => (p as Record)?.type) + : [], + }) + }) + + // Combine all images (message attachments + legacy) + const allImages = [...messageImages, ...legacyImages] + console.log('DEBUG: Total images available:', allImages.length) // Step 2: Extract the latest user query - const latestMessage = coreMessages[coreMessages.length - 1] + const originalLatestMessage = messages[messages.length - 1] let latestQuery = '' - if (Array.isArray(latestMessage?.content)) { - latestQuery = - latestMessage.content.find((part): part is TextPart => part.type === 'text')?.text || '' - } else { - latestQuery = '' + + // Handle 'parts' structure + if (originalLatestMessage?.parts && Array.isArray(originalLatestMessage.parts)) { + interface MessagePart { + type: string + text?: string + } + const textPart = originalLatestMessage.parts.find( + (part: unknown): part is MessagePart => + typeof part === 'object' && part !== null && (part as MessagePart).type === 'text', + ) + latestQuery = textPart?.text || '' + } + + // Fallback: try the utility function for complex extraction + if (!latestQuery) { + latestQuery = extractTextFromMessage(originalLatestMessage) + console.log('DEBUG: Extracted using utility function') } + if (!latestQuery) { + console.warn('Could not extract text from latest message:', { + originalLatestMessage, + }) + } + + console.log('DEBUG: Final extracted latestQuery:', latestQuery) + // Step 2.5: Check if any sources are selected and valid const hasSelectedSources = Array.isArray(selectedSources) && @@ -108,9 +170,42 @@ export async function POST(req: Request) { } } - const systemPrompt = ` + const languageDirective = + language === 'id' ? 'Selalu balas dalam Bahasa Indonesia.' : 'Always reply in English.' + + const systemPrompt = + language === 'id' + ? ` +Anda adalah asisten AI percakapan yang andal dan berpengetahuan. +${languageDirective} +Pedoman Umum: +Gunakan HANYA informasi yang disediakan di bawah ini untuk jawaban Anda. +Jika diperlukan daftar, tampilkan jumlah item alih-alih bullet. +JANGAN menyimpulkan atau menghasilkan informasi di luar data yang diberikan. +Jika konteks relevan tersedia, jelaskan bahwa jawaban Anda didasarkan pada informasi tersebut. +Jika tidak ada konteks yang tersedia, balas dengan: +"Saya tidak memiliki cukup informasi untuk menjawab pertanyaan tersebut." +Namun, bila memungkinkan, berikan juga sesuatu yang terkait dengan pertanyaan untuk tetap membantu dan natural. +Jika pengguna ingin detail lebih lanjut, tanyakan apakah mereka ingin mengeksplorasi topik tersebut lebih jauh. +Pastikan jawaban Anda selalu berbasis pada pengetahuan yang disediakan dan, bila tepat, rujuk bagian spesifik darinya. +${ + mostRelevantImageMarkdown + ? `Selalu sertakan markdown gambar relevan ini: ${mostRelevantImageMarkdown} dalam jawaban Anda. +Posisikan markdown gambar: ${mostRelevantImageMarkdown} secara tepat dalam jawaban untuk meningkatkan kejelasan dan relevansi. +Hindari penggunaan tag gambar HTML seperti dalam jawaban. +Jika pertanyaan secara khusus meminta representasi visual, prioritaskan menyertakan markdown gambar di awal jawaban.` + : '' +} + +Tambahkan di akhir jawaban bagian singkat berlabel "Ringkasan penalaran:" yang: +- Merangkum pendekatan Anda secara tingkat tinggi (tanpa langkah-langkah rinci). +- Menyebutkan sumber/bagian yang dirujuk (judul atau ID jika ada). +- Menyebutkan asumsi/keterbatasan yang relevan. +Batasi hingga 2–3 butir. Jangan mengungkap rantai penalaran internal atau langkah-langkah tersembunyi. +` + : ` You are a knowledgeable and reliable AI chat assistant. -Always reply in English. +${languageDirective} General Guidelines: Use ONLY the information provided below for your responses. If a list of items is required, show the number of items instead of bullets. @@ -129,9 +224,15 @@ Avoid using HTML image tags such as in your response. If the query specifically asks for a visual representation, prioritize including the image markdown early in your response.` : '' } + +At the end of the answer, add a short section labeled "Reasoning summary:" that: +- Summarizes your high-level approach (no detailed steps). +- Mentions the sources/sections referenced (titles or IDs if available). +- Notes any relevant assumptions/limitations. +Keep it to 2–3 bullets. Do not reveal internal chain-of-thought or hidden step-by-step reasoning. ` - const systemMessage: CoreMessage = { + const systemMessage: ModelMessage = { role: 'system', content: systemPrompt, } @@ -190,24 +291,159 @@ If the query specifically asks for a visual representation, prioritize including assistantContent = 'An error occurred while retrieving knowledge.' } - const assistantMessage: CoreMessage = { + // Step 5: Prepare full messages - convert all to consistent ModelMessage format + // First, create UI-format messages for system and assistant, then convert everything together + const uiSystemMessage = { + id: 'system-1', + role: 'system', + content: systemMessage.content, + } + + const uiAssistantMessage = { + id: 'assistant-1', role: 'assistant', content: assistantContent, } - // Step 5: Prepare full messages - const fullMessages = [systemMessage, assistantMessage, ...coreMessages] + // Combine all messages in UI format, then convert to ModelMessage format for provider + console.log('DEBUG: Before combining - uiSystemMessage:', uiSystemMessage) + console.log('DEBUG: Before combining - uiAssistantMessage:', uiAssistantMessage) + console.log('DEBUG: Before combining - messages:', JSON.stringify(messages, null, 2)) + + // Validate and clean messages before combining + const validatedMessages = messages.filter((msg: unknown) => { + const m = msg as Record + if (!msg || typeof msg !== 'object' || !m.role) { + console.warn('DEBUG: Filtering out invalid message:', msg) + return false + } + // Ensure parts array exists if it's a user message with parts structure + if (m.parts && !Array.isArray(m.parts)) { + console.warn('DEBUG: Message has invalid parts (not array):', msg) + return false + } + // Ensure attachments array exists if specified + if (m.attachments && !Array.isArray(m.attachments)) { + console.warn('DEBUG: Message has invalid attachments (not array):', msg) + return false + } + return true + }) + + const allUIMessages = [uiSystemMessage, uiAssistantMessage, ...validatedMessages] + + console.log('DEBUG: allUIMessages after validation:', JSON.stringify(allUIMessages, null, 2)) + console.log('DEBUG: allUIMessages length:', allUIMessages.length) + + if (allUIMessages.length === 0) { + throw new Error('No valid messages found after validation') + } + + // Try manual conversion first to avoid the convertToModelMessages error + console.log('DEBUG: Using manual message conversion for better compatibility...') + const fullMessages: ModelMessage[] = allUIMessages.map((msg, index) => { + const m = msg as Record + console.log(`DEBUG: Converting message ${index}:`, { + role: m.role, + hasContent: !!m.content, + hasParts: !!m.parts, + partsLength: Array.isArray(m.parts) ? m.parts.length : 0, + partsStructure: Array.isArray(m.parts) + ? m.parts.map((p: unknown) => { + const part = p as Record + return { + type: part?.type, + hasText: !!part?.text, + textLength: typeof part?.text === 'string' ? part.text.length : 0, + } + }) + : 'no parts', + }) + + if (m.parts && Array.isArray(m.parts) && m.parts.length > 0) { + const textParts: string[] = [] + + for (const part of m.parts) { + if (!part || typeof part !== 'object') continue + + const p = part as Record + console.log(`DEBUG: Processing part:`, { type: p.type, hasText: !!p.text, text: p.text }) + + if (p.type === 'text' && typeof p.text === 'string' && p.text.trim()) { + textParts.push(p.text.trim()) + } + } + + const content = textParts.length > 0 ? textParts.join('\n') : '' + console.log(`DEBUG: Extracted content from parts:`, { + contentLength: content.length, + content: content.substring(0, 100) + '...', + }) + + if (!content) { + // Fallback: try extractTextFromMessage utility + const fallbackContent = extractTextFromMessage(msg) + console.log(`DEBUG: Fallback extraction result:`, { + fallbackLength: fallbackContent.length, + }) + return { + role: m.role as 'user' | 'assistant' | 'system', + content: fallbackContent || '[Empty message]', + } + } + + return { role: m.role as 'user' | 'assistant' | 'system', content } + } else if (m.content && typeof m.content === 'string') { + // Handle regular message with content + console.log(`DEBUG: Using direct content:`, { contentLength: m.content.length }) + return { role: m.role as 'user' | 'assistant' | 'system', content: m.content } + } else { + // Last resort: try the utility function + const extractedContent = extractTextFromMessage(msg) + console.log(`DEBUG: Last resort extraction:`, { extractedLength: extractedContent.length }) + return { + role: m.role as 'user' | 'assistant' | 'system', + content: extractedContent || '[Unable to extract content]', + } + } + }) + console.log('DEBUG: Manual conversion completed, fullMessages length:', fullMessages.length) + + // Validate all messages have content + const validatedFullMessages = fullMessages.filter((msg) => { + const content = typeof msg.content === 'string' ? msg.content : String(msg.content || '') + if (!content || content.trim() === '') { + console.warn('DEBUG: Filtering out message with empty content:', msg) + return false + } + return true + }) + + if (validatedFullMessages.length === 0) { + throw new Error('No messages with valid content found') + } + console.log('DEBUG: latestQuery:', latestQuery) - // console.log('DEBUG: fullMessages:', fullMessages); - // console.log('DEBUG: selectedSources:', selectedSources); + console.log( + 'DEBUG: validatedFullMessages after conversion:', + JSON.stringify(validatedFullMessages, null, 2), + ) // Step 6: Stream AI response using Ollama const startTime = Date.now() - const result = await streamText({ - model: ollama(selectedModel, { numCtx: TOKEN_RESPONSE_BUDGET }), - messages: fullMessages, + const result = streamText({ + model: ollama(selectedModel), + messages: validatedFullMessages, temperature: TEMPERATURE, - maxTokens: TOKEN_RESPONSE_BUDGET, + maxOutputTokens: TOKEN_RESPONSE_BUDGET, + providerOptions: { + ollama: { + mode: 'json', + options: { + numCtx: TOKEN_RESPONSE_BUDGET, + }, + }, + }, onError: (error) => { console.error(error) }, @@ -217,23 +453,41 @@ If the query specifically asks for a visual representation, prioritize including const timeTakenMs = endTime - startTime const timeTakenSeconds = timeTakenMs / 1000 - // Calculate token generation speed. - const totalTokens = usage.completionTokens - const tokenGenerationSpeed = totalTokens / timeTakenSeconds + // Guard for optional usage fields (provider may omit usage or only provide totalUsage via finish parts) + const inputTokens = usage?.inputTokens ?? 0 + const outputTokens = usage?.outputTokens ?? 0 + const tokenGenerationSpeed = timeTakenSeconds > 0 ? outputTokens / timeTakenSeconds : 0 + + console.log('onFinish usage:', usage) console.log( `Usage tokens: ` + `promptEst(${usedTokens}) ` + - `prompt(${usage.promptTokens}) ` + - `completion(${usage.completionTokens}) | ` + + `prompt(${inputTokens}) ` + + `completion(${outputTokens}) | ` + `${tokenGenerationSpeed.toFixed(2)} t/s | ` + `Duration: ${timeTakenSeconds.toFixed(2)} s`, ) - // console.log('DEBUBG: Response text:', text); }, }) - return result.toDataStreamResponse({ - getErrorMessage: errorHandler, + type FinishPart = { + type: 'finish' + totalUsage?: { + inputTokens?: number + outputTokens?: number + totalTokens?: number + } + } + + return result.toUIMessageStreamResponse({ + onError: errorHandler, + // Attach the totalUsage from the final 'finish' stream part to the UI message metadata + // so clients using useChat can access it via message.metadata?.totalUsage + messageMetadata: ({ part }: { part: FinishPart | { type: string } }) => { + if (part.type === 'finish' && 'totalUsage' in part && part.totalUsage) { + return { totalUsage: part.totalUsage } + } + }, }) } diff --git a/frontend/src/app/api/faq/route.ts b/frontend/src/app/api/faq/route.ts index bfb0572..1e04679 100644 --- a/frontend/src/app/api/faq/route.ts +++ b/frontend/src/app/api/faq/route.ts @@ -1,8 +1,8 @@ // Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 -import { createOllama } from 'ollama-ai-provider' -import { generateObject } from 'ai' +import { createOllama } from 'ollama-ai-provider-v2' +import { generateObject, convertToModelMessages, type UIMessage } from 'ai' import { NextResponse } from 'next/server' import { hybridSearch } from '@/lib/chunk/hybrid-search' import { getStoredChunks } from '@/lib/chunk/get-stored-chunks' @@ -29,6 +29,11 @@ type FaqResult = { _sentToFrontend?: boolean } +type Usage = { inputTokens?: number; outputTokens?: number; totalTokens?: number } +const getInputTokens = (u?: Usage | undefined) => u?.inputTokens ?? 0 +const getOutputTokens = (u?: Usage | undefined) => u?.outputTokens ?? 0 +const getTotalTokens = (u?: Usage | undefined) => u?.totalTokens ?? 0 + export const dynamic = 'force-dynamic' // Configuration constants @@ -36,6 +41,12 @@ const TEMPERATURE = parseFloat(process.env.RAG_TEMPERATURE || '0.1') const TOKEN_RESPONSE_BUDGET = 2048 const TOKEN_CONTEXT_BUDGET = 1024 +// Language directive for enforcing output language +const langDirective = (lang: 'en' | 'id') => + lang === 'id' + ? 'PENTING: Semua output harus dalam Bahasa Indonesia yang jelas dan alami.' + : 'IMPORTANT: All output must be in clear and natural English.' + // Content generation type for FAQs, not secret const CONTENT_TYPE_FAQ = 'faq' @@ -49,17 +60,55 @@ const faqSystemPromptGenerator: SystemPromptGenerator = (isFirstPass, query, opt const courseInfo = options.courseInfo as | { courseName?: string; courseDescription?: string } | undefined + const language = (options.language as 'en' | 'id') || 'en' - const contextInstruction = hasValidSources - ? 'based on the context provided' + const contextInstructionEN = hasValidSources + ? 'based on the provided context' : courseInfo?.courseName ? `for the course "${courseInfo.courseName}"${courseInfo.courseDescription ? ` (${courseInfo.courseDescription})` : ''}. Use general academic knowledge relevant to this course` : 'using general academic knowledge' + const contextInstructionID = hasValidSources + ? 'berdasarkan konteks yang disediakan' + : courseInfo?.courseName + ? `untuk mata kuliah "${courseInfo.courseName}"${courseInfo.courseDescription ? ` (${courseInfo.courseDescription})` : ''}. Gunakan pengetahuan akademik umum yang relevan dengan mata kuliah ini` + : 'dengan menggunakan pengetahuan akademik umum' + + if (language === 'id') { + return ` + ${langDirective(language)} + + Tugas Anda adalah menghasilkan FAQ yang beragam dan menarik berupa pasangan pertanyaan–jawaban ${contextInstructionID}. + + Format SEMUA FAQ sebagai objek JSON dengan struktur berikut (JANGAN terjemahkan kunci JSON – gunakan persis: FAQs, question, answer): + { + "FAQs": [ + { + "question": "Teks pertanyaan", + "answer": "Jawaban yang rinci dan deskriptif. HARUS berupa string (bukan objek atau array) dan terstruktur dengan baik agar mudah dibaca." + } + ] + } + + Instruksi Penting: + 1. Anda HARUS menghasilkan TEPAT ${faqCount} FAQ — tidak kurang, tidak lebih. + 2. Buat pertanyaan yang BERAGAM — variasikan format (bagaimana, apa, mengapa, apakah, dll.) dan topik. + 3. Susun pertanyaan dengan cara yang berbeda — jangan mengikuti pola yang kaku. + 4. Fokus pada aspek konten yang berbeda — temukan sudut pandang dan wawasan yang unik. + 5. Pastikan semua tanda kutip dan karakter khusus dalam JSON ter-escape dengan benar. + 6. JSON harus valid dan dapat di-parse tanpa kesalahan. + 7. Jawaban harus rinci, deskriptif, dan memberikan penjelasan yang jelas${hasValidSources ? ' berdasarkan konteks' : ''}. + ${!hasValidSources && courseInfo?.courseName ? `8. Fokus pada pertanyaan dan jawaban yang relevan bagi mahasiswa di ${courseInfo.courseName}.` : ''} + ` + } + + // English default return ` - Your job is to generate diverse and interesting FAQs with question answer pairs ${contextInstruction}. + ${langDirective(language)} + + Your job is to generate diverse and interesting FAQs with question–answer pairs ${contextInstructionEN}. - Format ALL FAQs as a JSON object with this structure: + Format ALL FAQs as a JSON object with this structure (Do NOT translate JSON keys — use exactly: FAQs, question, answer): { "FAQs": [ { @@ -68,12 +117,12 @@ const faqSystemPromptGenerator: SystemPromptGenerator = (isFirstPass, query, opt } ] } - + Important Instructions: - 1. You MUST generate EXACTLY ${faqCount} FAQs - no more, no less. - 2. Create DIVERSE questions - vary question formats (how, what, why, can, etc.) and topics. - 3. Phrase questions differently - don't follow a rigid pattern. - 4. Focus on different aspects of the content - find unique angles and insights. + 1. You MUST generate EXACTLY ${faqCount} FAQs — no more, no less. + 2. Create DIVERSE questions — vary question formats (how, what, why, can, etc.) and topics. + 3. Phrase questions differently — don't follow a rigid pattern. + 4. Focus on different aspects of the content — find unique angles and insights. 5. Ensure all quotes and special characters in the JSON are properly escaped. 6. The JSON must be valid and parsable without errors. 7. Answers should be detailed, descriptive, and provide clear explanations${hasValidSources ? ' based on the context' : ''}. @@ -90,12 +139,16 @@ const createFaqContentProcessor = (faqCount: number): ContentProcessor() return (result, previousResults) => { - // Extract FAQs from the current result - const faqs = result.FAQs || [] + // Extract FAQs from the current result - with better error handling + if (!result) { + console.error('FAQ Content Processor: result is undefined or null') + result = { FAQs: [] } + } + const faqs = Array.isArray(result.FAQs) ? result.FAQs : [] // Process previous FAQs let previousFaqs: FaqItem[] = [] - if (previousResults && previousResults.length > 0) { + if (previousResults && Array.isArray(previousResults) && previousResults.length > 0) { const prevResult = previousResults[previousResults.length - 1] if (prevResult && 'FAQs' in prevResult && Array.isArray(prevResult.FAQs)) { previousFaqs = prevResult.FAQs @@ -110,12 +163,25 @@ const createFaqContentProcessor = (faqCount: number): ContentProcessor faq.question.toLowerCase().trim().replace(/\s+/g, ' '), ) // Filter out duplicates from current generation + console.log( + 'DEBUG FAQ Processor: faqs type:', + typeof faqs, + 'isArray:', + Array.isArray(faqs), + 'length:', + faqs?.length, + ) const uniqueNewFaqsList = faqs.filter((newFaq: { question: string }) => { const normalizedNewQuestion = newFaq.question.toLowerCase().trim().replace(/\s+/g, ' ') const questionKey = normalizedNewQuestion.substring(0, 40) @@ -184,30 +250,80 @@ const createFaqContentProcessor = (faqCount: number): ContentProcessor => { return async (options: Record) => { - const { model, messages, temperature, maxTokens } = options as { + const { model, messages, temperature, maxOutputTokens } = options as { model: unknown messages: unknown temperature: number - maxTokens: number + maxOutputTokens: number + } + + if (!messages) { + throw new Error('createFaqGenerationFunction: messages is required') + } + + if (!Array.isArray(messages)) { + throw new Error('createFaqGenerationFunction: messages must be an array') + } + + // Handle both UIMessage format (with parts) and ModelMessage format (with content) + let modelMessages: Parameters[0]['messages'] + + if (messages.length > 0 && 'parts' in messages[0]) { + // UIMessage format from direct generation - convert using AI SDK utility + modelMessages = convertToModelMessages(messages as UIMessage[]) + } else { + // ModelMessage format from multi-pass system - use directly + modelMessages = messages as Parameters[0]['messages'] + } + + // Ensure modelMessages is never undefined + if (!modelMessages || !Array.isArray(modelMessages)) { + throw new Error('createFaqGenerationFunction: Failed to process messages into valid format') } const result = await generateObject({ model: model as Parameters[0]['model'], output: 'no-schema' as const, - messages: messages as Parameters[0]['messages'], + messages: modelMessages, temperature, - maxTokens, + maxOutputTokens, + providerOptions: { + ollama: { + mode: 'json', + options: { + numCtx: TOKEN_RESPONSE_BUDGET, + }, + }, + }, }) // Transform the raw AI response to FaqResult format + if (!result || !result.object) { + console.error('FAQ Generation: generateObject returned null or undefined result') + return { + object: { FAQs: [], _needNextPass: false, _sentToFrontend: false }, + usage: undefined, + } + } + const rawResponse = result.object as Record let faqs: FaqItem[] = [] - // Extract FAQs from the response - if (rawResponse.FAQs && Array.isArray(rawResponse.FAQs)) { + // Extract FAQs from the response with better error handling + console.log( + 'DEBUG FAQ Generation: rawResponse type:', + typeof rawResponse, + 'rawResponse:', + JSON.stringify(rawResponse, null, 2), + ) + + if (rawResponse && rawResponse.FAQs && Array.isArray(rawResponse.FAQs)) { faqs = rawResponse.FAQs } else if (Array.isArray(rawResponse)) { faqs = rawResponse + } else { + console.warn('FAQ Generation: Unexpected response format, using empty array') + faqs = [] } const faqResult: FaqResult = { @@ -216,15 +332,17 @@ const createFaqGenerationFunction = (): GenerationFunction => { _sentToFrontend: false, } + const usageObj = result.usage + ? ({ + inputTokens: (result.usage as Usage).inputTokens ?? undefined, + outputTokens: (result.usage as Usage).outputTokens ?? undefined, + totalTokens: (result.usage as Usage).totalTokens ?? undefined, + } as Usage) + : undefined + return { object: faqResult, - usage: result.usage - ? { - promptTokens: result.usage.promptTokens, - completionTokens: result.usage.completionTokens, - totalTokens: result.usage.totalTokens, - } - : undefined, + usage: usageObj, } } } @@ -246,6 +364,7 @@ export async function POST(req: Request) { useReranker, // Add this line with default value true _recursionDepth = 0, courseInfo, // Add courseInfo parameter + language = 'en', } = await req.json() // Debug logging @@ -315,6 +434,7 @@ export async function POST(req: Request) { preserveOrder: !hasUserQuery, hasValidSources, // Add this for system prompt courseInfo, // Add this for system prompt + language, } // Start processing timer @@ -330,22 +450,41 @@ export async function POST(req: Request) { // Direct generation without chunks for course context console.log('DEBUG FAQ API: Generating FAQs using course context only') const systemPrompt = faqSystemPromptGenerator(true, userQuery, options) - const userPrompt = courseInfo?.courseName - ? `Generate FAQs for the course "${courseInfo.courseName}"${userQuery ? ` related to: "${userQuery}"` : ''}. Use general academic knowledge relevant to this course.` - : `Generate FAQs${userQuery ? ` for the topic: "${userQuery}"` : ''}. Use general academic knowledge to provide comprehensive answers.` + let userPrompt: string + if (courseInfo?.courseName) { + if (language === 'id') { + userPrompt = `Buat FAQ untuk mata kuliah "${courseInfo.courseName}"${userQuery ? ` terkait: "${userQuery}"` : ''}. Gunakan pengetahuan akademik umum yang relevan dengan mata kuliah ini.` + } else { + userPrompt = `Generate FAQs for the course "${courseInfo.courseName}"${userQuery ? ` related to: "${userQuery}"` : ''}. Use general academic knowledge relevant to this course.` + } + } else { + if (language === 'id') { + userPrompt = `Buat FAQ${userQuery ? ` untuk topik: "${userQuery}"` : ''}. Gunakan pengetahuan akademik umum untuk memberikan jawaban yang komprehensif.` + } else { + userPrompt = `Generate FAQs${userQuery ? ` for the topic: "${userQuery}"` : ''}. Use general academic knowledge to provide comprehensive answers.` + } + } const messages = [ - { role: 'system' as const, content: systemPrompt }, - { role: 'user' as const, content: userPrompt }, + { role: 'system' as const, parts: [{ type: 'text', text: systemPrompt }] }, + { role: 'user' as const, parts: [{ type: 'text', text: userPrompt }] }, ] try { const { object: rawResult, usage } = await faqGenerationFunction({ - model: ollama(selectedModel, { numCtx: TOKEN_RESPONSE_BUDGET }), + model: ollama(selectedModel), output: 'no-schema', messages: messages, temperature: TEMPERATURE + 0.1, - maxTokens: TOKEN_RESPONSE_BUDGET, + maxOutputTokens: TOKEN_RESPONSE_BUDGET, + providerOptions: { + ollama: { + mode: 'json', + options: { + numCtx: TOKEN_RESPONSE_BUDGET, + }, + }, + }, }) const processedResult = faqContentProcessor(rawResult, []) @@ -367,9 +506,9 @@ export async function POST(req: Request) { totalChunks: 0, remainingChunks: 0, tokenUsage: { - prompt: usage?.promptTokens || 0, - completion: usage?.completionTokens || 0, - total: usage?.totalTokens || 0, + prompt: getInputTokens(usage as Usage | undefined), + completion: getOutputTokens(usage as Usage | undefined), + total: getTotalTokens(usage as Usage | undefined), }, timeTaken: Date.now() - startTime, }, @@ -384,17 +523,23 @@ export async function POST(req: Request) { userQuery, continueFaqs ? [] : retrievedChunks, faqGenerationFunction, - ollama(selectedModel, { numCtx: TOKEN_RESPONSE_BUDGET }), + ollama(selectedModel), options, CONTENT_TYPE_FAQ, faqSystemPromptGenerator, (query) => { if (hasValidSources) { - return `Generate FAQs for the following query: "${query}". Use the provided context to answer.` + return language === 'id' + ? `Buat FAQ untuk kueri berikut: "${query}". Gunakan konteks yang disediakan untuk menjawab.` + : `Generate FAQs for the following query: "${query}". Use the provided context to answer.` } else if (courseInfo?.courseName) { - return `Generate FAQs for the course "${courseInfo.courseName}"${query ? ` related to: "${query}"` : ''}. Use general academic knowledge relevant to this course.` + return language === 'id' + ? `Buat FAQ untuk mata kuliah "${courseInfo.courseName}"${query ? ` terkait: "${query}"` : ''}. Gunakan pengetahuan akademik umum yang relevan dengan mata kuliah ini.` + : `Generate FAQs for the course "${courseInfo.courseName}"${query ? ` related to: "${query}"` : ''}. Use general academic knowledge relevant to this course.` } else { - return `Generate FAQs${query ? ` for the topic: "${query}"` : ''}. Use general academic knowledge to provide comprehensive answers.` + return language === 'id' + ? `Buat FAQ${query ? ` untuk topik: "${query}"` : ''}. Gunakan pengetahuan akademik umum untuk memberikan jawaban yang komprehensif.` + : `Generate FAQs${query ? ` for the topic: "${query}"` : ''}. Use general academic knowledge to provide comprehensive answers.` } }, faqContentProcessor, @@ -423,6 +568,7 @@ export async function POST(req: Request) { continueFaqs: true, useReranker, // Include in recursive calls _recursionDepth: _recursionDepth + 1, + language, } const nextReqObj = new Request(req.url, { diff --git a/frontend/src/app/api/quiz/generate-quiz/route.ts b/frontend/src/app/api/quiz/generate-quiz/route.ts index ed66d9f..6f15a68 100644 --- a/frontend/src/app/api/quiz/generate-quiz/route.ts +++ b/frontend/src/app/api/quiz/generate-quiz/route.ts @@ -1,8 +1,8 @@ // Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 -import { createOllama } from 'ollama-ai-provider' -import { type CoreMessage, generateObject } from 'ai' +import { createOllama } from 'ollama-ai-provider-v2' +import { type ModelMessage, generateObject } from 'ai' import { NextResponse } from 'next/server' import { getStoredChunks } from '@/lib/chunk/get-stored-chunks' import { effectiveTokenCountForText } from '@/lib/utils' @@ -18,7 +18,7 @@ const TOKEN_CONTEXT_BUDGET = 1024 export async function POST(req: Request) { try { - const { selectedModel, selectedSources, difficulty, numQuestions, questionType } = + const { selectedModel, selectedSources, difficulty, numQuestions, questionType, language } = await req.json() console.log('Data from request:', { @@ -35,8 +35,8 @@ export async function POST(req: Request) { } const ollama = createOllama({ baseURL: ollamaUrl + '/api' }) - const getQuestionTypePrompt = (type: string, difficulty: string) => { - const basePrompts = { + const getQuestionTypePrompt = (type: string, difficulty: string, lang: 'en' | 'id') => { + const basePromptsEN = { mcq: `Create multiple choice questions with: - Clear question text - Exactly 4 options per question @@ -101,10 +101,103 @@ export async function POST(req: Request) { - Include both true and false statements in a balanced way`, } - return basePrompts[type as keyof typeof basePrompts] || '' + const basePromptsID = { + mcq: `Buat soal pilihan ganda dengan: + - Teks pertanyaan yang jelas + - Tepat 4 opsi per pertanyaan + - Satu jawaban benar + - Opsi lain harus masuk akal namun jelas salah + - ${ + difficulty === 'easy' + ? 'Opsi sederhana dan berbeda jelas' + : difficulty === 'medium' + ? 'Pengecoh dengan tingkat kesulitan sedang' + : 'Pengecoh canggih yang membutuhkan analisis cermat' + } + - Penjelasan rinci mengapa jawaban benar tepat`, + + fillInTheBlank: `Buat soal isian dengan: + - Kalimat atau paragraf yang utuh + - Tepat satu [BLANK] per pertanyaan untuk mengisi istilah kunci + - ${ + difficulty === 'easy' + ? 'Kosakata dasar dan konsep sederhana' + : difficulty === 'medium' + ? 'Terminologi dan relasi yang lebih kompleks' + : 'Konsep lanjutan dan terminologi teknis' + } + - Bagian kosong harus menguji konsep penting + - Sertakan kata atau frasa tepat yang mengisi bagian kosong + - Sertakan petunjuk konteks dalam kalimat`, + + shortAnswer: `Buat soal jawaban singkat dengan: + - Pertanyaan terbuka yang menuntut pemahaman + - Pertanyaan jelas dan fokus yang dapat dijawab dalam ${ + difficulty === 'easy' + ? '1–2 kalimat sederhana' + : difficulty === 'medium' + ? '2–3 kalimat rinci' + : '3–4 kalimat komprehensif' + } + - Jawaban contoh yang memuat poin kunci + - Daftar variasi jawaban yang dapat diterima atau konsep kunci + - ${ + difficulty === 'easy' + ? 'Konsep dasar dan jawaban lugas' + : difficulty === 'medium' + ? 'Beberapa konsep dan hubungan' + : 'Analisis kompleks dan penjelasan menyeluruh' + } + - Kriteria penilaian pada penjelasan`, + + trueFalse: `Buat soal benar/salah dengan: + - Pernyataan yang jelas dan tidak ambigu (benar atau salah) + - ${ + difficulty === 'easy' + ? 'Fakta dasar dan konsep sederhana' + : difficulty === 'medium' + ? 'Hubungan antar beberapa konsep' + : 'Relasi kompleks dan pemahaman yang bernuansa' + } + - Hindari kalimat berlapis negatif atau menjebak + - Penjelasan rinci mengapa pernyataan benar atau salah + - Fokus pada konsep penting dari konteks + - Seimbangkan jumlah pernyataan benar dan salah`, + } + + const dict = lang === 'id' ? basePromptsID : basePromptsEN + return dict[type as keyof typeof dict] || '' } - const getDifficultyPrompt = (difficulty: string) => { + const getDifficultyPrompt = (difficulty: string, lang: 'en' | 'id') => { + if (lang === 'id') { + switch (difficulty) { + case 'easy': + return `Buat soal ramah pemula yang: + - Menggunakan bahasa sederhana dan jelas + - Berfokus pada konsep dasar dan definisi + - Memberikan jawaban yang langsung + - Menyertakan petunjuk konteks yang membantu + - Menghindari terminologi yang rumit` + case 'medium': + return `Buat soal tingkat menengah yang: + - Menggabungkan beberapa konsep + - Membutuhkan pemikiran analitis + - Menguji pemahaman hubungan antar konsep + - Menyertakan sebagian terminologi teknis + - Menantang peserta untuk menerapkan pengetahuannya` + case 'hard': + return `Buat soal tingkat lanjut yang: + - Menguji pemahaman mendalam terhadap konsep kompleks + - Membutuhkan pemikiran kritis dan analisis + - Menyertakan terminologi dan konsep tingkat lanjut + - Menantang peserta mensintesis informasi + - Menguji kasus tepi dan pemahaman yang bernuansa` + default: + return '' + } + } + // English default switch (difficulty) { case 'easy': return `Create beginner-friendly questions that: @@ -132,13 +225,54 @@ export async function POST(req: Request) { } } - const quizSystemPrompt = `You are a quiz generator. Create a ${difficulty} difficulty quiz with EXACTLY ${numQuestions} questions of type "${questionType}" based on the provided context. + const quizLanguageDirective = + language === 'id' + ? 'PENTING: Anda harus menghasilkan seluruh keluaran kuis dalam Bahasa Indonesia.' + : 'IMPORTANT: You must produce the entire quiz output in English.' + + const quizSystemPrompt = + language === 'id' + ? `${quizLanguageDirective}\n\nAnda adalah generator kuis. Buat kuis tingkat kesulitan ${difficulty} dengan TEPAT ${numQuestions} pertanyaan bertipe "${questionType}" berdasarkan konteks yang diberikan. + +${getDifficultyPrompt(difficulty, language)} + +${getQuestionTypePrompt(questionType, difficulty, language)} + +Format SEMUA pertanyaan sebagai objek JSON dengan struktur berikut (JANGAN terjemahkan kunci JSON — gunakan tepat: questions, question, options, statement, correctAnswer, explanation, type, difficulty): +{ + "questions": [ + ${ + questionType === 'trueFalse' + ? `{ + "statement": "Pernyataan yang dievaluasi sebagai benar atau salah", + "correctAnswer": "true", + "explanation": "Penjelasan rinci mengapa pernyataan benar atau salah. HARUS berupa string (bukan objek/array) dan tersusun baik untuk dibaca manusia. Dalam 1 atau 2 kalimat.", + "type": "trueFalse", + "difficulty": "${difficulty}" + }` + : `{ + "question": "Teks pertanyaan ${questionType === 'fillInTheBlank' ? 'dengan [BLANK]' : ''}", + ${questionType === 'mcq' ? '"options": ["Option 1", "Option 2", "Option 3", "Option 4"],' : ''} + "correctAnswer": "Jawaban benar atau kata untuk mengisi blank", + "explanation": "Penjelasan rinci beserta kriteria penilaian. HARUS berupa string (bukan objek/array) dan terstruktur baik untuk dibaca manusia.", + "type": "${questionType}", + "difficulty": "${difficulty}" + }` + } + ] +} + +PENTING: +- SEMUA pertanyaan HARUS bertipe "${questionType}" +- SEMUA pertanyaan HARUS mempertahankan tingkat kesulitan ${difficulty} +- Penjelasan harus sesuai dengan tingkat kesulitan` + : `${quizLanguageDirective}\n\nYou are a quiz generator. Create a ${difficulty} difficulty quiz with EXACTLY ${numQuestions} questions of type "${questionType}" based on the provided context. -${getDifficultyPrompt(difficulty)} +${getDifficultyPrompt(difficulty, language)} -${getQuestionTypePrompt(questionType, difficulty)} +${getQuestionTypePrompt(questionType, difficulty, language)} -Format ALL questions as a JSON object with this structure: +Format ALL questions as a JSON object with this structure (Do NOT translate JSON keys — use exactly: questions, question, options, statement, correctAnswer, explanation, type, difficulty): { "questions": [ ${ @@ -167,14 +301,17 @@ IMPORTANT: - ALL questions MUST maintain the specified ${difficulty} difficulty level - Explanations should be appropriate for the difficulty level` - const systemMessage: CoreMessage = { + const systemMessage: ModelMessage = { role: 'system', content: quizSystemPrompt, } - const userMessage: CoreMessage = { + const userMessage: ModelMessage = { role: 'user', - content: `Generate ${numQuestions} ${questionType} questions based on the provided context.`, + content: + language === 'id' + ? `Hasilkan ${numQuestions} pertanyaan bertipe ${questionType} berdasarkan konteks yang diberikan.` + : `Generate ${numQuestions} ${questionType} questions based on the provided context.`, } let usedTokens = @@ -216,7 +353,7 @@ IMPORTANT: assistantContent = 'An error occurred while retrieving knowledge.' } - const assistantMessage: CoreMessage = { + const assistantMessage: ModelMessage = { role: 'assistant', content: assistantContent, } @@ -226,11 +363,19 @@ IMPORTANT: console.log('Generating quiz with Ollama...') const startTime = Date.now() const { object: quiz, usage } = await generateObject({ - model: ollama(selectedModel, { numCtx: TOKEN_RESPONSE_BUDGET }), + model: ollama(selectedModel), output: 'no-schema', messages: fullMessages, temperature: TEMPERATURE, - maxTokens: TOKEN_RESPONSE_BUDGET, + maxOutputTokens: TOKEN_RESPONSE_BUDGET, + providerOptions: { + ollama: { + mode: 'json', + options: { + numCtx: TOKEN_RESPONSE_BUDGET, + }, + }, + }, }) // End timing and calculate the time taken. @@ -238,19 +383,126 @@ IMPORTANT: const timeTakenMs = endTime - startTime const timeTakenSeconds = timeTakenMs / 1000 - // Calculate token generation speed. - const totalTokens = usage.completionTokens - const tokenGenerationSpeed = totalTokens / timeTakenSeconds + // Calculate token generation speed (prefer totalTokens, fallback to input+output). + const totalTokens = + typeof usage?.totalTokens === 'number' + ? usage.totalTokens + : (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0) + const tokenGenerationSpeed = timeTakenSeconds > 0 ? totalTokens / timeTakenSeconds : 0 console.log( - `Usage tokens: ` + - `promptEst(${usedTokens}) ` + - `prompt(${usage.promptTokens}) ` + - `completion(${usage.completionTokens}) | ` + - `${tokenGenerationSpeed.toFixed(2)} t/s | ` + - `Duration: ${timeTakenSeconds.toFixed(2)} s`, + `Usage tokens: promptEst(${usedTokens}) prompt(${usage?.inputTokens ?? 0}) completion(${usage?.outputTokens ?? 0}) | ${tokenGenerationSpeed.toFixed(2)} t/s | Duration: ${timeTakenSeconds.toFixed(2)} s`, ) console.log('Generated Quiz:', JSON.stringify(quiz, null, 2)) + + // Normalize the quiz shape to always be { questions: [...] } and fix true/false specifics + type NormalizedQuestion = { + type: string + difficulty: string + question: string + statement?: string + options?: string[] + correctAnswer: string + explanation: string + // allow passthrough unknown extra fields without typing here + [key: string]: unknown + } + + const isRecord = (val: unknown): val is Record => + typeof val === 'object' && val !== null + + const normalizeQuiz = ( + raw: unknown, + expectedType: string, + expectedDifficulty: string, + ): { questions: NormalizedQuestion[] } => { + const extractQuestionsArray = (input: unknown): unknown[] => { + if (!input) return [] + if (Array.isArray(input)) return input + if (isRecord(input)) { + const qVal = input.questions + if (Array.isArray(qVal)) return qVal as unknown[] + // try to find array value in object values + const arrVal = Object.values(input).find((v) => Array.isArray(v)) + if (Array.isArray(arrVal)) return arrVal as unknown[] + } + return [] + } + + const toStringSafe = (v: unknown): string => { + if (v === undefined || v === null) return '' + if (typeof v === 'string') return v + if (typeof v === 'boolean') { + // Language-independent normalization for boolean-like fields + return v ? 'True' : 'False' + } + try { + return JSON.stringify(v) + } catch { + return String(v) + } + } + + const normType = (val: unknown): string => { + const s = toStringSafe(val).toLowerCase() + return s + } + + const isTrueFalseType = (t: string): boolean => { + const cleaned = t.replace(/[^a-z]/gi, '').toLowerCase() + return cleaned === 'truefalse' + } + + const normalized = extractQuestionsArray(raw).map((item): NormalizedQuestion => { + const q = isRecord(item) ? (item as Record) : {} + const rawType = q.type ?? expectedType + const typeStr = toStringSafe(rawType) + const isTF = isTrueFalseType(normType(typeStr)) || expectedType === 'trueFalse' + const statementVal = q.statement ?? q.Question ?? q.text + const statement = statementVal !== undefined ? toStringSafe(statementVal) : undefined + const questionTextVal = q.question ?? statementVal ?? '' + const questionText = toStringSafe(questionTextVal) + const correctAnsRaw = q.correctAnswer ?? q.answer + const correctAnswer = isTF + ? toStringSafe( + typeof correctAnsRaw === 'boolean' + ? correctAnsRaw + : typeof correctAnsRaw === 'string' + ? /^(true|false)$/i.test(correctAnsRaw) + ? correctAnsRaw.toLowerCase() === 'true' + ? 'True' + : 'False' + : correctAnsRaw + : correctAnsRaw, + ) + : toStringSafe(correctAnsRaw) + + // ensure options for true/false + let options: string[] | undefined + if (isTF) { + options = ['True', 'False'] + } else if (Array.isArray(q.options)) { + options = (q.options as unknown[]).map((o) => toStringSafe(o)) + } + + const explanation = toStringSafe(q.explanation) + + return { + ...q, + type: isTF ? 'trueFalse' : typeStr || expectedType, + difficulty: toStringSafe(q.difficulty || expectedDifficulty) || expectedDifficulty, + question: questionText, + ...(statement ? { statement } : {}), + correctAnswer, + ...(options ? { options } : {}), + explanation, + } + }) + + return { questions: normalized } + } + + const normalizedQuiz = normalizeQuiz(quiz, questionType, difficulty) // // Validate question types // interface QuizQuestion { // type: string @@ -290,7 +542,7 @@ IMPORTANT: // ) // } - return NextResponse.json(quiz) + return NextResponse.json(normalizedQuiz) } catch (error) { console.error('Error in summary generation:', error) return errorResponse('An unexpected error occurred', null, 500) diff --git a/frontend/src/app/api/slide/content-generator.ts b/frontend/src/app/api/slide/content-generator.ts index 865309a..69e8f07 100644 --- a/frontend/src/app/api/slide/content-generator.ts +++ b/frontend/src/app/api/slide/content-generator.ts @@ -1,9 +1,11 @@ // Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 -import { createOllama } from 'ollama-ai-provider' -import { type CoreMessage, generateText } from 'ai' +import { createOllama } from 'ollama-ai-provider-v2' +import { languageDirective, type Lang } from '@/lib/utils/lang' +import { type ModelMessage, generateText } from 'ai' import type { ClientSource } from '@/lib/types/client-source' +import type { CourseInfo } from '@/lib/types/course-info-types' import type { LectureContent, AssessmentQuestion, @@ -28,6 +30,9 @@ import { getReadingsSystemPrompt, getQuizQuestionPrompt, getDiscussionQuestionPrompt, + getIntroSystemPrompt, + getSpecialSlidesSystemPrompt, + getContentSlidesSystemPrompt, } from './prompts' import { validateAndSanitizeContent } from './content-validator' import { createFallbackContent } from './fallback-content' @@ -42,6 +47,8 @@ export async function generateCourseContent( sessionLength: number, difficultyLevel: string, topicName: string, + language: Lang, + courseInfo?: CourseInfo, ): Promise { try { // Check for required environment variables @@ -55,17 +62,44 @@ export async function generateCourseContent( // Prepare source content console.log('Preparing source content...') - const { content: assistantContent, metadata: sourceMetadata } = - await prepareSourceContent(selectedSources) + const hasSelectedSources = Array.isArray(selectedSources) && selectedSources.length > 0 + const { content: assistantContent, metadata: sourceMetadata } = hasSelectedSources + ? await prepareSourceContent(selectedSources, topicName, courseInfo) + : { + content: '', + metadata: { sourceCount: 0, chunkCount: 0, tokenEstimate: 0, sourceNames: [] }, + } - // Ensure assistant content fits within context window - const assistantMessage: CoreMessage = { - role: 'assistant', - content: assistantContent, - } + const hasSourceMaterials = hasSelectedSources && assistantContent.includes('SOURCE') + + // Build assistant message only when we have sources + const assistantMessage: ModelMessage | null = hasSourceMaterials + ? { role: 'assistant', content: assistantContent } + : null + + // Determine effective topic: when sources are selected, use topicName; when no sources, prefer courseInfo.courseName + const effectiveTopic = hasSelectedSources + ? topicName && topicName.trim().length > 0 + ? topicName + : courseInfo?.courseName || 'this course' + : courseInfo?.courseName && courseInfo.courseName.trim().length > 0 + ? courseInfo.courseName + : topicName && topicName.trim().length > 0 + ? topicName + : 'this course' + + console.log('=== TOPIC SELECTION DEBUG ===') + console.log('hasSelectedSources:', hasSelectedSources) + console.log('hasSourceMaterials:', hasSourceMaterials) + console.log('topicName:', topicName) + console.log('courseInfo?.courseName:', courseInfo?.courseName) + console.log('effectiveTopic:', effectiveTopic) + console.log('=== END TOPIC DEBUG ===') console.log('Generating course content with Ollama in sequential steps...') + const langDirective = languageDirective + // STEP 1: Generate basic metadata (title, contentType, difficultyLevel, learningOutcomes, keyTerms) console.log('STEP 1: Generating basic metadata...') @@ -75,23 +109,23 @@ export async function generateCourseContent( // Add specialized prompts for tutorials and workshops const specializedPrompt = contentType === 'tutorial' - ? `For this tutorial, ensure you: - - Structure content as a learning journey with clear progression - - Include detailed step-by-step instructions that build skills incrementally - - Provide sample solutions with explanations of the reasoning process + ? `For this tutorial, make sure you: + - Structure the content as a learning journey with clear progression + - Include step-by-step instructions that build skills gradually + - Provide example solutions with reasoning/explanation of the approach - Include troubleshooting tips for common mistakes - Add reflection questions after each major section - Ensure activities have clear success criteria - - Include both basic exercises and extension activities for differentiation` + - Provide foundational exercises and enrichment activities for differentiation` : contentType === 'workshop' - ? `For this workshop, ensure you: - - Design activities that promote active participation and collaboration + ? `For this workshop, make sure you: + - Design activities that encourage active participation and collaboration - Include detailed facilitation notes for the instructor - - Provide clear timing guidelines for each activity + - Provide clear time guidance for each activity - Include discussion prompts that connect theory to practice - - Structure activities with clear phases (introduction, main activity, debrief) - - Include guidance on managing group dynamics - - Provide templates and worksheets that support the activities` + - Structure activities with clear phases (introduction, core activity, closing) + - Include guidance for managing group dynamics + - Provide templates and worksheets to support activities` : '' const difficultyLevelPrompt = getDifficultyLevelPrompt(difficultyLevel) @@ -103,25 +137,36 @@ export async function generateCourseContent( contentStylePrompt, difficultyLevelPrompt, specializedPrompt, // Add this parameter + language, + hasSourceMaterials, ) - const metadataSystemMessage: CoreMessage = { + const metadataSystemMessage: ModelMessage = { role: 'system', - content: metadataSystemPrompt, + content: `${langDirective(language)}\n\n${metadataSystemPrompt}`, } - const metadataUserMessage: CoreMessage = { + const metadataUserMessage: ModelMessage = { role: 'user', - content: `Generate the title, learning outcomes, and at least 5-10 key terms for a ${difficultyLevel} level ${contentType} on "${topicName}" based STRICTLY on the provided source materials above.`, + content: + language === 'id' + ? hasSourceMaterials + ? `Buat judul, capaian pembelajaran, dan setidaknya 5-10 istilah kunci untuk ${contentType} tingkat ${difficultyLevel} tentang "${effectiveTopic}" SECARA KETAT berdasarkan materi sumber di atas. Semua dalam Bahasa Indonesia.` + : `Buat judul, capaian pembelajaran, dan setidaknya 5-10 istilah kunci untuk ${contentType} tingkat ${difficultyLevel} tentang "${effectiveTopic}". Semua dalam Bahasa Indonesia.` + : hasSourceMaterials + ? `Create a title, learning outcomes, and at least 5-10 key terms for a ${difficultyLevel} level ${contentType} on "${effectiveTopic}" STRICTLY based on the source materials provided above. All content MUST be in English.` + : `Create a title, learning outcomes, and at least 5-10 key terms for a ${difficultyLevel} level ${contentType} on "${effectiveTopic}". All content MUST be in English.`, } - const metadataMessages = [metadataSystemMessage, assistantMessage, metadataUserMessage] + const metadataMessages = assistantMessage + ? [metadataSystemMessage, assistantMessage, metadataUserMessage] + : [metadataSystemMessage, metadataUserMessage] const metadataTextResponse = await generateText({ model: ollama(selectedModel), messages: metadataMessages, temperature: TEMPERATURE, - maxTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 4), + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 4), }) console.log('Raw metadata response:', metadataTextResponse.text.substring(0, 500) + '...') @@ -134,39 +179,41 @@ export async function generateCourseContent( // STEP 2: Generate introduction console.log('STEP 2: Generating introduction...') - const introSystemPrompt = `You are an expert educational content developer. Continue creating a ${difficultyLevel} level ${contentType} on "${topicName}" designed for a ${sessionLength}-minute session. - -IMPORTANT INSTRUCTIONS: -1. You MUST base your content ENTIRELY on the source materials provided. -2. Extract key concepts, terminology, examples, and explanations directly from the source materials. -3. Do not introduce concepts or information that is not present in the source materials. -4. Create an engaging introduction that provides context and importance of the topic. - -RESPONSE FORMAT: -Your response MUST be a valid JSON object with EXACTLY these fields: -{ -"introduction": "Engaging introduction paragraph that provides context and importance of the topic" -} - -CRITICAL: Your response MUST be valid JSON only. Do not include any text, markdown, explanations, or other content outside the JSON object. Do not include backticks or code block markers.` + const introSystemPrompt = getIntroSystemPrompt( + difficultyLevel, + contentType, + effectiveTopic, + sessionLength, + language, + hasSourceMaterials, + ) - const introSystemMessage: CoreMessage = { + const introSystemMessage: ModelMessage = { role: 'system', - content: introSystemPrompt, + content: `${langDirective(language)}\n\n${introSystemPrompt}`, } - const introUserMessage: CoreMessage = { + const introUserMessage: ModelMessage = { role: 'user', - content: `Generate an engaging introduction for a ${difficultyLevel} level ${contentType} on "${topicName}" with title "${metadataResponse.title}" based STRICTLY on the provided source materials above.`, + content: + language === 'id' + ? hasSourceMaterials + ? `Buat pengantar yang menarik untuk ${contentType} tingkat ${difficultyLevel} tentang "${effectiveTopic}" dengan judul "${metadataResponse.title}" SECARA KETAT berdasarkan materi sumber di atas. Gunakan Bahasa Indonesia.` + : `Buat pengantar yang menarik untuk ${contentType} tingkat ${difficultyLevel} tentang "${effectiveTopic}" dengan judul "${metadataResponse.title}". Gunakan Bahasa Indonesia.` + : hasSourceMaterials + ? `Create an engaging introduction for a ${difficultyLevel} level ${contentType} on "${effectiveTopic}" with the title "${metadataResponse.title}" STRICTLY based on the source materials provided above. Use English only.` + : `Create an engaging introduction for a ${difficultyLevel} level ${contentType} on "${effectiveTopic}" with the title "${metadataResponse.title}". Use English only.`, } - const introMessages = [introSystemMessage, assistantMessage, introUserMessage] + const introMessages = assistantMessage + ? [introSystemMessage, assistantMessage, introUserMessage] + : [introSystemMessage, introUserMessage] const introTextResponse = await generateText({ model: ollama(selectedModel), messages: introMessages, temperature: TEMPERATURE, - maxTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 6), + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 6), }) console.log('Raw intro response:', introTextResponse.text.substring(0, 500) + '...') @@ -176,72 +223,41 @@ CRITICAL: Your response MUST be valid JSON only. Do not include any text, markdo // STEP 3: Generate special slides (introduction, agenda, assessment, conclusion) console.log('STEP 3: Generating special slides...') - const specialSlidesSystemPrompt = `You are an expert educational content developer. Continue creating a ${difficultyLevel} level ${contentType} on "${topicName}" designed for a ${sessionLength}-minute session. - -IMPORTANT INSTRUCTIONS: -1. You MUST base your content ENTIRELY on the source materials provided. -2. Extract key concepts, terminology, examples, and explanations directly from the source materials. -3. Do not introduce concepts or information that is not present in the source materials. -4. Create ONLY the following special slides: - - Introduction slide (first slide that introduces the topic) - - Agenda/Overview slide (outlines what will be covered) - - Assessment slide(s) (summarizes assessment approaches) - - Conclusion/Summary slide (wraps up the presentation) - -RESPONSE FORMAT: -Your response MUST be a valid JSON object with EXACTLY these fields: -{ -"specialSlides": [ - { - "type": "introduction", - "title": "Introduction to [Topic]", - "content": ["Point 1", "Point 2", "Point 3"], - "notes": "Speaker notes for this slide" - }, - { - "type": "agenda", - "title": "Agenda/Overview", - "content": ["Topic 1", "Topic 2", "Topic 3"], - "notes": "Speaker notes for this slide" - }, - { - "type": "assessment", - "title": "Assessment Approaches", - "content": ["Assessment method 1", "Assessment method 2"], - "notes": "Speaker notes for this slide" - }, - { - "type": "conclusion", - "title": "Summary and Conclusion", - "content": ["Key takeaway 1", "Key takeaway 2", "Next steps"], - "notes": "Speaker notes for this slide" - } -] -} - -CRITICAL: Your response MUST be valid JSON only. Do not include any text, markdown, explanations, or other content outside the JSON object. Do not include backticks or code block markers.` + const specialSlidesSystemPrompt = getSpecialSlidesSystemPrompt( + difficultyLevel, + contentType, + effectiveTopic, + sessionLength, + language, + hasSourceMaterials, + ) - const specialSlidesSystemMessage: CoreMessage = { + const specialSlidesSystemMessage: ModelMessage = { role: 'system', - content: specialSlidesSystemPrompt, + content: `${langDirective(language)}\n\n${specialSlidesSystemPrompt}`, } - const specialSlidesUserMessage: CoreMessage = { + const specialSlidesUserMessage: ModelMessage = { role: 'user', - content: `Generate the introduction, agenda, assessment, and conclusion slides for a ${difficultyLevel} level ${contentType} on "${topicName}" with title "${metadataResponse.title}" based STRICTLY on the provided source materials above.`, + content: + language === 'id' + ? hasSourceMaterials + ? `Buat slide pengantar, agenda, penilaian, dan kesimpulan untuk ${contentType} tingkat ${difficultyLevel} tentang "${effectiveTopic}" dengan judul "${metadataResponse.title}" SECARA KETAT berdasarkan materi sumber di atas. Gunakan Bahasa Indonesia.` + : `Buat slide pengantar, agenda, penilaian, dan kesimpulan untuk ${contentType} tingkat ${difficultyLevel} tentang "${effectiveTopic}" dengan judul "${metadataResponse.title}". Gunakan Bahasa Indonesia.` + : hasSourceMaterials + ? `Create the introduction, agenda, assessment, and conclusion slides for a ${difficultyLevel} level ${contentType} on "${effectiveTopic}" with the title "${metadataResponse.title}" STRICTLY based on the source materials provided above. Use English only.` + : `Create the introduction, agenda, assessment, and conclusion slides for a ${difficultyLevel} level ${contentType} on "${effectiveTopic}" with the title "${metadataResponse.title}". Use English only.`, } - const specialSlidesMessages = [ - specialSlidesSystemMessage, - assistantMessage, - specialSlidesUserMessage, - ] + const specialSlidesMessages = assistantMessage + ? [specialSlidesSystemMessage, assistantMessage, specialSlidesUserMessage] + : [specialSlidesSystemMessage, specialSlidesUserMessage] const specialSlidesTextResponse = await generateText({ model: ollama(selectedModel), messages: specialSlidesMessages, temperature: TEMPERATURE, - maxTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 4), + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 4), }) console.log( @@ -288,52 +304,34 @@ CRITICAL: Your response MUST be valid JSON only. Do not include any text, markdo `Generating content slides batch ${batchIndex + 1}/${batches} (slides ${startSlideNum}-${endSlideNum})...`, ) - const contentSlidesSystemPrompt = `You are generating content slides ${startSlideNum} through ${endSlideNum} of a total of ${totalContentSlidesNeeded} content slides. Ensure all slides are unique. - -IMPORTANT INSTRUCTIONS: -1. You MUST base your content ENTIRELY on the source materials provided. -2. Extract key concepts, terminology, examples, and explanations directly from the source materials. -3. Do not introduce concepts or information that is not present in the source materials. -4. Create detailed teaching slides with substantial content on each slide. -5. Focus ONLY on core teaching content slides. -6. Each slide should have comprehensive speaker notes with additional details and examples. -7. You are generating content slides ${startSlideNum} through ${endSlideNum} of a total of ${totalContentSlidesNeeded} content slides. -8. DO NOT create introduction, agenda, assessment, or conclusion slides - these are handled separately. - -RESPONSE FORMAT: -Your response MUST be a valid JSON object with EXACTLY these fields: -{ -"contentSlides": [ - { - "title": "Slide Title", - "content": [ - "Include multiple detailed points with examples and context", - "Each array item represents a bullet point or paragraph on the slide" - ], - "notes": "Comprehensive speaker notes with additional details, examples, and teaching tips" - } -] -} - -CRITICAL: Your response MUST be valid JSON only. Do not include any text, markdown, explanations, or other content outside the JSON object. Do not include backticks or code block markers.` + const contentSlidesSystemPrompt = getContentSlidesSystemPrompt( + startSlideNum, + endSlideNum, + totalContentSlidesNeeded, + language, + hasSourceMaterials, + ) - const contentSlidesSystemMessage: CoreMessage = { + const contentSlidesSystemMessage: ModelMessage = { role: 'system', - content: contentSlidesSystemPrompt, + content: `${langDirective(language)}\n\n${contentSlidesSystemPrompt}`, } - const contentSlidesUserMessage: CoreMessage = { + const contentSlidesUserMessage: ModelMessage = { role: 'user', - content: `Generate content slides ${startSlideNum} through ${endSlideNum} for a ${difficultyLevel} level ${contentType} on "${topicName}" with title "${metadataResponse.title}" based STRICTLY on the provided source materials above. - -DO NOT create introduction, agenda, assessment, or conclusion slides. Focus ONLY on core teaching content slides.`, + content: + language === 'id' + ? hasSourceMaterials + ? `Buat slide konten ${startSlideNum} hingga ${endSlideNum} untuk ${contentType} tingkat ${difficultyLevel} tentang "${effectiveTopic}" dengan judul "${metadataResponse.title}" SECARA KETAT berdasarkan materi sumber di atas.\n\nJANGAN membuat slide pengantar, agenda, penilaian, atau kesimpulan. Fokus HANYA pada slide konten instruksional inti. Gunakan Bahasa Indonesia.` + : `Buat slide konten ${startSlideNum} hingga ${endSlideNum} untuk ${contentType} tingkat ${difficultyLevel} tentang "${effectiveTopic}" dengan judul "${metadataResponse.title}".\n\nJANGAN membuat slide pengantar, agenda, penilaian, atau kesimpulan. Fokus HANYA pada slide konten instruksional inti. Gunakan Bahasa Indonesia.` + : hasSourceMaterials + ? `Create content slides ${startSlideNum} to ${endSlideNum} for a ${difficultyLevel} level ${contentType} on "${effectiveTopic}" with the title "${metadataResponse.title}" STRICTLY based on the source materials provided above.\n\nDO NOT create introduction, agenda, assessment, or conclusion slides. Focus ONLY on core instructional content slides. Use English only.` + : `Create content slides ${startSlideNum} to ${endSlideNum} for a ${difficultyLevel} level ${contentType} on "${effectiveTopic}" with the title "${metadataResponse.title}".\n\nDO NOT create introduction, agenda, assessment, or conclusion slides. Focus ONLY on core instructional content slides. Use English only.`, } - const contentSlidesMessages = [ - contentSlidesSystemMessage, - assistantMessage, - contentSlidesUserMessage, - ] + const contentSlidesMessages = assistantMessage + ? [contentSlidesSystemMessage, assistantMessage, contentSlidesUserMessage] + : [contentSlidesSystemMessage, contentSlidesUserMessage] const contentSlidesTextResponse = await generateText({ model: ollama(selectedModel), @@ -347,7 +345,7 @@ DO NOT create introduction, agenda, assessment, or conclusion slides. Focus ONLY // ], messages: contentSlidesMessages, temperature: TEMPERATURE, - maxTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 2), + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 2), }) const contentSlidesResponse = extractAndParseJSON(contentSlidesTextResponse.text) @@ -372,25 +370,38 @@ DO NOT create introduction, agenda, assessment, or conclusion slides. Focus ONLY contentTypePrompt, contentStylePrompt, difficultyLevelPrompt, + 2, + specializedPrompt, + language, + hasSourceMaterials, ) - const activitiesSystemMessage: CoreMessage = { + const activitiesSystemMessage: ModelMessage = { role: 'system', - content: activitiesSystemPrompt, + content: `${langDirective(language)}\n\n${activitiesSystemPrompt}`, } - const activitiesUserMessage: CoreMessage = { + const activitiesUserMessage: ModelMessage = { role: 'user', - content: `Generate the activities for a ${difficultyLevel} level ${contentType} on "${topicName}" with title "${metadataResponse.title}" based STRICTLY on the provided source materials above.`, + content: + language === 'id' + ? hasSourceMaterials + ? `Buat aktivitas untuk ${contentType} tingkat ${difficultyLevel} tentang "${effectiveTopic}" dengan judul "${metadataResponse.title}" SECARA KETAT berdasarkan materi sumber di atas. Gunakan Bahasa Indonesia.` + : `Buat aktivitas untuk ${contentType} tingkat ${difficultyLevel} tentang "${effectiveTopic}" dengan judul "${metadataResponse.title}". Gunakan Bahasa Indonesia.` + : hasSourceMaterials + ? `Create activities for a ${difficultyLevel} level ${contentType} on "${effectiveTopic}" with the title "${metadataResponse.title}" STRICTLY based on the source materials provided above. Use English only.` + : `Create activities for a ${difficultyLevel} level ${contentType} on "${effectiveTopic}" with the title "${metadataResponse.title}". Use English only.`, } - const activitiesMessages = [activitiesSystemMessage, assistantMessage, activitiesUserMessage] + const activitiesMessages = assistantMessage + ? [activitiesSystemMessage, assistantMessage, activitiesUserMessage] + : [activitiesSystemMessage, activitiesUserMessage] const activitiesTextResponse = await generateText({ model: ollama(selectedModel), messages: activitiesMessages, temperature: TEMPERATURE, - maxTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 4), + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 4), }) console.log('Raw activities response:', activitiesTextResponse.text.substring(0, 500) + '...') @@ -406,27 +417,39 @@ DO NOT create introduction, agenda, assessment, or conclusion slides. Focus ONLY const assessmentSystemPrompt = getAssessmentSystemPrompt( difficultyLevel, contentType, - topicName, + effectiveTopic, sessionLength, + '', + language, + hasSourceMaterials, ) - const assessmentSystemMessage: CoreMessage = { + const assessmentSystemMessage: ModelMessage = { role: 'system', - content: assessmentSystemPrompt, + content: `${langDirective(language)}\n\n${assessmentSystemPrompt}`, } - const assessmentUserMessage: CoreMessage = { + const assessmentUserMessage: ModelMessage = { role: 'user', - content: `Generate assessment ideas (without example questions) for a ${difficultyLevel} level ${contentType} on "${topicName}" with title "${metadataResponse.title}" based STRICTLY on the provided source materials above.`, + content: + language === 'id' + ? hasSourceMaterials + ? `Buat ide penilaian (tanpa contoh pertanyaan) untuk ${contentType} tingkat ${difficultyLevel} tentang "${effectiveTopic}" dengan judul "${metadataResponse.title}" SECARA KETAT berdasarkan materi sumber di atas. Gunakan Bahasa Indonesia.` + : `Buat ide penilaian (tanpa contoh pertanyaan) untuk ${contentType} tingkat ${difficultyLevel} tentang "${effectiveTopic}" dengan judul "${metadataResponse.title}". Gunakan Bahasa Indonesia.` + : hasSourceMaterials + ? `Create assessment ideas (without example questions) for a ${difficultyLevel} level ${contentType} on "${effectiveTopic}" with the title "${metadataResponse.title}" STRICTLY based on the source materials provided above. Use English only.` + : `Create assessment ideas (without example questions) for a ${difficultyLevel} level ${contentType} on "${effectiveTopic}" with the title "${metadataResponse.title}". Use English only.`, } - const assessmentMessages = [assessmentSystemMessage, assistantMessage, assessmentUserMessage] + const assessmentMessages = assistantMessage + ? [assessmentSystemMessage, assistantMessage, assessmentUserMessage] + : [assessmentSystemMessage, assessmentUserMessage] const assessmentTextResponse = await generateText({ model: ollama(selectedModel), messages: assessmentMessages, temperature: TEMPERATURE, - maxTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 6), + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 6), }) console.log('Raw assessment response:', assessmentTextResponse.text.substring(0, 500) + '...') @@ -468,27 +491,39 @@ DO NOT create introduction, agenda, assessment, or conclusion slides. Focus ONLY const readingsSystemPrompt = getReadingsSystemPrompt( difficultyLevel, contentType, - topicName, + effectiveTopic, sessionLength, + '', + language, + hasSourceMaterials, ) - const readingsSystemMessage: CoreMessage = { + const readingsSystemMessage: ModelMessage = { role: 'system', - content: readingsSystemPrompt, + content: `${langDirective(language)}\n\n${readingsSystemPrompt}`, } - const readingsUserMessage: CoreMessage = { + const readingsUserMessage: ModelMessage = { role: 'user', - content: `Generate further reading suggestions for a ${difficultyLevel} level ${contentType} on "${topicName}" with title "${metadataResponse.title}" based STRICTLY on the provided source materials above.`, + content: + language === 'id' + ? hasSourceMaterials + ? `Buat rekomendasi bacaan lanjutan untuk ${contentType} tingkat ${difficultyLevel} tentang "${effectiveTopic}" dengan judul "${metadataResponse.title}" SECARA KETAT berdasarkan materi sumber di atas. Gunakan Bahasa Indonesia.` + : `Buat rekomendasi bacaan lanjutan untuk ${contentType} tingkat ${difficultyLevel} tentang "${effectiveTopic}" dengan judul "${metadataResponse.title}". Gunakan Bahasa Indonesia.` + : hasSourceMaterials + ? `Create further reading suggestions for a ${difficultyLevel} level ${contentType} on "${effectiveTopic}" with the title "${metadataResponse.title}" STRICTLY based on the source materials provided above. Use English only.` + : `Create further reading suggestions for a ${difficultyLevel} level ${contentType} on "${effectiveTopic}" with the title "${metadataResponse.title}". Use English only.`, } - const readingsMessages = [readingsSystemMessage, assistantMessage, readingsUserMessage] + const readingsMessages = assistantMessage + ? [readingsSystemMessage, assistantMessage, readingsUserMessage] + : [readingsSystemMessage, readingsUserMessage] const readingsTextResponse = await generateText({ model: ollama(selectedModel), messages: readingsMessages, temperature: TEMPERATURE, - maxTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 6), + maxOutputTokens: Math.floor(TOKEN_RESPONSE_BUDGET / 6), }) console.log('Raw readings response:', readingsTextResponse.text.substring(0, 500) + '...') @@ -520,14 +555,14 @@ DO NOT create introduction, agenda, assessment, or conclusion slides. Focus ONLY try { if (idea.type.toLowerCase().includes('quiz')) { - const prompt = getQuizQuestionPrompt(topicName, idea.description) + const prompt = `${langDirective(language)}\n\n${getQuizQuestionPrompt(topicName, idea.description, language)}` // Generate JSON-formatted questions for quiz const { text: questionsText } = await generateText({ model: ollama(selectedModel), prompt: prompt, temperature: TEMPERATURE, - maxTokens: 1000, + maxOutputTokens: 1000, }) console.log( @@ -588,14 +623,14 @@ DO NOT create introduction, agenda, assessment, or conclusion slides. Focus ONLY }) } } else if (idea.type.toLowerCase().includes('discussion')) { - const prompt = getDiscussionQuestionPrompt(topicName, idea.description) + const prompt = `${langDirective(language)}\n\n${getDiscussionQuestionPrompt(topicName, idea.description, language)}` // Generate JSON-formatted questions for discussion const { text: questionsText } = await generateText({ model: ollama(selectedModel), prompt: prompt, temperature: TEMPERATURE, - maxTokens: 1500, + maxOutputTokens: 1500, }) console.log( diff --git a/frontend/src/app/api/slide/download-pdf/route.ts b/frontend/src/app/api/slide/download-pdf/route.ts index 8189a46..5b6f759 100644 --- a/frontend/src/app/api/slide/download-pdf/route.ts +++ b/frontend/src/app/api/slide/download-pdf/route.ts @@ -2,31 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import { NextResponse, type NextRequest } from 'next/server' -import jsPDF from 'jspdf' import type { LectureContent } from '../types' -import type { ExplanationObject } from '@/lib/types/assessment-types' +import { generateLecturePdf } from '@/lib/pdf/generateLecturePdf' +// Thin API wrapper – all PDF composition handled in lib/pdf modules export async function POST(request: NextRequest) { try { - const { content } = await request.json() - + const { content, language } = await request.json() if (!content || !content.title || !content.slides) { return NextResponse.json({ error: 'Invalid content structure' }, { status: 400 }) } - - console.log('Generating PDF for:', content.title) - - // Generate the PDF file - const pdfBuffer = await generatePDF(content) - - if (!pdfBuffer || !(pdfBuffer instanceof Buffer)) { - console.error('Invalid PDF buffer returned:', typeof pdfBuffer) - return NextResponse.json({ error: 'Failed to generate valid PDF file' }, { status: 500 }) - } - - console.log('PDF generated successfully, buffer size:', pdfBuffer.length) - - // Return the PDF file as a downloadable response + const lang = language === 'id' ? 'id' : 'en' + const pdfBuffer = await generateLecturePdf(content as LectureContent, lang) return new NextResponse(new Uint8Array(pdfBuffer), { headers: { 'Content-Type': 'application/pdf', @@ -35,1030 +22,8 @@ export async function POST(request: NextRequest) { )}.pdf"`, }, }) - } catch (error) { - console.error('Error generating PDF:', error) - return NextResponse.json({ error: 'Failed to generate PDF document' }, { status: 500 }) - } -} - -async function generatePDF(content: LectureContent): Promise { - // Create a new jsPDF instance (A4 size in portrait orientation) - const pdf = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: 'a4', - }) - - // Define page dimensions and margins (in mm) - const pageWidth = 210 - const pageHeight = 297 - const margin = 20 - const contentWidth = pageWidth - margin * 2 - - // Define colors to match the images - const purpleColor = [94, 53, 177] // RGB for #5E35B1 (purple) - const lightBlueColor = [240, 248, 255] // RGB for #F0F8FF (light blue background) - - // Define standard font sizes - const FONT_SIZE_SECTION_TITLE = 16 - const FONT_SIZE_TITLE = 14 - const FONT_SIZE_SUBTITLE = 12 - const FONT_SIZE_STANDARD = 11 - const FONT_SIZE_SMALL = 10 - const FONT_SIZE_FOOTER = 10 - - // Add custom font if needed - pdf.setFont('helvetica') - - // Function to add a page break - const addPageBreak = () => { - pdf.addPage() - addFooter() - } - - // Function to add footer - const addFooter = () => { - pdf.setFontSize(FONT_SIZE_FOOTER) - pdf.setTextColor(100, 100, 100) // Gray color - pdf.text(`Generated on ${new Date().toLocaleDateString()}`, pageWidth / 2, pageHeight - 10, { - align: 'center', - }) - } - - // Function to add a section header - const addSectionHeader = (title: string, yPos: number) => { - // Add section title - pdf.setFontSize(FONT_SIZE_SECTION_TITLE) - pdf.setTextColor(purpleColor[0], purpleColor[1], purpleColor[2]) - pdf.setFont('helvetica', 'bold') - pdf.text(title, margin, yPos) - - // Add horizontal line - yPos += 5 - pdf.setDrawColor(200, 200, 200) // Light gray - pdf.line(margin, yPos, pageWidth - margin, yPos) - - return yPos + 10 - } - - // Start adding content to PDF - let yPosition = margin - - // Add title - pdf.setFontSize(FONT_SIZE_TITLE) - pdf.setTextColor(0, 0, 0) - pdf.setFont('helvetica', 'bold') - pdf.text(content.title, margin, yPosition) - yPosition += 8 - - // Add metadata - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setFont('helvetica', 'normal') - pdf.text(`Content Type: ${content.contentType || 'Lecture'}`, margin, yPosition) - yPosition += 6 - pdf.text(`Difficulty Level: ${content.difficultyLevel || 'Intermediate'}`, margin, yPosition) - yPosition += 10 - - // Add Introduction - if (content.introduction && content.introduction.trim() !== '') { - pdf.setFont('helvetica', 'bold') - pdf.text('Introduction', margin, yPosition) - yPosition += 6 - - // Add introduction text - pdf.setFont('helvetica', 'normal') - const introLines = pdf.splitTextToSize(content.introduction, contentWidth) - pdf.text(introLines, margin, yPosition) - yPosition += introLines.length * 6 - } - - // Add Learning Outcomes - pdf.setFont('helvetica', 'bold') - pdf.text('Learning Outcomes', margin, yPosition) - yPosition += 6 - - // Add learning outcomes as a numbered list - pdf.setFont('helvetica', 'normal') - for (let i = 0; i < content.learningOutcomes.length; i++) { - const outcomeLines = pdf.splitTextToSize( - `${i + 1}. ${content.learningOutcomes[i]}`, - contentWidth, - ) - pdf.text(outcomeLines, margin, yPosition) - yPosition += outcomeLines.length * 6 - } - yPosition += 5 - - // --- KEY TERMS SECTION --- - if (content.keyTerms && content.keyTerms.length > 0) { - addPageBreak() - yPosition = margin - yPosition = addSectionHeader('Key Terms', yPosition) - - // Set smaller font for key terms and definitions - const KEY_TERM_FONT_SIZE = 11 - const KEY_TERM_DEF_FONT_SIZE = 10 - - // Add each key term - matching the image format - for (const term of content.keyTerms) { - // Check if we need a new page - if (yPosition > pageHeight - margin - 20) { - addPageBreak() - yPosition = margin - // Ensure font is reset after page break - pdf.setFont('helvetica', 'bold') - pdf.setFontSize(KEY_TERM_FONT_SIZE) - } - - // Add term name in bold, smaller font - pdf.setFont('helvetica', 'bold') - pdf.setFontSize(KEY_TERM_FONT_SIZE) - pdf.setTextColor(0, 0, 0) - pdf.text(term.term, margin, yPosition) - yPosition += 6 - - // Add definition with indentation, smaller font - pdf.setFont('helvetica', 'normal') - pdf.setFontSize(KEY_TERM_DEF_FONT_SIZE) - const definitionLines = pdf.splitTextToSize(term.definition, contentWidth - 10) - pdf.text(definitionLines, margin + 10, yPosition) - yPosition += definitionLines.length * 5 + 7 - } - } - - // --- SLIDES SECTION --- - if (content.slides && content.slides.length > 0) { - addPageBreak() - yPosition = margin - yPosition = addSectionHeader('Slides', yPosition) - - // Add each slide - matching the image format exactly - for (let i = 0; i < content.slides.length; i++) { - const slide = content.slides[i] - - // Check if we need a new page - if (yPosition > pageHeight - margin - 80) { - addPageBreak() - yPosition = margin - // Re-add the section header if we're at the start of a new page - yPosition = addSectionHeader('Slides', yPosition) - } - - // Calculate height needed for this slide - const slideContentLines = slide.content - .map((point: string) => pdf.splitTextToSize(point, contentWidth - 25)) - .reduce((acc, lines) => acc + lines.length, 0) - const slideContentHeight = slideContentLines * 6 + slide.content.length * 2 + 5 // reduced bottom space - - // Wrap speaker notes text if too long - const speakerNotesBoxWidth = contentWidth - 16 - const label = 'Speaker Notes:' - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'bold') - - // Wrap speaker notes content to fit inside the box, full width minus padding - const speakerNotesLinesBox = pdf.splitTextToSize(slide.notes, speakerNotesBoxWidth - 16) - // Height: label line + content lines + top/bottom padding (6+6+2) - const speakerNotesHeightBox = 6 + speakerNotesLinesBox.length * 6 + 2 // reduced bottom padding - - // Calculate total height for the slide box (title + content + speaker notes + spacing) - const totalSlideHeight = 25 + slideContentHeight + speakerNotesHeightBox + 5 // reduced bottom space - - // Draw the vertical purple line on the left - pdf.setDrawColor(purpleColor[0], purpleColor[1], purpleColor[2]) - pdf.setLineWidth(2) - pdf.line(margin, yPosition, margin, yPosition + totalSlideHeight) - pdf.setLineWidth(0.1) - - // Draw the slide background (now includes speaker notes) - pdf.setFillColor(245, 247, 250) // Light gray background #F5F7FA - pdf.rect(margin + 2, yPosition, contentWidth - 2, totalSlideHeight, 'F') - - // Add slide number and title in purple - pdf.setFontSize(FONT_SIZE_TITLE) - pdf.setTextColor(purpleColor[0], purpleColor[1], purpleColor[2]) - pdf.setFont('helvetica', 'normal') - pdf.text(`${i + 1}. ${slide.title}`, margin + 10, yPosition + 15) - let slideY = yPosition + 30 - - // Add slide content as bullet points - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setTextColor(0, 0, 0) - pdf.setFont('helvetica', 'normal') - - for (const point of slide.content) { - const pointLines = pdf.splitTextToSize(point, contentWidth - 25) - pdf.text('•', margin + 10, slideY) - pdf.text(pointLines, margin + 15, slideY) - slideY += pointLines.length * 6 + 2 - } - - slideY += 3 // reduced space before speaker notes - - // Draw the speaker notes box inside the slide box - pdf.setFillColor(240, 240, 240) // Light gray for speaker notes - pdf.rect(margin + 8, slideY, speakerNotesBoxWidth, speakerNotesHeightBox, 'F') - - // Draw label - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'bold') - pdf.setTextColor(80, 80, 80) - pdf.text(label, margin + 12, slideY + 8) - - // Draw content one line below the label, left-aligned with label - pdf.setFont('helvetica', 'italic') - pdf.text(speakerNotesLinesBox, margin + 12, slideY + 14) - - // Move yPosition for next slide - yPosition += totalSlideHeight + 2 // reduced space after slide - } - } - - // --- ACTIVITIES SECTION --- - if (content.activities && content.activities.length > 0) { - addPageBreak() - yPosition = margin - yPosition = addSectionHeader('Activities', yPosition) - - for (let a = 0; a < content.activities.length; a++) { - const activity = content.activities[a] - if (a > 0) { - addPageBreak() - yPosition = margin - yPosition = addSectionHeader('Activities', yPosition) - } - - // Estimate activity box height (you can refine this as needed) - let activityHeight = 40 // base height for title/meta/desc - const descLines = pdf.splitTextToSize(activity.description, contentWidth - 20) - activityHeight += descLines.length * 6 - - let instructionsHeight = 0 - for (let i = 0; i < activity.instructions.length; i++) { - const instructionLines = pdf.splitTextToSize(activity.instructions[i], contentWidth - 30) - instructionsHeight += instructionLines.length * 6 + 2 - } - activityHeight += 12 + instructionsHeight // header + instructions - - let materialsHeight = 0 - for (const material of activity.materials) { - const materialLines = pdf.splitTextToSize(material, contentWidth - 30) - materialsHeight += materialLines.length * 6 + 2 - } - activityHeight += 12 + materialsHeight // header + materials - - // Draw the left border - pdf.setDrawColor(0, 153, 255) // Light blue border - pdf.setLineWidth(1.5) - pdf.line(margin, yPosition, margin, yPosition + activityHeight) - pdf.setLineWidth(0.1) - - // Draw the activity background - pdf.setFillColor(lightBlueColor[0], lightBlueColor[1], lightBlueColor[2]) - pdf.rect(margin + 1.5, yPosition, contentWidth - 1.5, activityHeight, 'F') - - // Add activity title in purple - pdf.setFontSize(FONT_SIZE_TITLE) - pdf.setTextColor(purpleColor[0], purpleColor[1], purpleColor[2]) - pdf.setFont('helvetica', 'bold') - pdf.text(activity.title, margin + 10, yPosition + 10) - let activityY = yPosition + 15 - - // Add activity meta (type and duration) on the same line - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setTextColor(0, 0, 0) - pdf.setFont('helvetica', 'normal') - pdf.text(`Type: ${activity.type}`, margin + 10, activityY) - pdf.text(`Duration: ${activity.duration}`, margin + contentWidth - 60, activityY) - activityY += 10 - - // Add activity description - pdf.setFont('helvetica', 'normal') - pdf.text(descLines, margin + 10, activityY) - activityY += descLines.length * 6 + 5 - - // Add instructions header in purple - pdf.setFontSize(FONT_SIZE_SUBTITLE) - pdf.setTextColor(purpleColor[0], purpleColor[1], purpleColor[2]) - pdf.setFont('helvetica', 'bold') - pdf.text('Instructions:', margin + 10, activityY) - activityY += 8 - - // Add numbered instructions - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setTextColor(0, 0, 0) - pdf.setFont('helvetica', 'normal') - for (let i = 0; i < activity.instructions.length; i++) { - const instructionLines = pdf.splitTextToSize(activity.instructions[i], contentWidth - 30) - pdf.text(`${i + 1}.`, margin + 10, activityY) - pdf.text(instructionLines, margin + 20, activityY) - activityY += instructionLines.length * 6 + 2 - } - - // Add materials needed header in purple - pdf.setFontSize(FONT_SIZE_SUBTITLE) - pdf.setTextColor(purpleColor[0], purpleColor[1], purpleColor[2]) - pdf.setFont('helvetica', 'bold') - pdf.text('Materials Needed:', margin + 10, activityY + 6) - activityY += 12 - - // Add materials as bullet points - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setTextColor(0, 0, 0) - pdf.setFont('helvetica', 'normal') - for (const material of activity.materials) { - const materialLines = pdf.splitTextToSize(material, contentWidth - 30) - pdf.text('•', margin + 10, activityY) - pdf.text(materialLines, margin + 15, activityY) - activityY += materialLines.length * 6 + 2 - } - - yPosition += activityHeight + 10 // Add space between activity boxes - } - } - - // --- ASSESSMENT IDEAS SECTION --- - if (content.assessmentIdeas && content.assessmentIdeas.length > 0) { - addPageBreak() - yPosition = margin - - // Add Assessment Ideas section header with enhanced styling - pdf.setFontSize(FONT_SIZE_SECTION_TITLE) - pdf.setTextColor(purpleColor[0], purpleColor[1], purpleColor[2]) - pdf.setFont('helvetica', 'bold') - pdf.text('Assessment Ideas', margin, yPosition) - - // Add horizontal line - yPosition += 5 - pdf.setDrawColor(200, 200, 200) // Light gray - pdf.line(margin, yPosition, pageWidth - margin, yPosition) - yPosition += 10 - - // Separate quizzes and discussions - const quizIdeas = content.assessmentIdeas.filter((idea) => - idea.type.toLowerCase().includes('quiz'), - ) - const discussionIdeas = content.assessmentIdeas.filter((idea) => - idea.type.toLowerCase().includes('discussion'), - ) - const otherIdeas = content.assessmentIdeas.filter( - (idea) => - !idea.type.toLowerCase().includes('quiz') && - !idea.type.toLowerCase().includes('discussion'), - ) - - // --- QUIZ SECTION --- - if (quizIdeas.length > 0) { - // Add Quiz header with icon-like element - pdf.setFillColor(79, 70, 229) // Purple - pdf.circle(margin + 4, yPosition + 4, 4, 'F') - - pdf.setFontSize(FONT_SIZE_TITLE) - pdf.setTextColor(79, 70, 229) // Purple - pdf.setFont('helvetica', 'bold') - pdf.text('Quiz Assessments', margin + 12, yPosition + 5) - yPosition += 15 - - for (const idea of quizIdeas) { - // Calculate height for description box (title, meta, description, example questions header) - const descLines = pdf.splitTextToSize(idea.description, contentWidth - 10) - const descHeight = descLines.length * 6 + 5 - - // Height for "Example Questions" header - const exampleHeaderHeight = 15 - - // Description box height (title/meta/desc/header) - const descBoxHeight = 22 + descHeight + exampleHeaderHeight - - // Check if we need a new page for the description box - if (yPosition > pageHeight - margin - descBoxHeight - 10) { - addPageBreak() - yPosition = margin - } - - // Draw quiz description box - pdf.setFillColor(245, 247, 250) // Light gray background - pdf.roundedRect(margin, yPosition, contentWidth, descBoxHeight, 3, 3, 'F') - - // Add a colored stripe at the top - pdf.setFillColor(79, 70, 229) // Purple - pdf.rect(margin, yPosition, contentWidth, 5, 'F') - - // Assessment title and meta - pdf.setFontSize(FONT_SIZE_SUBTITLE) - pdf.setTextColor(0, 0, 0) - pdf.setFont('helvetica', 'bold') - pdf.text(idea.type, margin + 10, yPosition + 13) - - // Duration with clock icon simulation - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'normal') - pdf.text(`⏱ Duration: ${idea.duration}`, margin + contentWidth - 70, yPosition + 13) - - // Description (inside the description box) - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setFont('helvetica', 'normal') - pdf.text(descLines, margin + 10, yPosition + 22) - - // "Example Questions" header - pdf.setFillColor(230, 230, 250) // Lavender - pdf.roundedRect(margin + 5, yPosition + 22 + descHeight, contentWidth - 10, 10, 2, 2, 'F') - pdf.setFontSize(FONT_SIZE_SUBTITLE) - pdf.setTextColor(79, 70, 229) // Purple - pdf.setFont('helvetica', 'bold') - pdf.text('Example Questions', margin + contentWidth / 2, yPosition + 22 + descHeight + 7, { - align: 'center', - }) - - // Move yPosition below the description box for questions - yPosition += descBoxHeight + 10 - - // Now render each question as a separate box, each on a new page except the first (if you want) - for (let q = 0; q < idea.exampleQuestions.length; q++) { - if (q > 0) { - addPageBreak() - yPosition = margin - } - - const question = idea.exampleQuestions[q] - const questionLines = pdf.splitTextToSize(question.question, contentWidth - 50) - const questionHeight = Math.max(questionLines.length * 6 + 10, 30) - - let optionsHeight = 0 - if (question.options && question.options.length > 0) { - for (let o = 0; o < question.options.length; o++) { - const optionLines = pdf.splitTextToSize(question.options[o], contentWidth - 70) - optionsHeight += Math.max(optionLines.length * 6, 15) - } - } - - let answerHeight = 0 - let answerLines: string[] = [] - if (question.correctAnswer) { - answerLines = pdf.splitTextToSize(question.correctAnswer, contentWidth - 100) - answerHeight = Math.max(answerLines.length * 6, 15) + 2 - } - - let explanationHeight = 0 - let explanationLines: string[] = [] - if (question.explanation) { - const explanationText = - typeof question.explanation === 'string' - ? question.explanation - : JSON.stringify(question.explanation, null, 2) - explanationLines = pdf.splitTextToSize(explanationText, contentWidth - 100) - explanationHeight = Math.max(explanationLines.length * 6, 15) + 2 - } - - // Calculate total box height for this question - const totalBoxHeight = - questionHeight + - optionsHeight + - (answerHeight ? answerHeight + 5 : 0) + - (explanationHeight ? explanationHeight + 5 : 0) + - 20 - - // Draw question box with enough height - pdf.setFillColor(250, 250, 250) // White - pdf.setDrawColor(220, 220, 220) // Light gray border - pdf.roundedRect(margin + 10, yPosition, contentWidth - 20, totalBoxHeight, 3, 3, 'FD') - - // Question number in a circle - pdf.setFillColor(79, 70, 229) // Purple - pdf.circle(margin + 25, yPosition + 15, 8, 'F') - pdf.setTextColor(255, 255, 255) // White - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'bold') - pdf.text(`${q + 1}`, margin + 25, yPosition + 17, { align: 'center' }) - - // Question text - pdf.setTextColor(0, 0, 0) // Black - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setFont('helvetica', 'normal') - pdf.text(questionLines, margin + 40, yPosition + 15) - - let qBoxY = yPosition + 15 + questionLines.length * 6 + 5 - - // Options with attractive styling - if (question.options && question.options.length > 0) { - for (let o = 0; o < question.options.length; o++) { - pdf.setFillColor(245, 247, 250) // Light gray - pdf.roundedRect(margin + 30, qBoxY, contentWidth - 60, 10, 2, 2, 'F') - - pdf.setFillColor(200, 200, 230) // Light purple - pdf.circle(margin + 40, qBoxY + 5, 5, 'F') - pdf.setTextColor(0, 0, 0) // Black - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'bold') - pdf.text(String.fromCharCode(65 + o), margin + 40, qBoxY + 7, { align: 'center' }) - - pdf.setFont('helvetica', 'normal') - const optionLines = pdf.splitTextToSize(question.options[o], contentWidth - 80) - pdf.text(optionLines, margin + 50, qBoxY + 5) - qBoxY += Math.max(optionLines.length * 6, 15) - } - } - - // Correct Answer with visual indicator - if (question.correctAnswer) { - qBoxY += 5 - pdf.setFillColor(230, 250, 230) // Light green - pdf.roundedRect(margin + 30, qBoxY, contentWidth - 60, answerHeight, 2, 2, 'F') - - pdf.setTextColor(0, 130, 0) // Dark green - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'bold') - pdf.text('Correct Answer:', margin + 40, qBoxY + 5) - - pdf.setTextColor(0, 0, 0) // Black - pdf.setFont('helvetica', 'normal') - pdf.text(answerLines, margin + 90, qBoxY + 5) - qBoxY += answerHeight - } - - // Explanation/Mark Allocation with visual styling - if (question.explanation) { - qBoxY += 5 - pdf.setFillColor(240, 245, 250) // Light blue - pdf.roundedRect(margin + 30, qBoxY, contentWidth - 60, explanationHeight, 2, 2, 'F') - - pdf.setTextColor(0, 0, 150) // Dark blue - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'bold') - pdf.text('Explanation:', margin + 40, qBoxY + 5) - - pdf.setTextColor(0, 0, 0) // Black - pdf.setFont('helvetica', 'normal') - pdf.text(explanationLines, margin + 90, qBoxY + 5) - qBoxY += explanationHeight - } - - yPosition += totalBoxHeight + 15 // Space between questions - } - } - } - - // --- DISCUSSION SECTION --- - if (discussionIdeas.length > 0) { - // Add page break before Discussion section - addPageBreak() - yPosition = margin - - // Add Discussion header with icon-like element - pdf.setFillColor(14, 165, 233) // Blue - pdf.circle(margin + 4, yPosition + 4, 4, 'F') - - pdf.setFontSize(FONT_SIZE_TITLE) - pdf.setTextColor(14, 165, 233) // Blue - pdf.setFont('helvetica', 'bold') - pdf.text('Discussion Assessments', margin + 12, yPosition + 5) - yPosition += 15 - - for (const idea of discussionIdeas) { - // --- DISCUSSION DESCRIPTION CARD --- - const descLines = pdf.splitTextToSize(idea.description, contentWidth - 10) - const descHeight = descLines.length * 6 + 5 - const exampleHeaderHeight = 15 - const descBoxHeight = 22 + descHeight + exampleHeaderHeight - - if (yPosition > pageHeight - margin - descBoxHeight - 10) { - addPageBreak() - yPosition = margin - } - - // Draw discussion description box (full width) - pdf.setFillColor(240, 249, 255) - pdf.roundedRect(margin, yPosition, contentWidth, descBoxHeight, 3, 3, 'F') - pdf.setFillColor(14, 165, 233) - pdf.rect(margin, yPosition, contentWidth, 5, 'F') - - pdf.setFontSize(FONT_SIZE_SUBTITLE) - pdf.setTextColor(0, 0, 0) - pdf.setFont('helvetica', 'bold') - pdf.text(idea.type, margin + 10, yPosition + 13) - - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'normal') - pdf.text(`Duration: ${idea.duration}`, margin + contentWidth - 70, yPosition + 13) - - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setFont('helvetica', 'normal') - pdf.text(descLines, margin + 10, yPosition + 22) - - pdf.setFillColor(230, 240, 250) - pdf.roundedRect(margin + 5, yPosition + 22 + descHeight, contentWidth - 10, 10, 2, 2, 'F') - pdf.setFontSize(FONT_SIZE_SUBTITLE) - pdf.setTextColor(14, 165, 233) - pdf.setFont('helvetica', 'bold') - pdf.text('Discussion Topics', margin + contentWidth / 2, yPosition + 22 + descHeight + 7, { - align: 'center', - }) - - yPosition += descBoxHeight + 10 - - // --- INDIVIDUAL DISCUSSION QUESTION CARDS --- - if (idea.exampleQuestions && idea.exampleQuestions.length > 0) { - for (let q = 0; q < idea.exampleQuestions.length; q++) { - const question = idea.exampleQuestions[q] - const questionLines = pdf.splitTextToSize(question.question, contentWidth - 40) - const questionHeight = Math.max(questionLines.length * 6 + 10, 30) - - // Always re-initialize for each question - let guidanceLines: string[] = [] - let guidanceHeight = 0 - if (question.correctAnswer) { - guidanceLines = pdf.splitTextToSize(question.correctAnswer, contentWidth - 40) - guidanceHeight = Math.max(guidanceLines.length * 6, 15) + 8 - } - - // Assessment Criteria Table - let criteriaRows: { name: string; weight: string }[] = [] - let hasCriteria = false - if ( - question.explanation && - typeof question.explanation === 'object' && - 'criteria' in question.explanation && - Array.isArray(question.explanation.criteria) - ) { - hasCriteria = true - const explanationObj = question.explanation as ExplanationObject - criteriaRows = explanationObj.criteria.map((c) => ({ - name: c.name, - weight: `${c.weight}%`, - })) - } - - // Point Allocation Table - let pointAllocRows: { key: string; value: string }[] = [] - let hasPointAlloc = false - - // Check for markAllocation (ExplanationObject structure) - if ( - question.explanation && - typeof question.explanation === 'object' && - 'markAllocation' in question.explanation && - Array.isArray(question.explanation.markAllocation) - ) { - hasPointAlloc = true - const explanationObj = question.explanation as ExplanationObject - pointAllocRows = explanationObj.markAllocation.map((allocation) => ({ - key: allocation.component - .replace(/([A-Z])/g, ' $1') - .trim() - .replace(/^./, (str) => str.toUpperCase()), - value: `${allocation.marks} marks`, - })) - } - // Fallback to pointAllocation (slide types structure) for backward compatibility - else if ( - question.explanation && - typeof question.explanation === 'object' && - 'pointAllocation' in question.explanation && - question.explanation.pointAllocation - ) { - hasPointAlloc = true - const pointAllocation = question.explanation.pointAllocation - if (typeof pointAllocation === 'object' && pointAllocation !== null) { - pointAllocRows = Object.entries(pointAllocation).map(([key, value]) => ({ - key: key - .replace(/([A-Z])/g, ' $1') - .trim() - .replace(/^./, (str) => str.toUpperCase()), - value: `${value} points`, - })) - } else { - pointAllocRows = [{ key: 'Points', value: String(pointAllocation) }] - } - } - - // Fallback for simple explanation - let explanationHeight = 0 - let explanationLines: string[] = [] - if (question.explanation && !hasCriteria && !hasPointAlloc) { - const explanationText = - typeof question.explanation === 'string' - ? question.explanation - : JSON.stringify(question.explanation, null, 2) - explanationLines = pdf.splitTextToSize(explanationText, contentWidth - 40) - explanationHeight = explanationLines.length * 6 + 10 - } - - // Table heights - const tableRowHeight = 8 - const tableHeaderSpacing = 6 // <-- Add spacing between header and first row - const criteriaTableHeight = hasCriteria - ? 10 + tableHeaderSpacing + criteriaRows.length * tableRowHeight - : 0 - const pointAllocTableHeight = hasPointAlloc - ? 10 + tableHeaderSpacing + pointAllocRows.length * tableRowHeight - : 0 - - // Calculate total box height for this question card (full width) - const totalBoxHeight = - questionHeight + - (guidanceHeight ? guidanceHeight + 5 : 0) + - (hasCriteria ? criteriaTableHeight + 5 : 0) + - (hasPointAlloc ? pointAllocTableHeight + 5 : 0) + - (explanationHeight ? explanationHeight + 5 : 0) + - 20 - - // --- PAGE FIT CHECK --- - if (yPosition + totalBoxHeight > pageHeight - margin - 10) { - addPageBreak() - yPosition = margin - } - - // Draw question card (full width) - pdf.setFillColor(250, 250, 250) - pdf.setDrawColor(220, 220, 220) - pdf.roundedRect(margin, yPosition, contentWidth, totalBoxHeight, 3, 3, 'FD') - - // Question number in a circle - pdf.setFillColor(14, 165, 233) - pdf.circle(margin + 15, yPosition + 15, 8, 'F') - pdf.setTextColor(255, 255, 255) - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'bold') - pdf.text(`${q + 1}`, margin + 15, yPosition + 17, { align: 'center' }) - - // Question text - pdf.setTextColor(0, 0, 0) - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setFont('helvetica', 'normal') - pdf.text(questionLines, margin + 30, yPosition + 15) - - let qBoxY = yPosition + 15 + questionLines.length * 6 + 5 - - // --- Discussion Guidance (always wrapped in a card, left-aligned, for every question) --- - if (question.correctAnswer) { - pdf.setFillColor(230, 245, 255) - // Increase width: use margin + 10 and contentWidth - 20 for a wider box - pdf.roundedRect(margin + 10, qBoxY, contentWidth - 20, guidanceHeight, 2, 2, 'F') - - pdf.setTextColor(0, 100, 150) - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'bold') - pdf.text('Discussion Guidance:', margin + 20, qBoxY + 7) - - pdf.setTextColor(0, 0, 0) - pdf.setFont('helvetica', 'normal') - pdf.text(guidanceLines, margin + 20, qBoxY + 14, { maxWidth: contentWidth - 30 }) - qBoxY += guidanceHeight + 5 - } - - // --- Assessment Criteria Table (with header spacing) --- - if (hasCriteria && criteriaRows.length > 0) { - pdf.setFillColor(240, 245, 255) - // Increase width: use margin + 10 and contentWidth - 20 for a wider box - pdf.roundedRect(margin + 10, qBoxY, contentWidth - 20, criteriaTableHeight, 2, 2, 'F') - - pdf.setTextColor(14, 165, 233) - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'bold') - pdf.text('Assessment Criteria:', margin + 20, qBoxY + 7) - - // Table header - pdf.setFont('helvetica', 'bold') - pdf.setTextColor(0, 0, 0) - pdf.text('Criteria', margin + 30, qBoxY + 15) - pdf.text('Weight', margin + contentWidth - 30, qBoxY + 15) - - // Add spacing between header and first row - let rowY = qBoxY + 15 + tableHeaderSpacing - pdf.setFont('helvetica', 'normal') - for (const row of criteriaRows) { - pdf.text(row.name, margin + 30, rowY) - pdf.text(row.weight, margin + contentWidth - 30, rowY) - rowY += tableRowHeight - } - qBoxY += criteriaTableHeight + 5 - } - - // --- Point Allocation Table (with header spacing) --- - if (hasPointAlloc && pointAllocRows.length > 0) { - pdf.setFillColor(235, 245, 255) - // Increase width: use margin + 10 and contentWidth - 20 for a wider box - pdf.roundedRect( - margin + 10, - qBoxY, - contentWidth - 20, - pointAllocTableHeight, - 2, - 2, - 'F', - ) - - pdf.setTextColor(14, 165, 233) - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'bold') - pdf.text('Point Allocation:', margin + 20, qBoxY + 7) - - // Table header - pdf.setFont('helvetica', 'bold') - pdf.setTextColor(0, 0, 0) - pdf.text('Component', margin + 30, qBoxY + 15) - pdf.text('Points', margin + contentWidth - 30, qBoxY + 15) - - // Add spacing between header and first row - let rowY = qBoxY + 15 + tableHeaderSpacing - pdf.setFont('helvetica', 'normal') - for (const row of pointAllocRows) { - pdf.text(row.key, margin + 30, rowY) - pdf.text(row.value, margin + contentWidth - 30, rowY) - rowY += tableRowHeight - } - qBoxY += pointAllocTableHeight + 5 - } - - // Fallback for simple explanation - if (explanationHeight > 0) { - pdf.setFillColor(235, 245, 255) - pdf.roundedRect(margin + 20, qBoxY, contentWidth - 40, explanationHeight, 2, 2, 'F') - - pdf.setTextColor(0, 100, 150) - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'bold') - pdf.text('Assessment Criteria:', margin + 30, qBoxY + 7) - - pdf.setTextColor(0, 0, 0) - pdf.setFont('helvetica', 'normal') - pdf.text(explanationLines, margin + 40, qBoxY + 14) - qBoxY += explanationHeight + 5 - } - - yPosition += totalBoxHeight + 15 - } - } - - yPosition += 10 - } - } - - // --- OTHER ASSESSMENT TYPES --- - if (otherIdeas.length > 0) { - // Add page break before Other Assessments section - addPageBreak() - yPosition = margin - - // Add Other Assessments header with icon-like element - pdf.setFillColor(16, 185, 129) // Green - pdf.circle(margin + 4, yPosition + 4, 4, 'F') - - pdf.setFontSize(FONT_SIZE_TITLE) - pdf.setTextColor(16, 185, 129) // Green - pdf.setFont('helvetica', 'bold') - pdf.text('Other Assessments', margin + 12, yPosition + 5) - yPosition += 15 - - for (const idea of otherIdeas) { - // Check if we need a new page - if (yPosition > pageHeight - margin - 100) { - addPageBreak() - yPosition = margin - } - - // Draw assessment box with gradient-like effect - pdf.setFillColor(240, 253, 244) // Light green background - pdf.roundedRect(margin, yPosition, contentWidth, 30, 3, 3, 'F') - - // Add a colored stripe at the top - pdf.setFillColor(16, 185, 129) // Green - pdf.rect(margin, yPosition, contentWidth, 5, 'F') - - // Assessment title and meta - pdf.setFontSize(FONT_SIZE_SUBTITLE) - pdf.setTextColor(0, 0, 0) - pdf.setFont('helvetica', 'bold') - pdf.text(idea.type, margin + 10, yPosition + 18) - - // Duration with clock icon simulation - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'normal') - pdf.text(`Duration: ${idea.duration}`, margin + contentWidth - 70, yPosition + 18) - - // Description - yPosition += 35 - const descLines = pdf.splitTextToSize(idea.description, contentWidth - 10) - pdf.text(descLines, margin + 5, yPosition) - yPosition += descLines.length * 6 + 10 - - // Process example questions similar to quiz or discussion based on format - if (idea.exampleQuestions && idea.exampleQuestions.length > 0) { - // Add example questions header - pdf.setFillColor(230, 250, 240) // Light green - pdf.roundedRect(margin, yPosition, contentWidth, 10, 2, 2, 'F') - - pdf.setFontSize(FONT_SIZE_SUBTITLE) - pdf.setTextColor(16, 185, 129) // Green - pdf.setFont('helvetica', 'bold') - pdf.text('Example Questions', margin + contentWidth / 2, yPosition + 7, { - align: 'center', - }) - yPosition += 20 - - // Process questions (simplified version of quiz processing) - for (let q = 0; q < idea.exampleQuestions.length; q++) { - const question = idea.exampleQuestions[q] - - // Check if we need a new page - if (yPosition > pageHeight - margin - 80) { - addPageBreak() - yPosition = margin - } - - // Question box - pdf.setFillColor(250, 250, 250) // White - pdf.setDrawColor(220, 220, 220) // Light gray border - pdf.roundedRect(margin, yPosition, contentWidth, 10, 3, 3, 'FD') - - // Question number in a circle - pdf.setFillColor(16, 185, 129) // Green - pdf.circle(margin + 15, yPosition + 15, 8, 'F') - pdf.setTextColor(255, 255, 255) // White - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'bold') - pdf.text(`${q + 1}`, margin + 15, yPosition + 17, { align: 'center' }) - - // Question text - pdf.setTextColor(0, 0, 0) // Black - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setFont('helvetica', 'normal') - const questionLines = pdf.splitTextToSize(question.question, contentWidth - 50) - pdf.text(questionLines, margin + 30, yPosition + 15) - - yPosition += Math.max(questionLines.length * 6 + 10, 30) - - // Add options, answers, and explanations similar to quiz section - // (Simplified for brevity) - if (question.correctAnswer) { - yPosition += 5 - pdf.setFillColor(230, 250, 240) // Light green - pdf.roundedRect(margin + 20, yPosition, contentWidth - 40, 10, 2, 2, 'F') - - pdf.setTextColor(0, 130, 0) // Dark green - pdf.setFontSize(FONT_SIZE_SMALL) - pdf.setFont('helvetica', 'bold') - pdf.text('Model Answer:', margin + 30, yPosition + 5) - - pdf.setTextColor(0, 0, 0) // Black - pdf.setFont('helvetica', 'normal') - const answerLines = pdf.splitTextToSize(question.correctAnswer, contentWidth - 100) - pdf.text(answerLines, margin + 80, yPosition + 5) - yPosition += Math.max(answerLines.length * 6, 15) + 5 - } - - yPosition += 15 // Space between questions - } - } - - yPosition += 10 // Space between assessment types - } - } + } catch (err) { + console.error('Error generating PDF:', err) + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) } - - // --- FURTHER READINGS SECTION --- - if (content.furtherReadings && content.furtherReadings.length > 0) { - addPageBreak() - yPosition = margin - yPosition = addSectionHeader('Further Readings', yPosition) - - // Add each reading - matching the image format - for (const reading of content.furtherReadings) { - // Check if we need a new page - if (yPosition > pageHeight - margin - 40) { - addPageBreak() - yPosition = margin - } - - // Add bullet and reading title in bold - pdf.setFontSize(FONT_SIZE_STANDARD) - pdf.setTextColor(0, 0, 0) - pdf.text('•', margin, yPosition) - - pdf.setFont('helvetica', 'bold') - pdf.text(reading.title, margin + 5, yPosition) - - // Add author on a new line - yPosition += 7 - pdf.setFont('helvetica', 'normal') - pdf.text(`Author: ${reading.author}`, margin + 5, yPosition) - yPosition += 8 - - // Add description with indentation - const descLines = pdf.splitTextToSize(reading.readingDescription, contentWidth - 10) - pdf.text(descLines, margin + 5, yPosition) - yPosition += descLines.length * 6 + 10 - } - } - - // Add footers to all pages - const totalPages = pdf.getNumberOfPages() - for (let i = 1; i <= totalPages; i++) { - pdf.setPage(i) - addFooter() - } - - // Convert the PDF to a Buffer - const pdfBuffer = Buffer.from(pdf.output('arraybuffer')) - return pdfBuffer } diff --git a/frontend/src/app/api/slide/prompts.ts b/frontend/src/app/api/slide/prompts.ts index 3b372e3..236af67 100644 --- a/frontend/src/app/api/slide/prompts.ts +++ b/frontend/src/app/api/slide/prompts.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // System prompts for different content generation steps +import { type Lang } from '@/lib/utils/lang' // Metadata system prompt export function getMetadataSystemPrompt( @@ -11,8 +12,63 @@ export function getMetadataSystemPrompt( contentStylePrompt: string, difficultyLevelPrompt: string, specializedPrompt = '', + language: Lang = 'en', + hasSourceMaterials: boolean = true, ) { - return `You are an expert educational content developer. Create a ${difficultyLevel} level ${contentType} designed for a session. + const idInstructions = hasSourceMaterials + ? `1. Anda HARUS mendasarkan semua konten secara ketat pada materi sumber yang diberikan. +2. Gunakan konsep kunci, terminologi, contoh, dan penjelasan yang DIAMBIL dari sumber, namun TERJEMAHKAN ke Bahasa Indonesia bila sumber bukan Bahasa Indonesia. Jangan menyalin teks non-Bahasa Indonesia. +3. Jangan memperkenalkan konsep atau informasi yang tidak ada di sumber.` + : `1. Dasarkan konten pada topik mata kuliah dan praktik terbaik kurikulum. +2. Fokus pada konsep inti, istilah kunci, dan alur pengajaran yang jelas. +3. Jangan menambahkan informasi yang tidak relevan.` + + const enInstructions = hasSourceMaterials + ? `1. You MUST base all content strictly on the provided source materials. +2. Derive key concepts, terminology, examples, and explanations from the sources, but TRANSLATE them into English when the sources are not in English. Do NOT copy non-English text. +3. Do not introduce concepts or information not present in the sources.` + : `1. Base the content on the course topic and curriculum best practices. +2. Focus on core concepts, key terminology, and a clear instructional flow. +3. Do not add irrelevant information.` + + if (language === 'id') { + return `Anda adalah pengembang konten pendidikan ahli. Buat ${contentType} tingkat ${difficultyLevel} untuk sebuah sesi. + +${contentTypePrompt} + +${contentStylePrompt} + +${difficultyLevelPrompt} + +${specializedPrompt} + +PETUNJUK PENTING: +${idInstructions} +4. Sertakan minimal 5-10 istilah kunci dengan definisi terperinci (semua dalam Bahasa Indonesia). + +PERSYARATAN BAHASA: +- Semua NILAI string dalam JSON (title, learningOutcomes, keyTerms.term, keyTerms.definition) HARUS dalam Bahasa Indonesia saja. Jangan mencampur bahasa. + +FORMAT RESPON: +Respon Anda HARUS berupa objek JSON valid dengan FIELDS berikut (gunakan kunci persis seperti di bawah): +{ + "title": "Main title for the ${contentType}", + "contentType": "${contentType}", + "difficultyLevel": "${difficultyLevel}", + "learningOutcomes": ["Include several clear and measurable learning outcomes"], + "keyTerms": [ + {"term": "Term 1", "definition": "Definition 1"}, + {"term": "Term 2", "definition": "Definition 2"}, + {"term": "Term 3", "definition": "Definition 3"}, + {"term": "Term 4", "definition": "Definition 4"}, + {"term": "Term 5", "definition": "Definition 5"} + ] +} + +PENTING: Respon Anda HARUS berupa JSON valid saja. Jangan sertakan teks, markdown, penjelasan, atau konten di luar objek JSON. Jangan sertakan backticks atau kode blok.` + } + + return `You are an expert educational content developer. Create a ${difficultyLevel} level ${contentType} for a session. ${contentTypePrompt} @@ -23,18 +79,19 @@ ${difficultyLevelPrompt} ${specializedPrompt} IMPORTANT INSTRUCTIONS: -1. You MUST base your content ENTIRELY on the source materials provided. -2. Extract key concepts, terminology, examples, and explanations directly from the source materials. -3. Do not introduce concepts or information that is not present in the source materials. -4. Include at least 5-10 key terms with detailed definitions. +${enInstructions} +4. Include at least 5-10 key terms with detailed definitions (all in English). + +LANGUAGE REQUIREMENTS: +- All JSON string values (title, learningOutcomes, keyTerms.term, keyTerms.definition) MUST be in English only. Do not mix languages. RESPONSE FORMAT: -Your response MUST be a valid JSON object with EXACTLY these fields: +Your response MUST be a valid JSON object with the following FIELDS: { "title": "Main title for the ${contentType}", "contentType": "${contentType}", "difficultyLevel": "${difficultyLevel}", - "learningOutcomes": ["Include multiple clear, measurable learning outcomes"], + "learningOutcomes": ["Include several clear and measurable learning outcomes"], "keyTerms": [ {"term": "Term 1", "definition": "Definition 1"}, {"term": "Term 2", "definition": "Definition 2"}, @@ -44,7 +101,7 @@ Your response MUST be a valid JSON object with EXACTLY these fields: ] } -CRITICAL: Your response MUST be valid JSON only. Do not include any text, markdown, explanations, or other content outside the JSON object. Do not include backticks or code block markers.` +CRITICAL: Your response MUST be valid JSON only. Do not include text, markdown, explanations, or any content outside the JSON object. Do not include backticks or code fences.` } // Content system prompt @@ -56,8 +113,51 @@ export function getContentSystemPrompt( difficultyLevelPrompt: string, recommendedSlides = 5, specializedPrompt = '', + language: Lang = 'en', ) { - return `You are an expert educational content developer. Continue creating a ${difficultyLevel} level ${contentType} designed for a session. + if (language === 'id') { + return `Anda adalah pengembang konten pendidikan ahli. Lanjutkan membuat ${contentType} tingkat ${difficultyLevel} untuk sebuah sesi. + +${contentTypePrompt} + +${contentStylePrompt} + +${difficultyLevelPrompt} + +${specializedPrompt} + +PETUNJUK PENTING: +1. Anda HARUS mendasarkan semua konten secara ketat pada materi sumber yang diberikan. +2. Gunakan konsep kunci, terminologi, contoh, dan penjelasan yang DIAMBIL dari sumber, namun TERJEMAHKAN ke Bahasa Indonesia bila sumber bukan Bahasa Indonesia. Jangan menyalin teks non-Bahasa Indonesia. +3. Jangan memperkenalkan konsep atau informasi yang tidak ada di sumber. +4. Gunakan contoh, terminologi, dan penjelasan spesifik dari sumber. +5. Buat TEPAT ${recommendedSlides} slide rinci untuk mencakup topik secara komprehensif. +6. Setiap slide HARUS memiliki konten UNIK tanpa pengulangan antar slide. +7. Pastikan alur yang terpadu dan progresi yang logis sepanjang presentasi. +8. Sebarkan konten secara merata di seluruh slide untuk kedalaman dan detail yang konsisten. + +FORMAT RESPON: +Respon Anda HARUS berupa objek JSON valid dengan FIELDS berikut: +{ + "introduction": "An engaging introductory paragraph that provides context and importance of the topic", + "slides": [ + { + "title": "Slide Title", + "content": [ + "Include several detailed points with examples and context" + ], + "notes": "Comprehensive speaker notes with additional details, examples, and teaching tips" + } + ] +} + +PENTING: Respon Anda HARUS berupa JSON valid saja. Jangan sertakan teks, markdown, penjelasan, atau konten di luar objek JSON. Jangan sertakan backticks atau kode blok. + +PERSYARATAN BAHASA: +- Semua nilai string (introduction, slides[].title, slides[].content[], slides[].notes) HARUS dalam Bahasa Indonesia saja. Jangan mencampur bahasa.` + } + + return `You are an expert educational content developer. Continue creating a ${difficultyLevel} level ${contentType} for a session. ${contentTypePrompt} @@ -68,31 +168,34 @@ ${difficultyLevelPrompt} ${specializedPrompt} IMPORTANT INSTRUCTIONS: -1. You MUST base your content ENTIRELY on the source materials provided. -2. Extract key concepts, terminology, examples, and explanations directly from the source materials. -3. Do not introduce concepts or information that is not present in the source materials. -4. Use specific examples, terminology, and explanations from the source materials. +1. You MUST base all content strictly on the provided source materials. +2. Derive key concepts, terminology, examples, and explanations from the sources, but TRANSLATE them into English when the sources are not in English. Do NOT copy non-English text. +3. Do not introduce concepts or information not present in the sources. +4. Use examples, terminology, and explanations specific to the sources. 5. Create EXACTLY ${recommendedSlides} detailed slides to cover the topic comprehensively. -6. Each slide MUST have UNIQUE content with NO repetition between slides. -7. Ensure a cohesive flow and logical progression throughout the presentation. -8. Distribute content evenly across slides to maintain consistent depth and detail. +6. Each slide MUST have UNIQUE content without repetition across slides. +7. Ensure cohesive flow and logical progression throughout the presentation. +8. Distribute content evenly across slides for consistent depth and detail. RESPONSE FORMAT: -Your response MUST be a valid JSON object with EXACTLY these fields: +Your response MUST be a valid JSON object with the following FIELDS: { - "introduction": "Engaging introduction paragraph that provides context and importance of the topic", + "introduction": "An engaging introductory paragraph that provides context and importance of the topic", "slides": [ { "title": "Slide Title", "content": [ - "Include multiple detailed points with examples and context" + "Include several detailed points with examples and context" ], "notes": "Comprehensive speaker notes with additional details, examples, and teaching tips" } ] } -CRITICAL: Your response MUST be valid JSON only. Do not include any text, markdown, explanations, or other content outside the JSON object. Do not include backticks or code block markers.` +CRITICAL: Your response MUST be valid JSON only. Do not include text, markdown, explanations, or any content outside the JSON object. Do not include backticks or code fences. + +LANGUAGE REQUIREMENTS: +- All string values (introduction, slides[].title, slides[].content[], slides[].notes) MUST be in English only. No mixing of languages.` } // Activities system prompt @@ -104,8 +207,81 @@ export function getActivitiesSystemPrompt( difficultyLevelPrompt: string, recommendedActivities = 2, specializedPrompt = '', + language: Lang = 'en', + hasSourceMaterials: boolean = true, ) { - return `You are an expert educational content developer. Continue creating a ${difficultyLevel} level ${contentType} designed for a session. + const idInstructions = hasSourceMaterials + ? `1. Anda HARUS mendasarkan semua konten secara ketat pada materi sumber yang diberikan. +2. Gunakan konsep kunci, terminologi, contoh, dan penjelasan langsung dari sumber. +3. Jangan memperkenalkan konsep atau informasi yang tidak ada di sumber.` + : `1. Dasarkan aktivitas pada topik mata kuliah dan capaian pembelajaran. +2. Gunakan praktik terbaik desain aktivitas yang relevan. +3. Jangan menambahkan informasi yang tidak relevan.` + + const enInstructions = hasSourceMaterials + ? `1. You MUST base all content strictly on the provided source materials. +2. Use key concepts, terminology, examples, and explanations directly from the sources. +3. Do not introduce concepts or information not present in the sources.` + : `1. Base activities on the course topic and learning outcomes. +2. Use relevant activity design best practices. +3. Do not add irrelevant information.` + + if (language === 'id') { + return `Anda adalah pengembang konten pendidikan ahli. Lanjutkan membuat ${contentType} tingkat ${difficultyLevel} untuk sebuah sesi. + +${contentTypePrompt} + +${contentStylePrompt} + +${difficultyLevelPrompt} +${ + contentType === 'tutorial' + ? ` +Untuk tutorial, pastikan aktivitas: +- Membangun keterampilan secara progresif dari dasar hingga lanjutan +- Menyertakan kriteria keberhasilan yang jelas untuk setiap langkah +- Menyediakan kesempatan latihan dengan umpan balik +- Menyertakan panduan pemecahan masalah untuk isu umum +- Diakhiri dengan pertanyaan refleksi untuk memperkuat pembelajaran` + : contentType === 'workshop' + ? ` +Untuk workshop, pastikan aktivitas: +- Mendorong partisipasi aktif dan kolaborasi +- Menyertakan peran yang jelas bagi anggota kelompok +- Memberikan tips fasilitasi untuk instruktur +- Menyertakan pemicu diskusi untuk memperdalam pemahaman +- Diakhiri dengan sesi berbagi atau presentasi` + : '' +} + +${specializedPrompt} + +PETUNJUK PENTING: +${idInstructions} +4. Buat TEPAT ${recommendedActivities} aktivitas yang sesuai untuk durasi sesi. +5. Setiap aktivitas harus unik dan fokus pada aspek berbeda dari konten. +6. Sertakan estimasi waktu yang realistis untuk setiap aktivitas. +7. Pastikan aktivitas saling membangun secara logis. + +FORMAT RESPON: +Respon Anda HARUS berupa objek JSON valid dengan FIELDS berikut: +{ + "activities": [ + { + "title": "Activity Title", + "type": "Discussion/Exercise/Group work", + "description": "Detailed activity description with clear learning objectives", + "duration": "15 minutes", + "instructions": ["Include several steps with clear guidance"], + "materials": ["List all required materials"] + } + ] +} + +PENTING: Respon Anda HARUS berupa JSON valid saja. Jangan sertakan teks, markdown, penjelasan, atau konten di luar objek JSON. Jangan sertakan backticks atau kode blok.` + } + + return `You are an expert educational content developer. Continue creating a ${difficultyLevel} level ${contentType} for a session. ${contentTypePrompt} @@ -115,50 +291,48 @@ ${difficultyLevelPrompt} ${ contentType === 'tutorial' ? ` -For tutorials, ensure activities: +For a tutorial, ensure activities: - Build skills progressively from basic to advanced - Include clear success criteria for each step -- Provide opportunities for practice with feedback +- Provide practice opportunities with feedback - Include troubleshooting guidance for common issues -- End with reflection questions to consolidate learning` +- End with reflection questions to reinforce learning` : contentType === 'workshop' ? ` -For workshops, ensure activities: -- Promote active participation and collaboration +For a workshop, ensure activities: +- Encourage active participation and collaboration - Include clear roles for group members -- Provide facilitation tips for the instructor +- Provide facilitation tips for instructors - Include discussion prompts to deepen understanding -- End with a sharing or presentation component` +- End with a sharing or presentation session` : '' } ${specializedPrompt} IMPORTANT INSTRUCTIONS: -1. You MUST base your content ENTIRELY on the source materials provided. -2. Extract key concepts, terminology, examples, and explanations directly from the source materials. -3. Do not introduce concepts or information that is not present in the source materials. -4. Create EXACTLY ${recommendedActivities} activities that are appropriate for the session length. +${enInstructions} +4. Create EXACTLY ${recommendedActivities} activities appropriate for the session duration. 5. Each activity must be unique and focus on different aspects of the content. 6. Include realistic time estimates for each activity. -7. Ensure activities build on each other in a logical progression. +7. Ensure activities build on each other logically. RESPONSE FORMAT: -Your response MUST be a valid JSON object with EXACTLY these fields: +Your response MUST be a valid JSON object with the following FIELDS: { "activities": [ { "title": "Activity Title", "type": "Discussion/Exercise/Group work", - "description": "Detailed activity description with clear learning purpose", + "description": "Detailed activity description with clear learning objectives", "duration": "15 minutes", - "instructions": ["Include multiple steps with clear guidance"], - "materials": ["Include all necessary materials"] + "instructions": ["Include several steps with clear guidance"], + "materials": ["List all required materials"] } ] } -CRITICAL: Your response MUST be valid JSON only. Do not include any text, markdown, explanations, or other content outside the JSON object. Do not include backticks or code block markers.` +CRITICAL: Your response MUST be valid JSON only. Do not include text, markdown, explanations, or any content outside the JSON object. Do not include backticks or code fences.` } // Assessment system prompt @@ -168,53 +342,124 @@ export function getAssessmentSystemPrompt( topicName: string, sessionLength: number, specializedPrompt = '', + language: Lang = 'en', + hasSourceMaterials: boolean = true, ) { - return `You are an expert educational content developer. Generate assessment ideas for a ${difficultyLevel} level ${contentType} on "${topicName}" designed for a ${sessionLength}-minute session. + const idInstructions = hasSourceMaterials + ? `1. Anda HARUS mendasarkan semua konten secara ketat pada materi sumber yang diberikan. +2. Gunakan konsep kunci, terminologi, contoh, dan penjelasan langsung dari sumber. +3. Jangan memperkenalkan konsep atau informasi yang tidak ada di sumber.` + : `1. Dasarkan ide penilaian pada topik mata kuliah dan capaian pembelajaran. +2. Gunakan praktik terbaik asesmen yang relevan. +3. Jangan menambahkan informasi yang tidak relevan.` + + const enInstructions = hasSourceMaterials + ? `1. You MUST base all content strictly on the provided source materials. +2. Use key concepts, terminology, examples, and explanations directly from the sources. +3. Do not introduce concepts or information not present in the sources.` + : `1. Base assessment ideas on the course topic and learning outcomes. +2. Use relevant assessment best practices. +3. Do not add irrelevant information.` + + if (language === 'id') { + return `Anda adalah pengembang konten pendidikan ahli. Buat ide penilaian untuk ${contentType} tingkat ${difficultyLevel} tentang "${topicName}" untuk sesi berdurasi ${sessionLength} menit. + +${specializedPrompt} + +PETUNJUK PENTING: +${idInstructions} +4. Buat ide penilaian beserta contoh pertanyaan. +5. Anda HARUS menyertakan tipe Kuis dan Diskusi. +6. Untuk pertanyaan Kuis, sertakan opsi, jawaban yang benar, dan penjelasan. +7. Untuk pertanyaan Diskusi, sertakan jawaban model yang rinci dan kriteria penilaian dengan alokasi poin. + +FORMAT RESPON: +Respon Anda HARUS berupa objek JSON valid dengan FIELDS berikut: +{ + "assessmentIdeas": [ + { + "type": "Quiz", + "duration": "Time required to complete", + "description": "Detailed assessment description", + "exampleQuestions": [ + { + "question": "Full question text?", + "options": ["Option A", "Option B", "Option C", "Option D"], + "correctAnswer": "The correct option text", + "explanation": "Explanation for why this answer is correct" + } + ] + }, + { + "type": "Discussion", + "duration": "Time required to complete", + "description": "Detailed assessment description", + "exampleQuestions": [ + { + "question": "Discussion question", + "correctAnswer": "Detailed guidance on points to be discussed", + "explanation": { + "criteria": [ + {"name": "Quality of contribution", "weight": 30}, + {"name": "Conceptual understanding", "weight": 25}, + {"name": "Critical thinking", "weight": 25}, + {"name": "Peer interaction", "weight": 20} + ], + "pointAllocation": "Detailed point allocation for different discussion aspects" + } + } + ] + } + ] +} + +PENTING: Respon Anda HARUS berupa JSON valid saja. Jangan sertakan teks, markdown, penjelasan, atau konten di luar objek JSON. Jangan sertakan backticks atau kode blok.` + } + + return `You are an expert educational content developer. Create assessment ideas for a ${difficultyLevel} level ${contentType} on "${topicName}" for a ${sessionLength}-minute session. ${specializedPrompt} IMPORTANT INSTRUCTIONS: -1. You MUST base your content ENTIRELY on the source materials provided. -2. Extract key concepts, terminology, examples, and explanations directly from the source materials. -3. Do not introduce concepts or information that is not present in the source materials. +${enInstructions} 4. Create assessment ideas WITH example questions. -5. You MUST create BOTH Quiz AND Discussion assessment types. -6. For Quiz questions, include options, correct answer, and explanation. -7. For Discussion questions, include detailed model answers and evaluation criteria with mark allocation. +5. You MUST include both Quiz and Discussion types. +6. For Quiz questions, include options, the correct answer, and an explanation. +7. For Discussion questions, include a detailed model answer and marking criteria with point allocation. RESPONSE FORMAT: -Your response MUST be a valid JSON object with EXACTLY these fields: +Your response MUST be a valid JSON object with the following FIELDS: { "assessmentIdeas": [ { "type": "Quiz", "duration": "Time required to complete", - "description": "Detailed description of the assessment", + "description": "Detailed assessment description", "exampleQuestions": [ { - "question": "The full text of the question?", + "question": "Full question text?", "options": ["Option A", "Option B", "Option C", "Option D"], - "correctAnswer": "The exact text of the correct option", - "explanation": "Explanation of why this answer is correct" + "correctAnswer": "The correct option text", + "explanation": "Explanation for why this answer is correct" } ] }, { "type": "Discussion", "duration": "Time required to complete", - "description": "Detailed description of the assessment", + "description": "Detailed assessment description", "exampleQuestions": [ { - "question": "The discussion question", - "correctAnswer": "Detailed guidance on what points the discussion should cover", + "question": "Discussion question", + "correctAnswer": "Detailed guidance on points to be discussed", "explanation": { "criteria": [ {"name": "Quality of contribution", "weight": 30}, - {"name": "Understanding of concepts", "weight": 25}, + {"name": "Conceptual understanding", "weight": 25}, {"name": "Critical thinking", "weight": 25}, - {"name": "Engagement with peers", "weight": 20} + {"name": "Peer interaction", "weight": 20} ], - "pointAllocation": "Detailed breakdown of how points are distributed" + "pointAllocation": "Detailed point allocation for different discussion aspects" } } ] @@ -222,7 +467,7 @@ Your response MUST be a valid JSON object with EXACTLY these fields: ] } -CRITICAL: Your response MUST be valid JSON only. Do not include any text, markdown, explanations, or other content outside the JSON object. Do not include any backticks or code block markers.` +CRITICAL: Your response MUST be valid JSON only. Do not include text, markdown, explanations, or any content outside the JSON object. Do not include backticks or code fences.` } // Readings system prompt @@ -232,72 +477,429 @@ export function getReadingsSystemPrompt( topicName: string, sessionLength: number, specializedPrompt = '', + language: Lang = 'en', + hasSourceMaterials: boolean = true, ) { - return `You are an expert educational content developer. Generate further reading suggestions for a ${difficultyLevel} level ${contentType} on "${topicName}" designed for a ${sessionLength}-minute session. + const idInstructions = hasSourceMaterials + ? `1. Anda HARUS mendasarkan semua konten secara ketat pada materi sumber yang diberikan. +2. Gunakan konsep kunci, terminologi, contoh, dan penjelasan langsung dari sumber. +3. Jangan memperkenalkan konsep atau informasi yang tidak ada di sumber.` + : `1. Dasarkan rekomendasi bacaan pada topik mata kuliah. +2. Pilih bacaan yang relevan dan berkualitas. +3. Jangan menambahkan informasi yang tidak relevan.` + + const enInstructions = hasSourceMaterials + ? `1. You MUST base all content strictly on the provided source materials. +2. Use key concepts, terminology, examples, and explanations directly from the sources. +3. Do not introduce concepts or information not present in the sources.` + : `1. Base reading recommendations on the course topic. +2. Choose relevant, high-quality readings. +3. Do not add irrelevant information.` + + if (language === 'id') { + return `Anda adalah pengembang konten pendidikan ahli. Buat rekomendasi bacaan lanjutan untuk ${contentType} tingkat ${difficultyLevel} tentang "${topicName}" untuk sesi berdurasi ${sessionLength} menit. + +${specializedPrompt} + +PETUNJUK PENTING: +${idInstructions} +4. Jaga struktur tetap sederhana dan fokus hanya pada bacaan lanjutan. + +FORMAT RESPON: +Respon Anda HARUS berupa objek JSON valid dengan FIELDS berikut: +{ + "furtherReadings": [ + { + "title": "Reading title", + "author": "Author name", + "readingDescription": "Short description of the reading and its relevance" + } + ] +} + +PENTING: Respon Anda HARUS berupa JSON valid saja. Jangan sertakan teks, markdown, penjelasan, atau konten di luar objek JSON. Jangan sertakan backticks atau kode blok.` + } + + return `You are an expert educational content developer. Create further reading suggestions for a ${difficultyLevel} level ${contentType} on "${topicName}" for a ${sessionLength}-minute session. ${specializedPrompt} IMPORTANT INSTRUCTIONS: -1. You MUST base your content ENTIRELY on the source materials provided. -2. Extract key concepts, terminology, examples, and explanations directly from the source materials. -3. Do not introduce concepts or information that is not present in the source materials. -4. Keep the structure simple and focused only on further readings. +${enInstructions} +4. Keep the structure simple and focus only on further readings. RESPONSE FORMAT: -Your response MUST be a valid JSON object with EXACTLY these fields: +Your response MUST be a valid JSON object with the following FIELDS: { "furtherReadings": [ { - "title": "Title of the reading", - "author": "Author name(s)", - "readingDescription": "Brief description of the reading and its relevance" + "title": "Reading title", + "author": "Author name", + "readingDescription": "Short description of the reading and its relevance" } ] } -CRITICAL: Your response MUST be valid JSON only. Do not include any text, markdown, explanations, or other content outside the JSON object. Do not include any backticks or code block markers.` +CRITICAL: Your response MUST be valid JSON only. Do not include text, markdown, explanations, or any content outside the JSON object. Do not include backticks or code fences.` } // Quiz question generation prompt -export function getQuizQuestionPrompt(topicName: string, description: string) { - return `Create 3 multiple-choice quiz questions about "${topicName}" related to: "${description}". +export function getQuizQuestionPrompt( + topicName: string, + description: string, + language: Lang = 'en', +) { + if (language === 'id') { + return `Buat 3 pertanyaan pilihan ganda tentang "${topicName}" terkait: "${description}". -IMPORTANT: Your response must be a valid JSON array of quiz question objects with this exact structure: +PENTING: Respon Anda harus berupa array JSON valid dari objek pertanyaan kuis dengan struktur berikut: [ { - "question": "The full text of the question?", + "question": "Full question text?", "options": ["Option A", "Option B", "Option C", "Option D"], "correctAnswer": "The exact text of the correct option", - "explanation": "Explanation of why this answer is correct" + "explanation": "Explanation for why this answer is correct" } ] -Each question must have exactly 4 options. The correctAnswer must match one of the options exactly. +Setiap pertanyaan harus memiliki tepat 4 opsi. Nilai correctAnswer harus persis cocok dengan salah satu opsi. +Jangan sertakan teks, markdown, atau penjelasan di luar array JSON.` + } + + return `Generate 3 multiple-choice questions about "${topicName}" related to: "${description}". + +IMPORTANT: Your response must be a valid JSON array of quiz question objects with the following structure: +[ + { + "question": "Full question text?", + "options": ["Option A", "Option B", "Option C", "Option D"], + "correctAnswer": "The exact text of the correct option", + "explanation": "Explanation for why this answer is correct" + } +] + +Each question must have exactly 4 options. The correctAnswer value must match exactly one of the options. Do not include any text, markdown, or explanations outside the JSON array.` } // Discussion question generation prompt -export function getDiscussionQuestionPrompt(topicName: string, description: string) { - return `Create 2 discussion prompts about "${topicName}" related to: "${description}". +export function getDiscussionQuestionPrompt( + topicName: string, + description: string, + language: Lang = 'en', +) { + if (language === 'id') { + return `Buat 2 pertanyaan diskusi tentang "${topicName}" terkait: "${description}". + +PENTING: Respon Anda harus berupa array JSON valid dari objek pertanyaan diskusi dengan struktur berikut: +[ + { + "question": "Discussion question", + "correctAnswer": "Detailed guidance on points to be discussed, including key concepts, examples, and possible arguments", + "explanation": { + "criteria": [ + {"name": "Quality of contribution", "weight": 30}, + {"name": "Conceptual understanding", "weight": 25}, + {"name": "Critical thinking", "weight": 25}, + {"name": "Peer interaction", "weight": 20} + ], + "pointAllocation": "Detailed point allocation for different discussion aspects" + } + } +] -IMPORTANT: Your response must be a valid JSON array of discussion prompt objects with this exact structure: +Setiap pertanyaan diskusi harus menyertakan kriteria penilaian terperinci dengan alokasi poin spesifik. +Nilai correctAnswer harus memberikan panduan komprehensif tentang poin diskusi yang diharapkan. +Jangan sertakan teks, markdown, atau penjelasan di luar array JSON.` + } + + return `Generate 2 discussion questions about "${topicName}" related to: "${description}". + +IMPORTANT: Your response must be a valid JSON array of discussion question objects with the following structure: [ { - "question": "The discussion question", - "correctAnswer": "Detailed guidance on what points the discussion should cover, including key concepts, examples, and potential arguments", + "question": "Discussion question", + "correctAnswer": "Detailed guidance on points to be discussed, including key concepts, examples, and possible arguments", "explanation": { "criteria": [ {"name": "Quality of contribution", "weight": 30}, - {"name": "Understanding of concepts", "weight": 25}, + {"name": "Conceptual understanding", "weight": 25}, {"name": "Critical thinking", "weight": 25}, - {"name": "Engagement with peers", "weight": 20} + {"name": "Peer interaction", "weight": 20} ], - "pointAllocation": "Detailed breakdown of how points are distributed across different aspects of the discussion" + "pointAllocation": "Detailed point allocation for different discussion aspects" } } ] -Each discussion prompt must have detailed evaluation criteria with specific point allocations. -The correctAnswer must provide comprehensive guidance on expected discussion points. +Each discussion question must include detailed marking criteria with specific point allocations. +The correctAnswer must provide comprehensive guidance on the expected discussion points. Do not include any text, markdown, or explanations outside the JSON array.` } + +// New: Intro, Special Slides, and Content Slides system prompts (bilingual) +export function getIntroSystemPrompt( + difficultyLevel: string, + contentType: string, + topicName: string, + sessionLength: number, + language: Lang = 'en', + hasSourceMaterials: boolean = true, +) { + const idInstructions = hasSourceMaterials + ? `1. Anda HARUS mendasarkan semua konten SECARA KETAT pada materi sumber yang diberikan. +2. Gunakan konsep kunci, terminologi, contoh, dan penjelasan yang DIAMBIL dari sumber, namun TERJEMAHKAN ke Bahasa Indonesia bila sumber bukan Bahasa Indonesia. Jangan menyalin teks non-Bahasa Indonesia. +3. Jangan memperkenalkan konsep atau informasi yang tidak ada di sumber.` + : `1. Dasarkan pengantar pada topik mata kuliah dan konteks pembelajaran. +2. Fokus pada konsep inti dan relevansi topik dengan praktik nyata. +3. Jangan menambahkan informasi yang tidak relevan.` + + const enInstructions = hasSourceMaterials + ? `1. You MUST base all content STRICTLY on the provided source materials. +2. Derive key concepts, terminology, examples, and explanations from the sources, but TRANSLATE them into English when the sources are not in English. Do NOT copy non-English text. +3. Do not introduce concepts or information not present in the sources.` + : `1. Base the introduction on the course topic and learning context. +2. Focus on core concepts and the topic's real-world relevance. +3. Do not add irrelevant information.` + + if (language === 'id') { + return `Anda adalah pengembang konten pendidikan ahli. Lanjutkan membuat ${contentType} tingkat ${difficultyLevel} tentang "${topicName}" untuk sesi berdurasi ${sessionLength} menit. + +PETUNJUK PENTING: +${idInstructions} +4. Buat pengantar yang menarik yang memberikan konteks dan pentingnya topik. + +FORMAT RESPON: +Respon Anda HARUS berupa objek JSON valid dengan TEPAT field berikut: +{ +"introduction": "An engaging paragraph that provides context and importance of the topic" +} + +PENTING: Respon Anda HARUS berupa JSON valid saja. Jangan sertakan teks, markdown, penjelasan, atau konten di luar objek JSON. Jangan sertakan backticks atau kode blok. + +PERSYARATAN BAHASA: +- Nilai string "introduction" HARUS dalam Bahasa Indonesia.` + } + + return `You are an expert educational content developer. Continue creating a ${difficultyLevel} level ${contentType} on "${topicName}" for a ${sessionLength}-minute session. + +IMPORTANT INSTRUCTIONS: +${enInstructions} +4. Create an engaging introduction that provides context and the importance of the topic. + +RESPONSE FORMAT: +Your response MUST be a valid JSON object with EXACTLY the following field: +{ +"introduction": "An engaging paragraph that provides context and importance of the topic" +} + +CRITICAL: Your response MUST be valid JSON only. Do not include text, markdown, explanations, or any content outside the JSON object. Do not include backticks or code fences. + +LANGUAGE REQUIREMENTS: +- The "introduction" string MUST be in English only.` +} + +export function getSpecialSlidesSystemPrompt( + difficultyLevel: string, + contentType: string, + topicName: string, + sessionLength: number, + language: Lang = 'en', + hasSourceMaterials: boolean = true, +) { + const idInstructions = hasSourceMaterials + ? `1. Anda HARUS mendasarkan semua konten SECARA KETAT pada materi sumber yang diberikan. +2. Gunakan konsep kunci, terminologi, contoh, dan penjelasan yang DIAMBIL dari sumber, namun TERJEMAHKAN ke Bahasa Indonesia bila sumber bukan Bahasa Indonesia. Jangan menyalin teks non-Bahasa Indonesia. +3. Jangan memperkenalkan konsep atau informasi yang tidak ada di sumber.` + : `1. Dasarkan slide khusus pada konteks mata kuliah dan tujuan pembelajaran. +2. Gunakan struktur yang jelas dan relevan. +3. Jangan menambahkan informasi yang tidak relevan.` + + const enInstructions = hasSourceMaterials + ? `1. You MUST base all content STRICTLY on the provided source materials. +2. Derive key concepts, terminology, examples, and explanations from the sources, but TRANSLATE them into English when the sources are not in English. Do NOT copy non-English text. +3. Do not introduce concepts or information not present in the sources.` + : `1. Base special slides on course context and learning objectives. +2. Use a clear and relevant structure. +3. Do not add irrelevant information.` + + if (language === 'id') { + return `Anda adalah pengembang konten pendidikan ahli. Lanjutkan membuat ${contentType} tingkat ${difficultyLevel} tentang "${topicName}" untuk sesi berdurasi ${sessionLength} menit. + +PETUNJUK PENTING: +${idInstructions} +4. Buat HANYA slide khusus berikut: + - Slide pengantar (slide pertama yang memperkenalkan topik) + - Slide agenda/ikhtisar (menguraikan apa yang akan dibahas) + - Slide penilaian (merangkum pendekatan penilaian) + - Slide kesimpulan/rangkuman (merangkum presentasi) + +FORMAT RESPON: +Respon Anda HARUS berupa objek JSON valid dengan TEPAT field berikut: +{ +"specialSlides": [ + { + "type": "introduction", + "title": "Introduction to [Topic]", + "content": ["Point 1", "Point 2", "Point 3"], + "notes": "Speaker notes for this slide" + }, + { + "type": "agenda", + "title": "Agenda/Overview", + "content": ["Topic 1", "Topic 2", "Topic 3"], + "notes": "Speaker notes for this slide" + }, + { + "type": "assessment", + "title": "Assessment Approach", + "content": ["Assessment method 1", "Assessment method 2"], + "notes": "Speaker notes for this slide" + }, + { + "type": "conclusion", + "title": "Summary and Closing", + "content": ["Key takeaway 1", "Key takeaway 2", "Next steps"], + "notes": "Speaker notes for this slide" + } +] +} + +PENTING: Respon Anda HARUS berupa JSON valid saja. Jangan sertakan teks, markdown, penjelasan, atau konten di luar objek JSON. Jangan sertakan backticks atau kode blok. + +PERSYARATAN BAHASA: +- Semua nilai string dalam specialSlides HARUS dalam Bahasa Indonesia.` + } + + return `You are an expert educational content developer. Continue creating a ${difficultyLevel} level ${contentType} on "${topicName}" for a ${sessionLength}-minute session. + +IMPORTANT INSTRUCTIONS: +${enInstructions} +4. Create ONLY the following special slides: + - Introduction slide (the first slide that introduces the topic) + - Agenda/Overview slide (outlining what will be covered) + - Assessment slide (summarizing the assessment approach) + - Conclusion/Summary slide (summarizing the presentation) + +RESPONSE FORMAT: +Your response MUST be a valid JSON object with EXACTLY the following field: +{ +"specialSlides": [ + { + "type": "introduction", + "title": "Introduction to [Topic]", + "content": ["Point 1", "Point 2", "Point 3"], + "notes": "Speaker notes for this slide" + }, + { + "type": "agenda", + "title": "Agenda/Overview", + "content": ["Topic 1", "Topic 2", "Topic 3"], + "notes": "Speaker notes for this slide" + }, + { + "type": "assessment", + "title": "Assessment Approach", + "content": ["Assessment method 1", "Assessment method 2"], + "notes": "Speaker notes for this slide" + }, + { + "type": "conclusion", + "title": "Summary and Closing", + "content": ["Key takeaway 1", "Key takeaway 2", "Next steps"], + "notes": "Speaker notes for this slide" + } +] +} + +CRITICAL: Your response MUST be valid JSON only. Do not include text, markdown, explanations, or any content outside the JSON object. Do not include backticks or code fences. + +LANGUAGE REQUIREMENTS: +- All string values in specialSlides MUST be in English only.` +} + +export function getContentSlidesSystemPrompt( + startSlideNum: number, + endSlideNum: number, + totalContentSlidesNeeded: number, + language: Lang = 'en', + hasSourceMaterials: boolean = true, +) { + const idInstructions = hasSourceMaterials + ? `1. Anda HARUS mendasarkan semua konten SECARA KETAT pada materi sumber yang diberikan. +2. Gunakan konsep kunci, terminologi, contoh, dan penjelasan yang DIAMBIL dari sumber, namun TERJEMAHKAN ke Bahasa Indonesia bila sumber bukan Bahasa Indonesia. Jangan menyalin teks non-Bahasa Indonesia. +3. Jangan memperkenalkan konsep atau informasi yang tidak ada di sumber.` + : `1. Dasarkan slide konten pada topik mata kuliah dan struktur pengajaran yang jelas. +2. Gunakan contoh, terminologi, dan penjelasan yang relevan. +3. Jangan menambahkan informasi yang tidak relevan.` + + const enInstructions = hasSourceMaterials + ? `1. You MUST base all content STRICTLY on the provided source materials. +2. Derive key concepts, terminology, examples, and explanations from the sources, but TRANSLATE them into English when the sources are not in English. Do NOT copy non-English text. +3. Do not introduce concepts or information not present in the sources.` + : `1. Base content slides on the course topic and a clear instructional structure. +2. Use relevant examples, terminology, and explanations. +3. Do not add irrelevant information.` + + if (language === 'id') { + return `Anda sedang membuat slide konten ${startSlideNum} hingga ${endSlideNum} dari total ${totalContentSlidesNeeded} slide konten. Pastikan semua slide unik. + +PETUNJUK PENTING: +${idInstructions} +4. Buat slide pengajaran yang rinci dengan konten substansial pada setiap slide. +5. Fokus HANYA pada slide konten instruksional inti. +6. Setiap slide harus menyertakan catatan pembicara yang komprehensif dengan detail dan contoh tambahan. +7. Anda membuat slide konten ${startSlideNum} hingga ${endSlideNum} dari ${totalContentSlidesNeeded}. +8. JANGAN membuat slide pengantar, agenda, penilaian, atau kesimpulan — itu ditangani secara terpisah. + +FORMAT RESPON: +Respon Anda HARUS berupa objek JSON valid dengan TEPAT field berikut: +{ +"contentSlides": [ + { + "title": "Slide Title", + "content": [ + "Include several detailed points with examples and context", + "Each array item represents a point or paragraph on the slide" + ], + "notes": "Comprehensive speaker notes with additional details, examples, and teaching tips" + } +] +} + +PENTING: Respon Anda HARUS berupa JSON valid saja. Jangan sertakan teks, markdown, penjelasan, atau konten di luar objek JSON. Jangan sertakan backticks atau kode blok. + +PERSYARATAN BAHASA: +- Semua nilai string dalam contentSlides HARUS dalam Bahasa Indonesia.` + } + + return `You are creating content slides ${startSlideNum} to ${endSlideNum} out of a total of ${totalContentSlidesNeeded} content slides. Ensure all slides are unique. + +IMPORTANT INSTRUCTIONS: +${enInstructions} +4. Create detailed teaching slides with substantial content on each slide. +5. Focus ONLY on core instructional content slides. +6. Each slide must include comprehensive speaker notes with additional details and examples. +7. You are creating content slides ${startSlideNum} to ${endSlideNum} out of ${totalContentSlidesNeeded}. +8. DO NOT create introduction, agenda, assessment, or conclusion slides — those are handled separately. + +RESPONSE FORMAT: +Your response MUST be a valid JSON object with EXACTLY the following field: +{ +"contentSlides": [ + { + "title": "Slide Title", + "content": [ + "Include several detailed points with examples and context", + "Each array item represents a point or paragraph on the slide" + ], + "notes": "Comprehensive speaker notes with additional details, examples, and teaching tips" + } +] +} + +CRITICAL: Your response MUST be valid JSON only. Do not include text, markdown, explanations, or any content outside the JSON object. Do not include backticks or code fences. + +LANGUAGE REQUIREMENTS: +- All string values in contentSlides MUST be in English only.` +} diff --git a/frontend/src/app/api/slide/route.ts b/frontend/src/app/api/slide/route.ts index 0ae893c..2c9c6ab 100644 --- a/frontend/src/app/api/slide/route.ts +++ b/frontend/src/app/api/slide/route.ts @@ -4,9 +4,17 @@ import { NextResponse } from 'next/server' import type { CourseContentRequest } from './types' import { generateCourseContent } from './content-generator' +import { normalizeLanguage } from '@/lib/utils/lang' export const dynamic = 'force-dynamic' +// Timeout for backend requests (ms), configurable via FASTAPI_TIMEOUT env var (default: 15000ms) +const REQUEST_TIMEOUT_MS = (() => { + const val = process.env.FASTAPI_TIMEOUT + const parsed = val ? parseInt(val, 10) : NaN + return Number.isFinite(parsed) && parsed > 0 ? parsed : 15000 +})() + export async function POST(req: Request) { try { const requestData: CourseContentRequest = await req.json() @@ -23,16 +31,52 @@ export async function POST(req: Request) { } try { - // Call the backend's generate-pptx endpoint - const backendGeneratePptxUrl = new URL('/generate-pptx', process.env.FASTAPI_SERVER_URL) - .href - const response = await fetch(backendGeneratePptxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ content: requestData.content }), - }) + // Resolve backend URL with safe fallback and explicit timeout + const baseBackendUrl = + process.env.FASTAPI_SERVER_URL || + process.env.BACKEND_SERVER_URL || + 'http://127.0.0.1:8016' + + let backendGeneratePptxUrl: string + try { + backendGeneratePptxUrl = new URL('/generate-pptx', baseBackendUrl).href + } catch (e) { + console.error('Invalid FASTAPI server URL:', baseBackendUrl, e) + throw new Error( + `Invalid FASTAPI_SERVER_URL configuration: ${baseBackendUrl}. Please set FASTAPI_SERVER_URL (e.g., http://127.0.0.1:8016).`, + ) + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + let response: Response + try { + response = await fetch(backendGeneratePptxUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ content: requestData.content, language: requestData.language }), + signal: controller.signal, + }) + } catch (err: unknown) { + const isAbortError = + typeof err === 'object' && + err !== null && + 'name' in err && + (err as { name?: string }).name === 'AbortError' + if (isAbortError) { + throw new Error( + `Timed out connecting to backend at ${baseBackendUrl}. Ensure the FastAPI server is running.`, + ) + } + // Node fetch ECONNREFUSED or other network errors + throw new Error( + `Failed to connect to backend at ${baseBackendUrl}. Ensure the FastAPI server is running and accessible. (Original error: ${(err as Error).message})`, + ) + } finally { + clearTimeout(timeout) + } if (!response.ok) { const errorText = await response.text() @@ -47,7 +91,6 @@ export async function POST(req: Request) { throw new Error(errorMessage) } - // Get the response as a blob const blob = await response.blob() @@ -91,7 +134,7 @@ export async function POST(req: Request) { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ content: requestData.content }), + body: JSON.stringify({ content: requestData.content, language: requestData.language }), }) if (!response.ok) { @@ -139,6 +182,8 @@ export async function POST(req: Request) { sessionLength, difficultyLevel, topicName, + language, + courseInfo, } = requestData console.log('Data from request:', { @@ -149,8 +194,25 @@ export async function POST(req: Request) { sessionLength, difficultyLevel, topicName, + language, }) + // Basic validation: ensure either sources or course info is provided + const hasValidSources = + Array.isArray(selectedSources) && + selectedSources.length > 0 && + selectedSources.every( + (source) => source && typeof source === 'object' && 'id' in source && 'name' in source, + ) + + if (!hasValidSources && !courseInfo?.courseName && !courseInfo?.courseDescription) { + console.warn('DEBUG: No sources or course information provided in POST /api/slide') + return NextResponse.json( + { error: 'Either sources or course information must be provided.' }, + { status: 400 }, + ) + } + // Generate course content const generatedContent = await generateCourseContent( selectedModel, @@ -160,6 +222,8 @@ export async function POST(req: Request) { sessionLength, difficultyLevel, topicName, + normalizeLanguage(language), + courseInfo, ) return NextResponse.json(generatedContent) diff --git a/frontend/src/app/api/slide/types.ts b/frontend/src/app/api/slide/types.ts index 021f3a5..5f4d16c 100644 --- a/frontend/src/app/api/slide/types.ts +++ b/frontend/src/app/api/slide/types.ts @@ -4,6 +4,7 @@ // Type definitions for course content generation import { ClientSource } from '@/lib/types/client-source' +import type { CourseInfo } from '@/lib/types/course-info-types' export interface LectureSlide { title: string @@ -120,6 +121,8 @@ export interface CourseContentRequest { sessionLength: number difficultyLevel: string topicName: string + language?: 'en' | 'id' + courseInfo?: CourseInfo action?: string content?: LectureContent } diff --git a/frontend/src/app/api/slide/utils.ts b/frontend/src/app/api/slide/utils.ts index 34d5b69..99202e4 100644 --- a/frontend/src/app/api/slide/utils.ts +++ b/frontend/src/app/api/slide/utils.ts @@ -3,9 +3,11 @@ import type { ContextChunk } from '@/lib/types/context-chunk' import type { ClientSource } from '@/lib/types/client-source' +import type { CourseInfo } from '@/lib/types/course-info-types' import { getStoredChunks } from '@/lib/chunk/get-stored-chunks' import type { AssessmentQuestion } from './types' import { fallbackDiscussionIdeas } from './fallback-content' +import { jsonrepair } from 'jsonrepair' // Configuration constants export const TEMPERATURE = Number.parseFloat(process.env.RAG_TEMPERATURE || '0.1') @@ -43,6 +45,16 @@ export function truncateToTokenLimit(text: string, maxTokens: number): string { return result.trim() + '...' } +// Safe wrapper around jsonrepair to guard against exceptions and return original text on failure +export function safeJsonRepair(text: string): string { + try { + return jsonrepair(text) + } catch (err) { + console.warn('jsonrepair failed, using original text:', err) + return text + } +} + // Utility function to safely parse JSON with fallbacks export function safeJsonParse(jsonString: string, fallback: unknown = {}) { try { @@ -53,31 +65,136 @@ export function safeJsonParse(jsonString: string, fallback: unknown = {}) { } } +// Lightweight cleanup that fixes common JSON issues without external libraries +export function basicJsonCleanup(json: string): string { + try { + let s = json + // Remove non-printable characters + s = s.replace(/[\x00-\x1F\x7F-\x9F]/g, '') + // Remove trailing commas in objects/arrays + s = s.replace(/,(\s*[}\]])/g, '$1') + // Quote unquoted object keys + s = s.replace(/([{,]\s*)([a-zA-Z0-9_]+)(\s*:)/g, '$1"$2"$3') + // Quote simple bareword values (conservative) + s = s.replace(/:\s*([^"{}\[\],\s][^{}\[\],]*?)(\s*[,}])/g, ':"$1"$2') + return s + } catch (e) { + console.warn('basicJsonCleanup failed; returning original string:', e) + return json + } +} + +/** + * Extracts JSON content from code-fenced blocks (e.g., ```json ... ```) within the input text. + * Employs multiple fallback strategies to maximize the chance of retrieving valid JSON: + * 1. Returns the first code-fenced block that parses as JSON. + * 2. Attempts to repair and parse each block using `jsonrepair` if direct parsing fails. + * 3. If multiple blocks are present, tries to combine all parseable blocks into a JSON array. + * 4. If all else fails, returns the first code-fenced block or the trimmed original text. + * + * @param {string} text - The input string potentially containing code-fenced JSON blocks. + * @returns {string} The extracted or repaired JSON string, or the original text if extraction fails. + */ + +// Utility function to extract JSON from code-fenced blocks in text. +function stripCodeFences(text: string): string { + // Remove ```json ... ``` or ``` ... ``` fences if present. + // If multiple code fences are present, prefer returning the first valid JSON block. + // If none are individually valid, attempt to return a JSON array of all parseable blocks. + const fenceRegex = /```(?:json)?\s*([\s\S]*?)\s*```/gi + const contents: string[] = [] + let match: RegExpExecArray | null + while ((match = fenceRegex.exec(text)) !== null) { + contents.push(match[1].trim()) + } + if (contents.length > 0) { + // 1) Try to return the first block that parses as JSON + for (const block of contents) { + try { + JSON.parse(block) + return block + } catch { + // Try lightweight cleanup before falling back to jsonrepair + try { + const cleaned = basicJsonCleanup(block) + JSON.parse(cleaned) + return cleaned + } catch { + // continue + } + try { + const repaired = safeJsonRepair(block) + JSON.parse(repaired) + return repaired + } catch { + // continue checking next block + } + } + } + + // 2) Try to combine all parseable blocks into a JSON array + const parsedItems: unknown[] = [] + let allParsed = true + for (const block of contents) { + try { + parsedItems.push(JSON.parse(block)) + } catch { + try { + parsedItems.push(JSON.parse(basicJsonCleanup(block))) + } catch { + try { + parsedItems.push(JSON.parse(safeJsonRepair(block))) + } catch { + allParsed = false + break + } + } + } + } + if (allParsed) { + return JSON.stringify(parsedItems) + } + + // 3) Fallback: return the first block (better than concatenation which may be invalid JSON) + return contents[0] + } + return text.trim() +} + // More robust JSON extraction function export function extractAndParseJSON(text: string) { + const input = stripCodeFences(text) try { - // First try to parse the entire text as JSON - return JSON.parse(text) + return JSON.parse(input) } catch { - console.log('Failed to parse entire response as JSON, attempting to extract JSON...') + // Try jsonrepair on the whole response first + try { + return JSON.parse(safeJsonRepair(input)) + } catch { + console.log('Failed to parse entire response as JSON, attempting to extract JSON...') + } // Try to extract JSON object or array from the text using regex const jsonRegex = /(\{[\s\S]*\}|\[[\s\S]*\])/ - const match = text.match(jsonRegex) + const match = input.match(jsonRegex) if (match && match[0]) { + const candidate = stripCodeFences(match[0]) + // Try direct parse try { - return JSON.parse(match[0]) + return JSON.parse(candidate) } catch { - console.log('Failed to parse extracted JSON, attempting cleanup...') + console.log('Failed to parse extracted JSON, attempting cleanup/repair...') + } - // Try to clean up common issues and parse again - const cleanedJSON = match[0] - // Fix trailing commas + // Try jsonrepair on the extracted candidate + try { + return JSON.parse(safeJsonRepair(candidate)) + } catch { + // Fallback minimal cleanup + const cleanedJSON = candidate .replace(/,(\s*[}\]])/g, '$1') - // Fix missing quotes around property names .replace(/([{,]\s*)([a-zA-Z0-9_]+)(\s*:)/g, '$1"$2"$3') - // Fix unquoted string values .replace(/:\s*([^"{}[\],\s][^{}[\],]*?)(\s*[,}])/g, ':"$1"$2') try { @@ -95,14 +212,66 @@ export function extractAndParseJSON(text: string) { } // Prepare source content for the AI model -export async function prepareSourceContent(selectedSources: ClientSource[]) { +export async function prepareSourceContent( + selectedSources: ClientSource[], + topicName?: string, + courseInfo?: CourseInfo, +) { try { + // Check if we have any selected sources + const selectedSourcesFiltered = selectedSources?.filter((source) => source.selected) || [] + // If no sources are selected, create course-based content + if (selectedSourcesFiltered.length === 0) { + console.log('No sources selected, using course context for content generation') + const courseContent = `COURSE CONTEXT:\n\n` + let structuredContent = courseContent + if (courseInfo) { + structuredContent += `Course: ${courseInfo.courseCode || ''} ${courseInfo.courseName || 'Academic Course'}\n` + structuredContent += `Semester: ${courseInfo.semester || 'Current Semester'}\n` + structuredContent += `Academic Year: ${courseInfo.academicYear || 'Current Academic Year'}\n\n` + } + structuredContent += `Topic: ${topicName || 'Course Topic'}\n\n` + structuredContent += `GENERAL KNOWLEDGE CONTEXT:\n` + structuredContent += `Since no specific source materials were provided, this content should be generated based on:\n` + structuredContent += `1. Standard academic knowledge for the topic "${topicName}"\n` + structuredContent += `2. Common educational practices and pedagogical approaches\n` + structuredContent += `3. Typical curriculum content for this subject area\n` + structuredContent += `4. Best practices in educational content development\n\n` + const sourceMetadata = { + sourceCount: 0, + chunkCount: 0, + tokenEstimate: countTokens(structuredContent), + sourceNames: [], + usingCourseContext: true, + } + return { content: structuredContent, metadata: sourceMetadata } + } + // Use the getStoredChunks function to retrieve chunks from Payload CMS - const retrievedChunks = await getStoredChunks(selectedSources) + const retrievedChunks = await getStoredChunks(selectedSourcesFiltered) console.log('Retrieved chunks:', retrievedChunks.length) if (retrievedChunks.length === 0) { - throw new Error('No content found in the selected sources.') + // If we have selected sources but no chunks found, fallback to course context + console.log('No content found in selected sources, falling back to course context') + const courseContent = `COURSE CONTEXT (Source Fallback):\n\n` + let structuredContent = courseContent + if (courseInfo) { + structuredContent += `Course: ${courseInfo.courseCode || ''} ${courseInfo.courseName || 'Academic Course'}\n` + structuredContent += `Semester: ${courseInfo.semester || 'Current Semester'}\n` + structuredContent += `Academic Year: ${courseInfo.academicYear || 'Current Academic Year'}\n\n` + } + structuredContent += `Topic: ${topicName || 'Course Topic'}\n\n` + structuredContent += `GENERAL KNOWLEDGE CONTEXT:\n` + structuredContent += `Content should be generated based on standard academic knowledge for "${topicName}"\n\n` + const sourceMetadata = { + sourceCount: 0, + chunkCount: 0, + tokenEstimate: countTokens(structuredContent), + sourceNames: [], + usingCourseContext: true, + } + return { content: structuredContent, metadata: sourceMetadata } } // Process chunks to create a more structured context @@ -184,6 +353,7 @@ export async function prepareSourceContent(selectedSources: ClientSource[]) { chunkCount: retrievedChunks.length, tokenEstimate: countTokens(structuredContent), sourceNames: Array.from(sourceGroups.keys()), + usingCourseContext: false, } return { content: structuredContent, metadata: sourceMetadata } @@ -196,6 +366,7 @@ export async function prepareSourceContent(selectedSources: ClientSource[]) { chunkCount: 0, tokenEstimate: 0, sourceNames: [], + usingCourseContext: false, }, } } @@ -379,39 +550,39 @@ export function parseQuestionsText(text: string, assessmentType: string): Assess // Get content type prompt export function getContentTypePrompt(type: string) { const contentPrompts = { - lecture: `Create a comprehensive lecture including: -- Clear, measurable learning outcomes -- At least 5-10 key terms with detailed definitions -- Engaging introduction that establishes relevance and context -- At least 5-10 detailed slides with substantial teaching material on each slide -- Comprehensive speaker notes for each slide with additional examples and explanations -- Relevant in-class activities with clear instructions and purpose -- Specific assessment ideas that align with learning outcomes -- Annotated further reading suggestions with brief descriptions`, - - tutorial: `Create a detailed tutorial including: -- Specific learning outcomes that build practical skills -- At least 5-10 key terms with detailed definitions -- Clear step-by-step instructions with examples and explanations -- Scaffolded practice exercises with increasing difficulty -- Sample solutions with detailed explanations of the process -- Common misconceptions and how to address them -- Formative assessment opportunities throughout -- Reflection points to consolidate learning -- Practical applications that demonstrate real-world relevance -- Differentiated activities for various skill levels`, - - workshop: `Create an interactive workshop including: -- Clear learning outcomes focused on skills development -- At least 5-10 key terms with detailed definitions -- Hands-on collaborative activities with detailed instructions -- Comprehensive facilitator notes for each activity -- Required materials with specific quantities and preparation notes -- Timing guidelines for each section of the workshop -- Discussion prompts that connect activities to learning objectives -- Reflection questions for participants -- Formative assessment methods that measure skill acquisition -- Guidance for managing group dynamics and participation`, + lecture: `Create a comprehensive lecture that includes: + - Clear, measurable learning outcomes + - At least 5-10 key terms with detailed definitions + - An engaging introduction that sets relevance and context + - At least 5-10 detailed slides with substantial teaching material on each slide + - Comprehensive speaker notes for each slide with examples and additional explanations + - In-class activities with clear instructions and objectives + - Specific assessment ideas aligned to the learning outcomes + - Suggested further readings with brief annotations`, + + tutorial: `Create a detailed tutorial that includes: + - Specific learning outcomes that build practical skills + - At least 5-10 key terms with detailed definitions + - Clear step-by-step instructions with examples and explanations + - Scaffolded exercises with increasing difficulty + - Sample solutions with detailed reasoning + - Common misconceptions and how to address them + - Formative assessment opportunities throughout the tutorial + - Reflection points to consolidate learning + - Practical applications that show real-world relevance + - Differentiated activities for varying skill levels`, + + workshop: `Create an interactive workshop that includes: + - Clear learning outcomes focused on skill development + - At least 5-10 key terms with detailed definitions + - Hands-on collaborative activities with detailed instructions + - Comprehensive facilitator notes for each activity + - Required materials list with specific quantities and preparation notes + - Timing guide for each section of the workshop + - Discussion prompts that connect activities to learning objectives + - Reflection questions for participants + - Formative assessment methods to measure skill achievement + - Guidance for managing group dynamics and participation`, } return contentPrompts[type as keyof typeof contentPrompts] || contentPrompts.lecture } @@ -419,14 +590,14 @@ export function getContentTypePrompt(type: string) { // Get content style prompt export function getContentStylePrompt(style: string) { const stylePrompts = { - interactive: `Make the content highly interactive with: + interactive: `Create highly interactive content with: - Discussion questions throughout - Think-pair-share activities - Student-led components - Opportunities for reflection - Real-time feedback mechanisms`, - caseStudy: `Structure content around case studies with: + caseStudy: `Structure case-study-based content with: - Detailed real-world examples - Analysis questions - Application exercises @@ -434,10 +605,10 @@ export function getContentStylePrompt(style: string) { - Critical thinking prompts`, problemBased: `Focus on problem-based learning with: - - Central problem statements + - A central problem statement - Guided inquiry activities - Research components - - Collaborative problem-solving + - Collaborative problem solving - Solution development and presentation`, traditional: `Use a traditional lecture format with: @@ -454,24 +625,24 @@ export function getContentStylePrompt(style: string) { export function getDifficultyLevelPrompt(level: string) { const difficultyPrompts = { introductory: `Target first-year undergraduate level: - - Define all specialized terms + - Define all domain-specific terms - Include more background context - - Provide numerous examples + - Provide many examples - Avoid complex theoretical models - Focus on foundational concepts`, - intermediate: `Target mid-program undergraduate level: + intermediate: `Target mid-level undergraduate: - Build on foundational knowledge - Introduce more specialized terminology - Include some theoretical frameworks - Expect basic prior knowledge - Balance theory and application`, - advanced: `Target final-year undergraduate or graduate level: - - Assume strong background knowledge - - Engage with complex theories + advanced: `Target advanced level (final-year undergraduate or postgraduate): + - Assume a strong knowledge background + - Discuss complex theories - Include current research - - Address nuances and exceptions + - Cover nuances and exceptions - Emphasize critical analysis`, } return ( diff --git a/frontend/src/app/api/study-plan/route.ts b/frontend/src/app/api/study-plan/route.ts index d3a55a0..946366a 100644 --- a/frontend/src/app/api/study-plan/route.ts +++ b/frontend/src/app/api/study-plan/route.ts @@ -1,16 +1,18 @@ // Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 -import { createOllama } from 'ollama-ai-provider' -import { type CoreMessage, generateObject } from 'ai' +import { createOllama } from 'ollama-ai-provider-v2' +import { type ModelMessage, generateObject } from 'ai' import { NextResponse } from 'next/server' import { getStoredChunks } from '@/lib/chunk/get-stored-chunks' import { errorResponse } from '@/lib/api-response' import type { ClientSource } from '@/lib/types/client-source' import type { StudyPlan } from '@/lib/types/study-plan' import type { ContextChunk } from '@/lib/types/context-chunk' +import { z } from 'zod' export const dynamic = 'force-dynamic' +// export const maxDuration = 600 // 10 minutes (600 seconds) for Vercel deployment // Configuration constants const TEMPERATURE = Number.parseFloat(process.env.RAG_TEMPERATURE || '0.1') @@ -19,6 +21,70 @@ const TOKEN_RESPONSE_RATIO = Number.parseFloat(process.env.RESPONSE_TOKEN_PERCEN const TOKEN_RESPONSE_BUDGET = Math.floor(TOKEN_MAX * TOKEN_RESPONSE_RATIO) const TOKEN_CONTEXT_BUDGET = Math.floor(TOKEN_MAX * (1 - TOKEN_RESPONSE_RATIO)) +// Zod schema for study plan validation +const studyPlanSchema = z.object({ + executiveSummary: z.string().min(1), + topicBreakdown: z + .array( + z.object({ + topic: z.string(), + subtopics: z.array(z.string()), + importance: z.string(), + estimatedStudyHours: z.number(), + }), + ) + .min(1), + weeklySchedule: z + .array( + z.object({ + week: z.number(), + focus: z.string(), + topics: z.array(z.string()), + activities: z.array( + z.object({ + type: z.string(), + description: z.string(), + duration: z.string(), + resources: z.string(), + }), + ), + milestones: z.array(z.string()), + }), + ) + .min(1), + studyTechniques: z + .array( + z.object({ + technique: z.string(), + description: z.string(), + bestFor: z.array(z.string()), + example: z.string(), + }), + ) + .min(1), + additionalResources: z + .array( + z.object({ + type: z.string(), + name: z.string(), + description: z.string(), + relevantTopics: z.array(z.string()), + }), + ) + .min(1), + practiceStrategy: z.object({ + approach: z.string(), + frequency: z.string(), + questionTypes: z.array(z.string()), + selfAssessment: z.string(), + }), + examPreparation: z.object({ + finalWeekPlan: z.string(), + dayBeforeExam: z.string(), + examDayTips: z.string(), + }), +}) + // Helper function to count tokens (simple approximation) function countTokens(text: string): number { return text.split(/\s+/).length @@ -244,7 +310,7 @@ The study plan should reflect standard academic expectations for a course with t } // Create assistant message with the source content - const assistantMessage: CoreMessage = { + const assistantMessage: ModelMessage = { role: 'assistant', content: assistantContent, } @@ -342,12 +408,12 @@ IMPORTANT RULES: 12. Do not add any fields that are not in the template above 13. Do not include any comments or explanations outside the JSON structure` - const systemMessage: CoreMessage = { + const systemMessage: ModelMessage = { role: 'system', content: studyPlanSystemPrompt, } - const userMessage: CoreMessage = { + const userMessage: ModelMessage = { role: 'user', content: `Generate a comprehensive study plan based on the provided content. Tailor it for a ${difficultyLevel} level student with a ${learningStyle} learning style preference, spanning ${studyPeriodWeeks} weeks with ${studyHoursPerWeek} hours available per week.`, } @@ -355,52 +421,59 @@ IMPORTANT RULES: console.log('Generating study plan with Ollama...') const startTime = Date.now() try { + // Use generateObject with increased context window to avoid timeouts + // The num_ctx option gives Ollama more memory to work with complex schemas const { object: studyPlan } = await generateObject({ - model: ollama(selectedModel, { numCtx: TOKEN_MAX }), - output: 'no-schema', + model: ollama(selectedModel), + schema: studyPlanSchema, messages: [systemMessage, assistantMessage, userMessage], temperature: TEMPERATURE, - maxTokens: TOKEN_RESPONSE_BUDGET, - mode: 'json', + maxOutputTokens: TOKEN_RESPONSE_BUDGET, + providerOptions: { + ollama: { + numCtx: TOKEN_MAX, + }, + }, }) + console.log('Checking on the raw studyPlan') + console.log(studyPlan) + // End timing and calculate the time taken const endTime = Date.now() const timeTakenSeconds = (endTime - startTime) / 1000 console.log(`Generation completed in ${timeTakenSeconds.toFixed(2)} seconds`) - // Type assertion to treat the response as a partial StudyPlan - const rawPlan = studyPlan as Partial - - // Create a simplified cleaned plan with validation to ensure all required fields exist - const cleanedPlan: StudyPlan = { - executiveSummary: - rawPlan.executiveSummary || 'A personalized study plan tailored to your learning needs.', - topicBreakdown: Array.isArray(rawPlan.topicBreakdown) ? rawPlan.topicBreakdown : [], - weeklySchedule: Array.isArray(rawPlan.weeklySchedule) ? rawPlan.weeklySchedule : [], - studyTechniques: Array.isArray(rawPlan.studyTechniques) ? rawPlan.studyTechniques : [], - additionalResources: Array.isArray(rawPlan.additionalResources) - ? rawPlan.additionalResources - : [], - practiceStrategy: rawPlan.practiceStrategy || { - approach: 'Regular practice with increasing difficulty', - frequency: 'Daily practice sessions', - questionTypes: ['Multiple choice', 'Short answer', 'Problem solving'], - selfAssessment: 'Regular self-assessment through practice tests', - }, - examPreparation: rawPlan.examPreparation || { - finalWeekPlan: 'Review all materials and take practice tests', - dayBeforeExam: 'Light review and relaxation', - examDayTips: "Get a good night's sleep and arrive early", - }, - } + // studyPlan is now properly typed and validated by Zod schema + // const cleanedPlan: StudyPlan = { + // ...studyPlan, + // executiveSummary: + // rawPlan.executiveSummary || 'A personalized study plan tailored to your learning needs.', + // topicBreakdown: Array.isArray(rawPlan.topicBreakdown) ? rawPlan.topicBreakdown : [], + // weeklySchedule: Array.isArray(rawPlan.weeklySchedule) ? rawPlan.weeklySchedule : [], + // studyTechniques: Array.isArray(rawPlan.studyTechniques) ? rawPlan.studyTechniques : [], + // additionalResources: Array.isArray(rawPlan.additionalResources) + // ? rawPlan.additionalResources + // : [], + // practiceStrategy: rawPlan.practiceStrategy || { + // approach: 'Regular practice with increasing difficulty', + // frequency: 'Daily practice sessions', + // questionTypes: ['Multiple choice', 'Short answer', 'Problem solving'], + // selfAssessment: 'Regular self-assessment through practice tests', + // }, + // examPreparation: rawPlan.examPreparation || { + // finalWeekPlan: 'Review all materials and take practice tests', + // dayBeforeExam: 'Light review and relaxation', + // examDayTips: "Get a good night's sleep and arrive early", + // }, + // } // Ensure weeklySchedule has the correct number of weeks - if (cleanedPlan.weeklySchedule.length < studyPeriodWeeks) { + if (studyPlan.weeklySchedule.length < studyPeriodWeeks) { // Add missing weeks - for (let i = cleanedPlan.weeklySchedule.length + 1; i <= studyPeriodWeeks; i++) { - cleanedPlan.weeklySchedule.push({ + for (let i = studyPlan.weeklySchedule.length + 1; i <= studyPeriodWeeks; i++) { + studyPlan.weeklySchedule.push({ week: i, focus: `Week ${i} Focus`, topics: ['Review previous material'], @@ -415,12 +488,15 @@ IMPORTANT RULES: milestones: ['Complete review of previous material'], }) } - } else if (cleanedPlan.weeklySchedule.length > studyPeriodWeeks) { + } else if (studyPlan.weeklySchedule.length > studyPeriodWeeks) { // Trim extra weeks - cleanedPlan.weeklySchedule = cleanedPlan.weeklySchedule.slice(0, studyPeriodWeeks) + studyPlan.weeklySchedule = studyPlan.weeklySchedule.slice(0, studyPeriodWeeks) } - return NextResponse.json(cleanedPlan) + console.log('Checking on the sanitized studyPlan') + console.log(studyPlan) + + return NextResponse.json(studyPlan) } catch (error) { console.error('Error generating study plan:', error) @@ -514,6 +590,9 @@ IMPORTANT RULES: }, } + console.log('Fallback content is being utilized') + console.log(fallbackPlan) + // Return the fallback plan with an error message return NextResponse.json(fallbackPlan) } diff --git a/frontend/src/app/api/summary/route.ts b/frontend/src/app/api/summary/route.ts index a9c5322..b1e76a8 100644 --- a/frontend/src/app/api/summary/route.ts +++ b/frontend/src/app/api/summary/route.ts @@ -1,8 +1,8 @@ // Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 -import { createOllama } from 'ollama-ai-provider' -import { generateObject, CoreMessage } from 'ai' +import { createOllama } from 'ollama-ai-provider-v2' +import { generateObject, ModelMessage } from 'ai' import { errorResponse } from '@/lib/api-response' import { hybridSearch } from '@/lib/chunk/hybrid-search' import { generateEmbeddings } from '@/lib/embedding/generate-embedding' @@ -66,7 +66,7 @@ const useReranker = false const similarityThreshold = 0.8 export async function POST(req: Request) { - const { selectedModel, selectedSources, courseInfo } = await req.json() + const { selectedModel, selectedSources, courseInfo, language } = await req.json() // Check if we have valid sources const hasValidSources = @@ -125,8 +125,28 @@ export async function POST(req: Request) { ? topChunks.map((chunk) => chunk.chunk).join('\n\n') : '' - const SystemPrompt = `You are a world-class academic summarizer. Your job is to generate a highly detailed, - well-structured, and interesting summary for students, based ${hasValidSources ? 'strictly on the provided context' : `on general academic knowledge${courseInfo?.courseName ? ` for the course "${courseInfo.courseName}"` : ''}${courseInfo?.courseDescription ? `. Course context: ${courseInfo.courseDescription}` : ''}`}. + const isID = language === 'id' + const languageDirective = isID + ? 'PENTING: Anda harus menghasilkan seluruh keluaran dalam Bahasa Indonesia.' + : 'IMPORTANT: You must produce the entire output in English.' + const overviewHeadingLabel = isID ? 'Ikhtisar' : 'Overview' + const conclusionHeadingLabel = isID ? 'Kesimpulan' : 'Conclusion' + const isConclusionHeadingText = (txt: string | undefined) => { + if (!txt) return false + const t = txt.trim().toLowerCase() + return t === 'conclusion' || t === 'kesimpulan' + } + + const strictnessDirective = hasValidSources + ? isID + ? 'SANGAT PENTING: Hanya gunakan konteks yang disediakan. Jangan menambahkan informasi yang tidak ada dalam konteks. Jika informasi tertentu tidak tersedia, abaikan bagian tersebut dan jangan menebak.' + : 'CRITICAL: Use only the provided context. Do not add information that is not present in the context. If certain information is missing, omit that part rather than guessing.' + : '' + + const SystemPrompt = `${languageDirective}\n\nYou are a world-class academic summarizer. Your job is to generate a highly detailed, + well-structured, and interesting summary for students, based ${hasValidSources ? 'strictly on the provided context' : `on general academic knowledge${courseInfo?.courseName ? ` for the course \"${courseInfo.courseName}\"` : ''}${courseInfo?.courseDescription ? `. Course context: ${courseInfo.courseDescription}` : ''}`}. + +${strictnessDirective} INSTRUCTIONS: - The summary must be comprehensive, with clear hierarchy (multiple heading levels, sections, and subsections). @@ -167,6 +187,7 @@ CRITICAL: Your response MUST be valid JSON only. Do not include any text, markdo - Be comprehensive, but concise and easy to scan - Include as much technical detail, facts, and examples as possible from the context. Do not omit any important information. - End with a short, insightful conclusion +STRICTNESS: Only use the provided context. Do not invent facts or add content from outside the provided context. If you do not use at least two heading levels and at least one list, your answer will be considered incomplete.` : `Generate a highly detailed, structured, and interesting summary${courseInfo?.courseName ? ` for students in ${courseInfo.courseName}` : ''}. ${courseInfo?.courseDescription ? `Use this course context to guide your summary: ${courseInfo.courseDescription}. ` : ''}The summary should: - Start with a clear, descriptive title (no markdown) @@ -178,34 +199,53 @@ If you do not use at least two heading levels and at least one list, your answer - End with a short, insightful conclusion If you do not use at least two heading levels and at least one list, your answer will be considered incomplete.` - const systemMessage: CoreMessage = { role: 'system', content: SystemPrompt } - const userMessage: CoreMessage = { role: 'user', content: UserPrompt } + const systemMessage: ModelMessage = { role: 'system', content: SystemPrompt } + const userMessage: ModelMessage = { role: 'user', content: UserPrompt } // Combine messages - only include assistant message if source is selected const fullMessages = hasValidSources - ? [systemMessage, { role: 'assistant', content: topChunkContent } as CoreMessage, userMessage] + ? [ + systemMessage, + { role: 'assistant', content: topChunkContent } as ModelMessage, + userMessage, + ] : [systemMessage, userMessage] const startFinalSummarizeTime = Date.now() const { object: summaryObj, usage: finalUsage } = await generateObject({ - model: ollama(selectedModel, { numCtx: TOKEN_RESPONSE_BUDGET }), + model: ollama(selectedModel), output: 'no-schema', messages: fullMessages, temperature: TEMPERATURE, - maxTokens: TOKEN_RESPONSE_BUDGET, + maxOutputTokens: TOKEN_RESPONSE_BUDGET, + providerOptions: { + ollama: { + mode: 'json', + options: { + numCtx: TOKEN_RESPONSE_BUDGET, + }, + }, + }, }) const endFinalSummarizeTime = Date.now() const finalTimeTakenMs = endFinalSummarizeTime - startFinalSummarizeTime const finalTimeTakenSeconds = finalTimeTakenMs / 1000 - const finalTotalTokens = finalUsage.completionTokens - const finalTokenGenerationSpeed = finalTotalTokens / finalTimeTakenSeconds + // Safely derive total tokens else fall back to input+output (coerce to number) + const finalTotalTokens = + typeof finalUsage?.totalTokens === 'number' + ? finalUsage.totalTokens + : Number(finalUsage?.inputTokens ?? 0) + Number(finalUsage?.outputTokens ?? 0) + + // Guard divide-by-zero when computing generation speed + const finalTokenGenerationSpeed = + finalTimeTakenSeconds > 0 ? finalTotalTokens / finalTimeTakenSeconds : 0 console.log( `Progress: 100.00 % | ` + `Tokens: ` + `promptEst(?) ` + - `prompt(${finalUsage.promptTokens}) ` + - `completion(${finalUsage.completionTokens}) | ` + + `prompt(${finalUsage?.inputTokens ?? 0}) ` + + `completion(${finalUsage?.outputTokens ?? 0}) | ` + `${finalTokenGenerationSpeed.toFixed(2)} t/s | ` + `Duration: ${finalTimeTakenSeconds} s`, ) @@ -326,8 +366,7 @@ If you do not use at least two heading levels and at least one list, your answer flattenBlocks(filteredContent, 2) } const conclusionCount = blocks.filter( - (block) => - block.type === 'heading' && block.content.trim().toLowerCase().includes('conclusion'), + (block) => block.type === 'heading' && isConclusionHeadingText(block.content), ).length if (conclusionCount > 0) { @@ -338,13 +377,12 @@ If you do not use at least two heading levels and at least one list, your answer console.log(`DEBUG: Also found conclusion in summaryObj.conclusion field`) } const hasExistingConclusion = blocks.some( - (block) => - block.type === 'heading' && block.content.trim().toLowerCase().includes('conclusion'), + (block) => block.type === 'heading' && isConclusionHeadingText(block.content), ) if (summaryObj.conclusion && !hasExistingConclusion) { console.log(`DEBUG: Adding conclusion from summaryObj.conclusion`) - blocks.push({ type: 'heading', content: 'Conclusion', level: 2 }) + blocks.push({ type: 'heading', content: conclusionHeadingLabel, level: 2 }) blocks.push({ type: 'paragraph', content: summaryObj.conclusion }) } else if (summaryObj.conclusion && hasExistingConclusion) { console.log(`DEBUG: Not adding conclusion - already exists in content blocks`) @@ -357,7 +395,12 @@ If you do not use at least two heading levels and at least one list, your answer if (fallbackText && fallbackText.trim().length > 0) { blocks.push({ type: 'paragraph', content: fallbackText }) } else { - blocks.push({ type: 'paragraph', content: 'No summary content was generated.' }) + blocks.push({ + type: 'paragraph', + content: isID + ? 'Tidak ada ringkasan yang dihasilkan.' + : 'No summary content was generated.', + }) } console.warn( 'WARNING: LLM summary object was missing or malformed. Fallback to plain text.', @@ -376,7 +419,7 @@ If you do not use at least two heading levels and at least one list, your answer ) } - // Prepare citation blocks (split paragraphs/lists into sentences/items) - only if source is selected + // Prepare citation blocks (split paragraphs/lists into sentences) - only if source is selected type CitationBlock = { type: string; content: string; blockIdx: number; itemIdx?: number } const citationBlocks: CitationBlock[] = [] @@ -670,7 +713,7 @@ If you do not use at least two heading levels and at least one list, your answer // Insert a special heading-title block for the title, and avoid duplicate Conclusion let processedBlocks: Block[] = [] const hasConclusionHeading = blocks.some( - (b) => b.type === 'heading' && b.content.trim().toLowerCase() === 'conclusion', + (b) => b.type === 'heading' && isConclusionHeadingText(b.content), ) // --- HIERARCHY IMPROVEMENT LOGIC --- // 1. Always insert a level 1 title as heading-title @@ -693,10 +736,10 @@ If you do not use at least two heading levels and at least one list, your answer break } } - // 3. If no level 2 headings, insert 'Overview' before first paragraph (if present) + // 3. If no level 2 headings, insert localized 'Overview' before first paragraph (if present) if (!hasLevel2) { if (blocks[idx] && blocks[idx].type === 'paragraph') { - out.push({ type: 'heading', content: 'Overview', level: 2 }) + out.push({ type: 'heading', content: overviewHeadingLabel, level: 2 }) out.push(blocks[idx]) idx++ } @@ -718,10 +761,10 @@ If you do not use at least two heading levels and at least one list, your answer if (b) out.push(b) } } - // 5. Remove all but the last 'Conclusion' heading and its following paragraph + // 5. Remove all but the last localized 'Conclusion' heading and its following paragraph let lastConclusionIdx = -1 for (let i = 0; i < out.length; i++) { - if (out[i].type === 'heading' && out[i].content.trim().toLowerCase() === 'conclusion') { + if (out[i].type === 'heading' && isConclusionHeadingText(out[i].content)) { lastConclusionIdx = i } } @@ -731,7 +774,7 @@ If you do not use at least two heading levels and at least one list, your answer for (let i = 0; i < out.length; ) { if ( out[i].type === 'heading' && - out[i].content.trim().toLowerCase() === 'conclusion' && + isConclusionHeadingText(out[i].content) && i !== lastConclusionIdx ) { // Skip this heading and the next paragraph if present @@ -753,10 +796,8 @@ If you do not use at least two heading levels and at least one list, your answer (b, i, arr) => !( b.type === 'heading' && - b.content.trim().toLowerCase() === 'conclusion' && - arr.findIndex( - (x) => x.type === 'heading' && x.content.trim().toLowerCase() === 'conclusion', - ) !== i + isConclusionHeadingText(b.content) && + arr.findIndex((x) => x.type === 'heading' && isConclusionHeadingText(x.content)) !== i ), ) } diff --git a/frontend/src/components/app-menu.tsx b/frontend/src/components/app-menu.tsx index 0edd6b6..f8d5a54 100644 --- a/frontend/src/components/app-menu.tsx +++ b/frontend/src/components/app-menu.tsx @@ -119,6 +119,13 @@ export function AppMenu({ activeItem }: { activeItem?: string }) { icon: , personas: ['lecturer'], }, + { + id: 'settings', + label: 'Settings', + href: `/workspace/settings${activePersona ? `?persona=${activePersona}` : ''}`, + icon: , + personas: ['faculty', 'lecturer', 'student'], + }, { id: 'study-plan', label: 'Study Plan', @@ -133,13 +140,6 @@ export function AppMenu({ activeItem }: { activeItem?: string }) { // icon: , // personas: ["lecturer"], // }, - { - id: 'settings', - label: 'Settings', - href: '/workspace/settings', - icon: , - personas: ['faculty'], - }, ] // Filter menu items based on active persona diff --git a/frontend/src/components/app-settings-form.tsx b/frontend/src/components/app-settings-form.tsx index a2a8ffb..dda6f5d 100644 --- a/frontend/src/components/app-settings-form.tsx +++ b/frontend/src/components/app-settings-form.tsx @@ -1,7 +1,7 @@ 'use client' import { z } from 'zod' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { useForm } from 'react-hook-form' import { Button } from '@/components/ui/button' import { @@ -29,7 +29,7 @@ export default function AppSettingsForm() { const setUserName = useChatStore((state) => state.setUserName) const form = useForm>({ - resolver: zodResolver(formSchema), + resolver: standardSchemaResolver(formSchema), defaultValues: { username: userName, }, diff --git a/frontend/src/components/assessment/assessment-editor.tsx b/frontend/src/components/assessment/assessment-editor.tsx index 5ec5ff5..bcdae41 100644 --- a/frontend/src/components/assessment/assessment-editor.tsx +++ b/frontend/src/components/assessment/assessment-editor.tsx @@ -57,7 +57,8 @@ export default function AssessmentEditor({ metadata, onUpdateMetadata, }: AssessmentEditorProps) { - const isProjectType = assessment.type.toLowerCase().includes('project') + // Treat both English and Indonesian labels as project type (e.g., "Project" or "Proyek") + const isProjectType = /\b(project|proyek)\b/i.test(assessment.type) const [previewMode, setPreviewMode] = useState(false) // Function to clean Markdown formatting from text @@ -579,6 +580,21 @@ function RubricsEditor({ question, isEditing, previewMode, onUpdate }: RubricsEd }) as ExplanationObject, ) + // Helpers to support both English and Indonesian rubric category labels + const rubricPrefixes = { + report: ['Report - ', 'Laporan - '], + demo: ['Demo - ', 'Presentasi Demo - '], + individual: ['Individual Contribution - ', 'Kontribusi Individu - '], + } + + const startsWithAny = (name: string, prefixes: string[]) => + prefixes.some((p) => name.startsWith(p)) + + const removeAnyPrefix = (name: string, prefixes: string[]) => { + for (const p of prefixes) if (name.startsWith(p)) return name.slice(p.length) + return name + } + // Function to update the explanation object and propagate changes const updateExplanation = (newExplanation: ExplanationObject) => { setExplanation(newExplanation) @@ -696,17 +712,33 @@ function RubricsEditor({ question, isEditing, previewMode, onUpdate }: RubricsEd ) } - // Group criteria by category - const reportCriteria = explanation.criteria?.filter((c) => c.name.includes('Report')) || [] - const demoCriteria = explanation.criteria?.filter((c) => c.name.includes('Demo')) || [] + // Group criteria by category (supports EN + ID prefixes) + const reportCriteria = + explanation.criteria?.filter((c) => startsWithAny(c.name, rubricPrefixes.report)) || [] + const demoCriteria = + explanation.criteria?.filter((c) => startsWithAny(c.name, rubricPrefixes.demo)) || [] const individualCriteria = - explanation.criteria?.filter((c) => c.name.includes('Individual')) || [] + explanation.criteria?.filter((c) => startsWithAny(c.name, rubricPrefixes.individual)) || [] const otherCriteria = explanation.criteria?.filter( (c) => - !c.name.includes('Report') && !c.name.includes('Demo') && !c.name.includes('Individual'), + !startsWithAny(c.name, rubricPrefixes.report) && + !startsWithAny(c.name, rubricPrefixes.demo) && + !startsWithAny(c.name, rubricPrefixes.individual), ) || [] + // Prefer provided marking scale (could be Indonesian) with English fallback + const explanationObj: ExplanationObject | null = + typeof question.explanation === 'object' ? (question.explanation as ExplanationObject) : null + let markingScaleText = + 'Marking Scale: 1 - Poor, 2 - Acceptable, 3 - Average, 4 - Good, 5- Excellent.' + if (explanationObj) { + const maybeScale = (explanationObj as Record)['markingScale'] + if (typeof maybeScale === 'string') { + markingScaleText = maybeScale + } + } + return ( @@ -1274,9 +1306,7 @@ function RubricsEditor({ question, isEditing, previewMode, onUpdate }: RubricsEd ) : (

Grading Rubrics

-

- Marking Scale: 1 - Poor, 2 - Acceptable, 3 - Average, 4 - Good, 5- Excellent. -

+

{markingScaleText}

{/* Report Criteria */} {reportCriteria.length > 0 && ( @@ -1309,7 +1339,7 @@ function RubricsEditor({ question, isEditing, previewMode, onUpdate }: RubricsEd className={index % 2 === 0 ? 'bg-gray-80' : 'bg-gray-60'} > - {criterion.name.replace('Report - ', '')} + {removeAnyPrefix(criterion.name, rubricPrefixes.report)} A, A- B+, B, B- @@ -1355,7 +1385,7 @@ function RubricsEditor({ question, isEditing, previewMode, onUpdate }: RubricsEd className={index % 2 === 0 ? 'bg-gray-80' : 'bg-gray-60'} > - {criterion.name.replace('Demo - ', '')} + {removeAnyPrefix(criterion.name, rubricPrefixes.demo)} A, A- B+, B, B- @@ -1401,7 +1431,7 @@ function RubricsEditor({ question, isEditing, previewMode, onUpdate }: RubricsEd className={index % 2 === 0 ? 'bg-gray-80' : 'bg-gray-60'} > - {criterion.name.replace('Individual Contribution - ', '')} + {removeAnyPrefix(criterion.name, rubricPrefixes.individual)} A, A- B+, B, B- diff --git a/frontend/src/components/chat/chat-list.tsx b/frontend/src/components/chat/chat-list.tsx index 2297cf0..e280a6f 100644 --- a/frontend/src/components/chat/chat-list.tsx +++ b/frontend/src/components/chat/chat-list.tsx @@ -7,10 +7,10 @@ import { ChatMessageList } from '../ui/chat/chat-message-list' import { ChatBubble, ChatBubbleMessage } from '../ui/chat/chat-bubble' import { ChatRequestOptions } from 'ai' import { GraduationCap } from 'lucide-react' -import { Message } from '@ai-sdk/react' +import { UIMessage } from '@ai-sdk/react' interface ChatListProps { - messages: Message[] + messages: UIMessage[] isLoading: boolean loadingSubmit?: boolean reload: (chatRequestOptions?: ChatRequestOptions) => Promise diff --git a/frontend/src/components/chat/chat-message.tsx b/frontend/src/components/chat/chat-message.tsx index 6087bd9..3fa8bbd 100644 --- a/frontend/src/components/chat/chat-message.tsx +++ b/frontend/src/components/chat/chat-message.tsx @@ -1,7 +1,7 @@ // Copyright (C) 2025 Intel Corporation // SPDX-License-Identifier: Apache-2.0 -import React, { memo, useMemo, useState } from 'react' +import React, { memo, useMemo, useState, useEffect } from 'react' import { motion } from 'framer-motion' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -13,11 +13,12 @@ import { ChatBubble, ChatBubbleMessage } from '../ui/chat/chat-bubble' import ButtonWithTooltip from '../button-with-tooltip' import { Button } from '../ui/button' import CodeDisplayBlock from '../code-display-block' -import { Message } from '@ai-sdk/react' +import { UIMessage } from '@ai-sdk/react' import { toast } from 'sonner' +import { extractTextFromMessage } from '@/lib/utils/message' export type ChatMessageProps = { - message: Message + message: UIMessage isLast: boolean isLoading: boolean | undefined reload: (chatRequestOptions?: ChatRequestOptions) => Promise @@ -45,44 +46,55 @@ function ChatMessage({ reload, canRegenerate = true, }: ChatMessageProps) { - const [isCopied] = useState(false) + const [isCopied, setIsCopied] = useState(false) + + // messageRecord for typed access + const messageRecord = message as unknown as Record // Extract "think" content from Deepseek R1 models and clean message (rest) content - const { thinkContent, cleanContent } = useMemo(() => { - const getThinkContent = (content: string) => { + const { thinkContent, cleanContent, rawText } = useMemo(() => { + const getThinkContent = (content: string): string | null => { const match = content.match(/([\s\S]*?)(?:<\/think>|$)/) return match ? match[1].trim() : null } + // Prefer v5 parts extraction, fall back to legacy message.content + const raw = extractTextFromMessage(message) || (messageRecord.content as string) || '' + return { - thinkContent: message.role === 'assistant' ? getThinkContent(message.content) : null, - cleanContent: message.content.replace(/[\s\S]*?(?:<\/think>|$)/g, '').trim(), + thinkContent: message.role === 'assistant' ? getThinkContent(raw) : null, + cleanContent: raw.replace(/[\s\S]*?(?:<\/think>|$)/g, '').trim(), + rawText: raw, } - }, [message.content, message.role]) + }, [message, messageRecord.content]) const contentParts = useMemo(() => cleanContent.split('```'), [cleanContent]) - const handleCopy = async () => { + const handleCopy = async (): Promise => { if (navigator.clipboard && navigator.clipboard.writeText) { try { - await navigator.clipboard.writeText(message.content) + await navigator.clipboard.writeText(rawText || '') + setIsCopied(true) toast.success('Chat message copied to clipboard', { description: 'Use ctrl + v to paste it', }) + setTimeout(() => setIsCopied(false), 2000) } catch (err) { console.error('Failed to copy text: ', err) } } else { // Fallback method const textarea = document.createElement('textarea') - textarea.value = message.content + textarea.value = rawText || '' document.body.appendChild(textarea) textarea.select() try { document.execCommand('copy') + setIsCopied(true) toast.success('Chat message copied to clipboard', { description: 'Use ctrl + v to paste it', }) + setTimeout(() => setIsCopied(false), 2000) } catch (err) { console.error('Failed to copy text: ', err) } @@ -90,22 +102,116 @@ function ChatMessage({ } } - const renderAttachments = () => ( -
- {message.experimental_attachments - ?.filter((attachment) => attachment.contentType?.startsWith('image/')) - .map((attachment, index) => ( - attached image - ))} -
- ) + /* Normalize attachments from v5 message.parts or legacy experimental_attachments. + - SSR-safe: avoid creating blob URLs during server render. + - Creates blob URLs for inline/base64 data and revokes them on cleanup. */ + type NormalizedAttachment = { + url?: string + contentType?: string + name?: string + blobUrl?: string + } + + type Part = { + type?: string + mime?: string + contentType?: string + url?: string + name?: string + filename?: string + data?: string + body?: string + } + + // Helper: convert data URL to Blob (browser only) + function dataURLToBlob(dataUrl: string): Blob { + const [meta, data] = dataUrl.split(',') + const mimeMatch = meta?.match(/:(.*?);/) + const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream' + const binary = atob(data) + const len = binary.length + const u8 = new Uint8Array(len) + for (let i = 0; i < len; i++) u8[i] = binary.charCodeAt(i) + return new Blob([u8], { type: mime }) + } + + const attachments = useMemo(() => { + // Avoid blob creation on server + if (typeof window === 'undefined') return [] + + const out: NormalizedAttachment[] = [] + const parts = (message as unknown as { parts?: Part[] }).parts + + if (Array.isArray(parts)) { + for (const part of parts) { + // common part shapes: { type: 'file', mime, url, name, filename, data } + const mime = part.mime || part.contentType || undefined + const url = part.url + const name = part.name || part.filename + const data = part.data || part.body + + const isFilePart = part.type === 'file' || !!url || !!data + if (!isFilePart) continue + + const att: NormalizedAttachment = { url, contentType: mime, name } + + // Inline/base64 data -> create blob URL + if (!att.url && typeof data === 'string') { + try { + const dataUrl = data.startsWith('data:') + ? data + : `data:${mime || 'application/octet-stream'};base64,${data}` + const blob = dataURLToBlob(dataUrl) + att.blobUrl = URL.createObjectURL(blob) + } catch { + // ignore invalid data + } + } + + out.push(att) + } + } + + return out + }, [message]) + + // Cleanup any created blob URLs when the message changes/unmount + useEffect(() => { + return () => { + for (const a of attachments) { + if (a.blobUrl) { + try { + URL.revokeObjectURL(a.blobUrl) + } catch { + // ignore + } + } + } + } + }, [attachments]) + + const renderAttachments = (): React.ReactNode => { + if (!attachments || attachments.length === 0) return null + + return ( +
+ {attachments + .filter((attachment: NormalizedAttachment) => + attachment.contentType?.startsWith?.('image/'), + ) + .map((attachment: NormalizedAttachment, index: number) => ( + {attachment.name + ))} +
+ ) + } const renderThinkingProcess = () => thinkContent && @@ -120,8 +226,8 @@ function ChatMessage({ ) - const renderContent = () => - contentParts.map((part, index) => + const renderContent = (): React.ReactNode => + contentParts.map((part: string, index: number) => index % 2 === 0 ? (
@@ -177,7 +283,7 @@ function ChatMessage({ ) } -export default memo(ChatMessage, (prevProps, nextProps) => { +export default memo(ChatMessage, (prevProps: ChatMessageProps, nextProps: ChatMessageProps) => { if (nextProps.isLast) return false return prevProps.isLast === nextProps.isLast && prevProps.message === nextProps.message }) diff --git a/frontend/src/components/chat/chat.tsx b/frontend/src/components/chat/chat.tsx index 6535d22..0515fd8 100644 --- a/frontend/src/components/chat/chat.tsx +++ b/frontend/src/components/chat/chat.tsx @@ -5,52 +5,43 @@ import ChatList from './chat-list' import ChatBottombar from './chat-bottombar' -import { Attachment, ChatRequestOptions, generateId } from 'ai' +import { ChatRequestOptions, generateId, DefaultChatTransport } from 'ai' import React, { useEffect, useState } from 'react' import { toast } from 'sonner' import useChatStore from '@/lib/store/chat-store' import { useRouter } from 'next/navigation' import { useSourcesStore } from '@/lib/store/sources-store' import { errorToastHandler } from '@/lib/handler/error-toast-handler' -import { Message, useChat } from '@ai-sdk/react' +import { UIMessage, useChat } from '@ai-sdk/react' import { usePersonaStore } from '@/lib/store/persona-store' import { useCourses } from '@/lib/hooks/use-courses' export interface ChatProps { id: string - initialMessages: Message[] | [] + initialMessages: UIMessage[] | [] selectedModel: string } export default function Chat({ initialMessages, id, selectedModel }: ChatProps) { - const { messages, input, handleInputChange, handleSubmit, status, stop, setMessages, reload } = - useChat({ - id, - initialMessages, - onResponse: (response) => { - if (response) { - setLoadingSubmit(false) - } - }, - onFinish: (message) => { - const savedMessages = getMessagesById(id) - saveMessages( - id, - [...savedMessages, message], - selectedModel, - selectedCourse, - selectedSources, - ) - setLoadingSubmit(false) - router.replace(`/workspace/chat/${id}`) - }, - onError: (error) => { - setLoadingSubmit(false) - router.replace('/workspace/chat') - console.log(error) - errorToastHandler(error) - }, - }) + const [input, setInput] = useState('') + const { messages, sendMessage, status, stop, setMessages, regenerate } = useChat({ + id, + messages: initialMessages, + transport: new DefaultChatTransport({ + api: '/api/chat', + }), + onFinish: ({ message }) => { + const savedMessages = getMessagesById(id) + saveMessages(id, [...savedMessages, message], selectedModel, selectedCourse, selectedSources) + setLoadingSubmit(false) + router.replace(`/workspace/chat/${id}`) + }, + onError: (error) => { + setLoadingSubmit(false) + router.replace('/workspace/chat') + errorToastHandler(error) + }, + }) const [loadingSubmit, setLoadingSubmit] = React.useState(false) const base64Images = useChatStore((state) => state.base64Images) const setBase64Images = useChatStore((state) => state.setBase64Images) @@ -60,7 +51,7 @@ export default function Chat({ initialMessages, id, selectedModel }: ChatProps) const { data: coursesData } = useCourses() const [contentHeight, setContentHeight] = useState('calc(100vh - 64px)') // Assuming 64px for header const selectedSources = useSourcesStore((state) => state.selectedSources) - const { activePersona, selectedCourseId } = usePersonaStore() + const { activePersona, selectedCourseId, getPersonaLanguage } = usePersonaStore() const selectedCourse = coursesData?.docs.find((course) => course.id === selectedCourseId) const onSubmit = (e: React.FormEvent) => { @@ -72,39 +63,51 @@ export default function Chat({ initialMessages, id, selectedModel }: ChatProps) return } - const userMessage: Message = { + // Create attachments for images + const attachments = + base64Images?.map((b64, i) => { + const mediaType = b64.match(/^data:([^;]+);base64,/)?.[1] ?? 'image/png' + return { + name: `image-${i}.png`, + contentType: mediaType, + url: b64, + } + }) ?? [] + + const messageParts = [{ type: 'text' as const, text: input }] + const userMessage: UIMessage = { id: generateId(), role: 'user', - content: input, + parts: messageParts, + ...(attachments.length > 0 && { attachments }), } setLoadingSubmit(true) - const attachments: Attachment[] = base64Images - ? base64Images.map((image) => ({ - contentType: 'image/base64', - url: image, - })) - : [] - const requestOptions: ChatRequestOptions = { body: { selectedModel: selectedModel, selectedSources, conversationHistory: 'yes', + language: getPersonaLanguage(activePersona), }, ...(base64Images && { data: { images: base64Images, conversationHistory: 'yes', }, - experimental_attachments: attachments, }), } - handleSubmit(e, requestOptions) - saveMessages(id, [...messages, userMessage], selectedModel, selectedCourse, selectedSources) - setBase64Images(null) + try { + sendMessage(userMessage, requestOptions) + saveMessages(id, [...messages, userMessage], selectedModel, selectedCourse, selectedSources) + setBase64Images(null) + setInput('') + } catch (error) { + console.error('Error in onSubmit:', error) + setLoadingSubmit(false) + } } const removeLatestMessage = () => { @@ -141,22 +144,25 @@ export default function Chat({ initialMessages, id, selectedModel }: ChatProps) messages={messages} isLoading={getIsLoadingFromStatus(status)} loadingSubmit={loadingSubmit} - reload={async () => { + reload={async (chatRequestOptions?: ChatRequestOptions) => { removeLatestMessage() const requestOptions: ChatRequestOptions = { body: { selectedModel: selectedModel, + language: getPersonaLanguage(activePersona), }, + ...chatRequestOptions, } setLoadingSubmit(true) - return reload(requestOptions) + await regenerate(requestOptions) + return null }} /> ) => setInput(e.target.value)} handleSubmit={onSubmit} isLoading={getIsLoadingFromStatus(status)} stop={handleStop} diff --git a/frontend/src/components/edit-username-form.tsx b/frontend/src/components/edit-username-form.tsx index 1a6604b..60c8ae8 100644 --- a/frontend/src/components/edit-username-form.tsx +++ b/frontend/src/components/edit-username-form.tsx @@ -1,7 +1,7 @@ 'use client' import { z } from 'zod' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { useForm } from 'react-hook-form' import { Button } from '@/components/ui/button' import { @@ -29,7 +29,7 @@ export default function EditUsernameForm() { const setUserName = useChatStore((state) => state.setUserName) const form = useForm>({ - resolver: zodResolver(formSchema), + resolver: standardSchemaResolver(formSchema), defaultValues: { username: userName, }, diff --git a/frontend/src/components/model-downloader.tsx b/frontend/src/components/model-downloader.tsx index fca1d52..0c6a658 100644 --- a/frontend/src/components/model-downloader.tsx +++ b/frontend/src/components/model-downloader.tsx @@ -16,7 +16,7 @@ import useChatStore from '@/lib/store/chat-store' import { useModels } from '@/lib/hooks/use-models' import { useForm } from 'react-hook-form' import { z } from 'zod' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { throttle } from 'lodash' import { Info, Loader2 } from 'lucide-react' @@ -46,7 +46,7 @@ export function ModelDownloader({ open, onOpenChange }: ModelDownloaderProps) { const { mutate } = useModels() const form = useForm>({ - resolver: zodResolver(formSchema), + resolver: standardSchemaResolver(formSchema), defaultValues: { name: '', }, diff --git a/frontend/src/components/slide/ConfigView.tsx b/frontend/src/components/slide/ConfigView.tsx index c850e4c..e523618 100644 --- a/frontend/src/components/slide/ConfigView.tsx +++ b/frontend/src/components/slide/ConfigView.tsx @@ -102,12 +102,10 @@ export function ConfigView({ ))} {selectedSources.filter((source) => source.selected).length === 0 && (
-

- No sources selected. Please select at least one source document from the - sidebar. -

+

No sources selected. Selecting sources is optional.

- The generated content will be based ONLY on your selected sources. + If you select sources, the generated content will be based ONLY on them. + If no sources are selected, it will use your course and topic context.

)} diff --git a/frontend/src/components/slide/CourseContentGenerator.tsx b/frontend/src/components/slide/CourseContentGenerator.tsx index 6cda010..faf5c04 100644 --- a/frontend/src/components/slide/CourseContentGenerator.tsx +++ b/frontend/src/components/slide/CourseContentGenerator.tsx @@ -6,11 +6,14 @@ import { useState } from 'react' import { toast } from 'sonner' import { ContextRequirementMessage } from '@/components/context-requirement-message' import { useContextAvailability } from '@/lib/hooks/use-context-availability' +import { useCourses } from '@/lib/hooks/use-courses' +import type { Course } from '@/payload-types' import { useSourcesStore } from '@/lib/store/sources-store' import type { LectureContent, View } from '@/lib/types/slide' import { WelcomeView } from './WelcomeView' import { ConfigView } from './ConfigView' import { ContentView } from './ContentView' +import { usePersonaStore } from '@/lib/store/persona-store' export default function CourseContentGenerator() { const [courseContent, setCourseContent] = useState(null) @@ -35,8 +38,12 @@ export default function CourseContentGenerator() { } | null>(null) const { getActiveContextModelName } = useContextAvailability() + const { data: coursesData } = useCourses() const selectedModel = getActiveContextModelName() const selectedSources = useSourcesStore((state) => state.selectedSources) + const activePersona = usePersonaStore((s) => s.activePersona) + const getPersonaLanguage = usePersonaStore((s) => s.getPersonaLanguage) + const selectedCourseId = usePersonaStore((s) => s.selectedCourseId) const generateCourseContent = async () => { if (!selectedModel) { @@ -50,8 +57,9 @@ export default function CourseContentGenerator() { } const selectedSourcesCount = selectedSources.filter((source) => source.selected).length - if (selectedSourcesCount === 0 || selectedSourcesCount >= 2) { - toast.error('Please select EXACTLY one source.') + // Allow 0 or 1 source selected; block only if more than one + if (selectedSourcesCount > 1) { + toast.error('Please select at most one source.') return } @@ -62,6 +70,15 @@ export default function CourseContentGenerator() { setExpandedQuestions({}) try { + // Derive courseInfo from the selected course (if available) + const selectedCourse = coursesData?.docs?.find((c: Course) => c.id === selectedCourseId) + const courseInfo = selectedCourse + ? { + courseCode: selectedCourse.code || '', + courseName: selectedCourse.name || '', + } + : undefined + const response = await fetch('/api/slide', { method: 'POST', headers: { @@ -75,6 +92,8 @@ export default function CourseContentGenerator() { sessionLength, difficultyLevel, topicName, + language: getPersonaLanguage(activePersona), + courseInfo, }), }) @@ -92,7 +111,7 @@ export default function CourseContentGenerator() { setGenerationError(data._error) toast.warning('Content generation had issues. Using fallback content.') } else { - toast.success('Course content generated successfully from your selected sources!') + toast.success('Course content generated successfully (using sources if provided).') } // Store source metadata if available @@ -134,6 +153,8 @@ export default function CourseContentGenerator() { body: JSON.stringify({ action: 'download-pptx', content: enhancedContent, + // Pass persona language so the PPTX headings can be localized + language: getPersonaLanguage(activePersona), }), }) @@ -199,6 +220,8 @@ export default function CourseContentGenerator() { body: JSON.stringify({ action: 'download-pdf', content: enhancedContent, + // Pass persona language so the PDF headings can be localized + language: getPersonaLanguage(activePersona), }), }) diff --git a/frontend/src/components/ui/chat/chat-message-list.tsx b/frontend/src/components/ui/chat/chat-message-list.tsx index 146ad85..441194c 100644 --- a/frontend/src/components/ui/chat/chat-message-list.tsx +++ b/frontend/src/components/ui/chat/chat-message-list.tsx @@ -8,17 +8,40 @@ interface ChatMessageListProps extends React.HTMLAttributes { } const ChatMessageList = React.forwardRef( - ({ className, children, smooth = false, ...props }) => { + ({ className, children, smooth = false, ...props }, ref) => { const { scrollRef, isAtBottom, scrollToBottom, disableAutoScroll } = useAutoScroll({ smooth, content: children, }) + // Robust type guard for mutable refs to avoid type assertions + const isMutableRefObject = (r: unknown): r is React.MutableRefObject => { + return r != null && typeof r === 'object' && 'current' in r + } + + // Merge forwarded ref with internal scrollRef so parents can access the scrollable element + const setMergedRef = React.useCallback( + (node: HTMLDivElement | null) => { + // Assign to internal scrollRef + if (isMutableRefObject(scrollRef)) { + scrollRef.current = node + } + + // Assign to forwarded ref (function or object ref) + if (typeof ref === 'function') { + ref(node) + } else if (isMutableRefObject(ref)) { + ref.current = node + } + }, + [scrollRef, ref], + ) + return (
( {!isAtBottom && (