From 65420320e79e89e36e814757425571986b7b877f Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Mon, 29 Jun 2026 12:28:58 +0100 Subject: [PATCH] feat(ai-assistant): odt support, document-to-lesson attach, upload UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add .odt (application/vnd.oasis.opendocument.text) as a supported upload format; extract text via JSZip + content.xml parsing - Add attach_document_to_lesson agent tool so the AI can attach an uploaded document to a lesson as a downloadable material - Instruct the agent to create a "Course Materials" lesson (first lesson of the first section) and attach the uploaded document to it on implementation - Suppress hallucinated references: uploaded documents do not count as fetched URLs — omit the References section when no fetch_documentation_url calls were made - Surface specific upload error snackbars (file_too_large, unsupported_file_type) instead of silently logging; fix generic fallback message - Allow typing in the chat textarea while a document is uploading; only block Enter/send, not the whole input - Fix retry endpoint to return the current run instead of erroring when the run is already active (queued/running/waiting_for_input/paused) - Connect @cio/core Redis singleton in the jobs bootstrap so course-generation workers no longer fail with "The client is closed" - Expose documentAssets (assetId + fileName) alongside documentText in AgentContext and pass it through both the chat route and course runner --- apps/api/src/routes/agent/agent.ts | 15 +++++-- apps/api/src/services/agent/run-state.ts | 4 ++ .../ai-assistant/api/ai-assistant.svelte.ts | 22 +++++++++- .../features/ai-assistant/chat-input.svelte | 6 +-- .../ai-assistant/message-bubble.svelte | 2 +- .../features/ai-assistant/plan-view.svelte | 20 ++++++++- .../src/lib/utils/translations/da.json | 5 ++- .../src/lib/utils/translations/de.json | 5 ++- .../src/lib/utils/translations/en.json | 5 ++- .../src/lib/utils/translations/es.json | 5 ++- .../src/lib/utils/translations/fr.json | 5 ++- .../src/lib/utils/translations/hi.json | 5 ++- .../src/lib/utils/translations/pl.json | 5 ++- .../src/lib/utils/translations/pt.json | 5 ++- .../src/lib/utils/translations/ru.json | 5 ++- .../src/lib/utils/translations/vi.json | 5 ++- apps/jobs/src/bootstrap.ts | 3 ++ packages/ai-assistant/src/prompt/teacher.ts | 25 ++++++++++- packages/ai-assistant/src/types.ts | 9 +++- .../src/services/agent/agent-tool-schemas.ts | 4 ++ .../core/src/services/agent/chat-context.ts | 17 ++++++- .../core/src/services/agent/chat-tools.ts | 44 +++++++++++++++++++ .../agent/course-generation-runner.ts | 10 ++++- packages/core/src/services/agent/document.ts | 31 ++++++++++++- 24 files changed, 235 insertions(+), 27 deletions(-) diff --git a/apps/api/src/routes/agent/agent.ts b/apps/api/src/routes/agent/agent.ts index 341c4c33..a6511912 100644 --- a/apps/api/src/routes/agent/agent.ts +++ b/apps/api/src/routes/agent/agent.ts @@ -69,6 +69,7 @@ import { collectDocumentIds, getActiveCourseTemplateId, getLatestImplementationPlan, + loadDocumentsMeta, loadDocumentsText, verifyExerciseBelongsToCourse, verifyLessonBelongsToCourse @@ -410,6 +411,7 @@ const agentCoreRouter = new Hono() .select({ title: schema.course.title, description: schema.course.description, + metadata: schema.course.metadata, organizationId: schema.group.organizationId }) .from(schema.course) @@ -449,7 +451,10 @@ const agentCoreRouter = new Hono() } const documentIds = collectDocumentIds(messages, context?.documentId); - const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, user.id) : undefined; + const [documentText, documentAssets] = await Promise.all([ + documentIds.length > 0 ? loadDocumentsText(documentIds, user.id) : Promise.resolve(undefined), + documentIds.length > 0 ? loadDocumentsMeta(documentIds) : Promise.resolve([]) + ]); const existingSections = await listCourseSections(courseId); @@ -496,7 +501,9 @@ const agentCoreRouter = new Hono() exerciseTitle, documentId: context?.documentId, documentText, - existingSectionCount: existingSections.length + documentAssets, + existingSectionCount: existingSections.length, + isContentGroupingEnabled: courseRow.metadata?.isContentGroupingEnabled ?? true }; trackAgentEvent(AgentEvent.CHAT_STARTED, { @@ -531,7 +538,9 @@ const agentCoreRouter = new Hono() const agentTools = role === AgentRole.STUDENT ? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings) - : filterToolsForChatMode(buildAgentTools(orgId, user.id, courseId, messages, { isOrgOnPaidPlan: isOrgPaid })); + : filterToolsForChatMode( + buildAgentTools(orgId, user.id, courseId, messages, { isOrgOnPaidPlan: isOrgPaid, documentAssets }) + ); const contextManaged = await buildModelContextMessages({ conversationId, diff --git a/apps/api/src/services/agent/run-state.ts b/apps/api/src/services/agent/run-state.ts index 0bca9840..546af4f0 100644 --- a/apps/api/src/services/agent/run-state.ts +++ b/apps/api/src/services/agent/run-state.ts @@ -331,6 +331,10 @@ export async function resumeAgentRunState(runId: string, userId: string, orgId: export async function retryAgentRunState(runId: string, userId: string, orgId: string): Promise { const run = await getOwnedAgentRun(runId, userId, orgId); + if (ACTIVE_RUN_STATUSES.has(run.status)) { + return buildAgentRunDetail(run); + } + if (run.status !== 'failed') { throw new AppError('Only failed agent runs can be retried', 'AGENT_RUN_NOT_RETRYABLE', 409); } diff --git a/apps/dashboard/src/lib/features/ai-assistant/api/ai-assistant.svelte.ts b/apps/dashboard/src/lib/features/ai-assistant/api/ai-assistant.svelte.ts index d3fdf035..fd280d33 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/api/ai-assistant.svelte.ts +++ b/apps/dashboard/src/lib/features/ai-assistant/api/ai-assistant.svelte.ts @@ -1,4 +1,6 @@ import { BaseApiWithErrors, classroomio, apiClient, getRequestBaseUrl } from '$lib/utils/services/api'; +import { snackbar } from '$features/ui/snackbar/store'; +import { t } from '$lib/utils/functions/translations'; import type { AgentConversation, AgentConversationCreateData, @@ -158,7 +160,25 @@ class AiAssistantApi extends BaseApiWithErrors { } } catch (error) { console.error('Error uploading document:', error); - this.error = 'Failed to upload document'; + + let errorCode: string | null = null; + + if (error instanceof Error) { + try { + const body = JSON.parse(error.message) as { error?: string }; + errorCode = body.error ?? null; + } catch { + // message was not JSON + } + } + + if (errorCode === 'file_too_large') { + snackbar.error(t.get('ai_assistant.upload_error_file_too_large')); + } else if (errorCode === 'unsupported_file_type') { + snackbar.error(t.get('ai_assistant.upload_error_unsupported_type')); + } else { + snackbar.error(t.get('ai_assistant.upload_error_generic')); + } } return null; diff --git a/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte b/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte index 532e391e..3ac0ebe5 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte @@ -175,7 +175,7 @@ @@ -255,9 +255,9 @@ bind:ref={chatTextareaRef} bind:value={inputValue} {mentionItems} - onSubmit={onSend} + onSubmit={isUploading ? undefined : onSend} placeholder={$t('ai_assistant.input_placeholder')} - disabled={isStreaming || isUploading} + disabled={isStreaming} typeLabel={getTypeLabel} emptyMessage={t.get('ai_assistant.mention_no_results')} rows={2} diff --git a/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte b/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte index de700153..9e0b65ad 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte @@ -151,7 +151,7 @@ ? 'bg-white/15 text-white/90' : 'ui:bg-background/70 ui:text-muted-foreground'}" > - + {messageAttachment.name} {/if} diff --git a/apps/dashboard/src/lib/features/ai-assistant/plan-view.svelte b/apps/dashboard/src/lib/features/ai-assistant/plan-view.svelte index 1fe5f0f2..a5526b5b 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/plan-view.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/plan-view.svelte @@ -5,6 +5,7 @@ import FileQuestionIcon from '@lucide/svelte/icons/file-question'; import PencilIcon from '@lucide/svelte/icons/pencil'; import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'; + import LoaderIcon from '@lucide/svelte/icons/loader'; import { Button } from '@cio/ui/base/button'; import { Textarea } from '@cio/ui/base/textarea'; import { SvelteSet } from 'svelte/reactivity'; @@ -53,6 +54,12 @@ isExpanded = !defaultCollapsed; }); + let isImplementing = $state(false); + + $effect(() => { + if (isBusy) isImplementing = false; + }); + let changeRequest = $state(''); const canSendChange = $derived(!isBusy && changeRequest.trim().length > 0); @@ -301,7 +308,18 @@ {/if}
-
diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index 0c6c531c..5de4bfff 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -4316,7 +4316,10 @@ "ask_ai": "Spørg AI", "content_input_placeholder": "Spørg om dette indhold...", "ask_ai_expand_aria": "Åbn Spørg AI-input", - "ask_ai_input_aria": "Spørg AI om dette indhold" + "ask_ai_input_aria": "Spørg AI om dette indhold", + "upload_error_file_too_large": "Filen er for stor. Maksimal størrelse er 5 MB.", + "upload_error_unsupported_type": "Filtype understøttes ikke. Upload venligst en PDF-, DOCX-, PPTX- eller ODT-fil.", + "upload_error_generic": "Kunne ikke uploade dokument. Prøv venligst igen." }, "widgets": { "heading": "Widgets", diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index f9a4ff3b..b0e7c453 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -4316,7 +4316,10 @@ "ask_ai": "Fragen Sie die KI", "content_input_placeholder": "Fragen Sie nach diesem Inhalt…", "ask_ai_expand_aria": "Öffnen Sie „Ask AI Input“.", - "ask_ai_input_aria": "Fragen Sie AI nach diesem Inhalt" + "ask_ai_input_aria": "Fragen Sie AI nach diesem Inhalt", + "upload_error_file_too_large": "Die Datei ist zu groß. Die maximale Größe beträgt 5 MB.", + "upload_error_unsupported_type": "Dateityp wird nicht unterstützt. Bitte laden Sie eine PDF-, DOCX-, PPTX- oder ODT-Datei hoch.", + "upload_error_generic": "Das Hochladen des Dokuments ist fehlgeschlagen. Bitte versuchen Sie es erneut." }, "widgets": { "heading": "Widgets", diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index aae52c0b..1512892a 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -4613,7 +4613,10 @@ "ask_ai_expand_aria": "Open Ask AI input", "ask_ai_input_aria": "Ask AI about this content", "quote_lesson_note": "Quote in assistant", - "attach_document": "Attach a document (PDF, DOCX, PPTX)", + "attach_document": "Attach a document (PDF, DOCX, PPTX, ODT)", + "upload_error_file_too_large": "File is too large. Maximum size is 5 MB.", + "upload_error_unsupported_type": "File type not supported. Please upload a PDF, DOCX, PPTX, or ODT file.", + "upload_error_generic": "Failed to upload document. Please try again.", "upgrade_to_upload": "Upgrade to upload documents", "tokens_exhausted": "Monthly AI token limit reached.", "tokens_exhausted_buy_more": "Buy more", diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index 0ace87e4..9041ba7f 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -4316,7 +4316,10 @@ "ask_ai": "Pregúntale a la IA", "content_input_placeholder": "Pregunta por este contenido...", "ask_ai_expand_aria": "Abrir Preguntar entrada a AI", - "ask_ai_input_aria": "Pregúntale a AI sobre este contenido" + "ask_ai_input_aria": "Pregúntale a AI sobre este contenido", + "upload_error_file_too_large": "El archivo es demasiado grande. El tamaño máximo es 5 MB.", + "upload_error_unsupported_type": "Tipo de archivo no compatible. Cargue un archivo PDF, DOCX, PPTX u ODT.", + "upload_error_generic": "No se pudo cargar el documento. Por favor inténtalo de nuevo." }, "widgets": { "heading": "widgets", diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 0504da8e..05dfbf9a 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -4316,7 +4316,10 @@ "ask_ai": "Demandez à l'IA", "content_input_placeholder": "Renseignez-vous sur ce contenu…", "ask_ai_expand_aria": "Ouvrir la saisie de Ask AI", - "ask_ai_input_aria": "Interrogez AI à propos de ce contenu" + "ask_ai_input_aria": "Interrogez AI à propos de ce contenu", + "upload_error_file_too_large": "Le fichier est trop volumineux. La taille maximale est de 5 Mo.", + "upload_error_unsupported_type": "Type de fichier non pris en charge. Veuillez télécharger un fichier PDF, DOCX, PPTX ou ODT.", + "upload_error_generic": "Échec du téléchargement du document. Veuillez réessayer." }, "widgets": { "heading": "Widgets", diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index f3f35dad..9a9ecef3 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -4316,7 +4316,10 @@ "ask_ai": "एआई से पूछें", "content_input_placeholder": "इस सामग्री के बारे में पूछें...", "ask_ai_expand_aria": "एआई इनपुट पूछें खोलें", - "ask_ai_input_aria": "इस सामग्री के बारे में AI से पूछें" + "ask_ai_input_aria": "इस सामग्री के बारे में AI से पूछें", + "upload_error_file_too_large": "फ़ाइल बहुत बड़ी है। अधिकतम आकार 5 एमबी है.", + "upload_error_unsupported_type": "फ़ाइल प्रकार समर्थित नहीं है. कृपया एक PDF, DOCX, PPTX, या ODT फ़ाइल अपलोड करें।", + "upload_error_generic": "दस्तावेज़ अपलोड करने में विफल. कृपया पुन: प्रयास करें।" }, "widgets": { "heading": "विजेट्स", diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 78a2e24e..5a313166 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -4316,7 +4316,10 @@ "ask_ai": "Zapytaj AI", "content_input_placeholder": "Zapytaj o tę treść…", "ask_ai_expand_aria": "Otwórz wejście Zapytaj AI", - "ask_ai_input_aria": "Zapytaj AI o tę treść" + "ask_ai_input_aria": "Zapytaj AI o tę treść", + "upload_error_file_too_large": "Plik jest za duży. Maksymalny rozmiar to 5 MB.", + "upload_error_unsupported_type": "Typ pliku nie jest obsługiwany. Prześlij plik PDF, DOCX, PPTX lub ODT.", + "upload_error_generic": "Nie udało się przesłać dokumentu. Spróbuj ponownie." }, "widgets": { "heading": "Widżety", diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index 9b9be200..da48ef27 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -4316,7 +4316,10 @@ "ask_ai": "Pergunte à IA", "content_input_placeholder": "Pergunte sobre este conteúdo…", "ask_ai_expand_aria": "Abra a entrada Ask AI", - "ask_ai_input_aria": "Pergunte à IA sobre este conteúdo" + "ask_ai_input_aria": "Pergunte à IA sobre este conteúdo", + "upload_error_file_too_large": "O arquivo é muito grande. O tamanho máximo é 5 MB.", + "upload_error_unsupported_type": "Tipo de arquivo não suportado. Faça upload de um arquivo PDF, DOCX, PPTX ou ODT.", + "upload_error_generic": "Falha ao carregar o documento. Por favor, tente novamente." }, "widgets": { "heading": "Widgets", diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index 0513234e..1cd2780d 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -4316,7 +4316,10 @@ "ask_ai": "Спросите ИИ", "content_input_placeholder": "Спросите об этом контенте…", "ask_ai_expand_aria": "Открыть вход Ask AI", - "ask_ai_input_aria": "Спросите ИИ об этом контенте" + "ask_ai_input_aria": "Спросите ИИ об этом контенте", + "upload_error_file_too_large": "Файл слишком большой. Максимальный размер — 5 МБ.", + "upload_error_unsupported_type": "Тип файла не поддерживается. Загрузите файл PDF, DOCX, PPTX или ODT.", + "upload_error_generic": "Не удалось загрузить документ. Пожалуйста, попробуйте еще раз." }, "widgets": { "heading": "Виджеты", diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 080e5178..bde6e351 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -4316,7 +4316,10 @@ "ask_ai": "Hỏi AI", "content_input_placeholder": "Hỏi về nội dung này…", "ask_ai_expand_aria": "Mở đầu vào Hỏi AI", - "ask_ai_input_aria": "Hỏi AI về nội dung này" + "ask_ai_input_aria": "Hỏi AI về nội dung này", + "upload_error_file_too_large": "Tệp quá lớn. Kích thước tối đa là 5 MB.", + "upload_error_unsupported_type": "Loại tệp không được hỗ trợ. Vui lòng tải lên tệp PDF, DOCX, PPTX hoặc ODT.", + "upload_error_generic": "Không thể tải tài liệu lên. Vui lòng thử lại." }, "widgets": { "heading": "Widget", diff --git a/apps/jobs/src/bootstrap.ts b/apps/jobs/src/bootstrap.ts index ac6504e9..35447f0f 100644 --- a/apps/jobs/src/bootstrap.ts +++ b/apps/jobs/src/bootstrap.ts @@ -3,6 +3,7 @@ import 'dotenv/config'; import { env } from './config/env'; import { warnIfFfmpegMissing } from './utils/ffmpeg'; import { log } from './utils/logger'; +import { connectRedis } from '@cio/core/utils/redis/redis'; void warnIfFfmpegMissing(); @@ -10,6 +11,8 @@ if (!env.REDIS_URL) { throw new Error('REDIS_URL is required to start a worker process.'); } +void connectRedis(); + log.info('worker-bootstrap', { nodeEnv: env.NODE_ENV, pid: process.pid, diff --git a/packages/ai-assistant/src/prompt/teacher.ts b/packages/ai-assistant/src/prompt/teacher.ts index 93276a5d..b76d0c14 100644 --- a/packages/ai-assistant/src/prompt/teacher.ts +++ b/packages/ai-assistant/src/prompt/teacher.ts @@ -279,6 +279,8 @@ If this conversation contains any successful \`fetch_documentation_url\` tool re When any \`fetch_documentation_url\` results exist in this conversation, **every** lesson you create or update via \`update_lesson_content\` MUST end with a References section so the teacher can verify your sourcing. Skip the References section ONLY when zero docs have been fetched in the entire conversation. +**Uploaded documents do NOT count as fetched documentation for the purposes of References.** If the only source material is an uploaded document (no \`fetch_documentation_url\` calls were made), omit the References section entirely — do not fabricate URLs or cite the document by a made-up link. + Format the section as the last block of the lesson HTML, in this exact shape: \`\`\`html @@ -431,11 +433,30 @@ export function buildTeacherContextMessage( contextLines.push(exerciseInfo); } if (context.documentText) { + const docAssets = context.documentAssets ?? []; + const assetLines = docAssets + .filter((d) => d.assetId) + .map((d) => `- documentId: ${d.documentId}, fileName: ${d.fileName}`) + .join('\n'); + + const courseMaterialsInstruction = + assetLines.length > 0 + ? `\n\nWhen implementing a course plan, create a lesson titled **"Course Materials"** as the **first lesson of the first section**. Give it no text content. Then immediately call \`attach_document_to_lesson\` for each document listed below to attach the original file to that lesson:\n${assetLines}` + : ''; + contextLines.push( - `The teacher has uploaded a document. Use this content as the source material for course planning and content generation:\n\n\n${context.documentText}\n` + `The teacher has uploaded a document. Use this content as the source material for course planning and content generation:${courseMaterialsInstruction}\n\n\n${context.documentText}\n` ); } - if (context.existingSectionCount && context.existingSectionCount > 0) { + if (context.isContentGroupingEnabled === false) { + contextLines.push( + `**Content grouping is DISABLED for this course.** Lessons and exercises are displayed as a flat ordered list — sections are invisible to students and teachers. Follow these rules strictly:\n` + + `- Do NOT plan or create multiple sections. Use exactly ONE section as a technical container for all content.\n` + + `- In \`generate_course_plan\`, output a single section whose title matches the course title. Place all lessons and exercises as items inside that one section, ordered sequentially (order 0, 1, 2, …).\n` + + `- When implementing a plan, create that one section first, then add every lesson and exercise into it with strictly ascending \`order\` values (0, 1, 2, …). The order field is the only thing that controls the visible sequence.\n` + + `- Never call \`create_section\` more than once for this course.` + ); + } else if (context.existingSectionCount && context.existingSectionCount > 0) { contextLines.push( `This course already has ${context.existingSectionCount} sections. When creating new sections, set their order values starting after the existing sections.` ); diff --git a/packages/ai-assistant/src/types.ts b/packages/ai-assistant/src/types.ts index 134f53fb..5d627132 100644 --- a/packages/ai-assistant/src/types.ts +++ b/packages/ai-assistant/src/types.ts @@ -43,7 +43,11 @@ export interface AgentContext { exerciseTitle?: string; documentId?: string; documentText?: string; + /** Metadata for documents attached to this conversation — used to attach originals to lessons. */ + documentAssets?: { documentId: string; assetId: string | null; fileName: string }[]; existingSectionCount?: number; + /** When false, content grouping is disabled: lessons/exercises are displayed as a flat ordered list and sections are hidden from students. */ + isContentGroupingEnabled?: boolean; } // ─── Course Plan (generated by the agent in Plan Mode) ─────────────────────── @@ -215,11 +219,12 @@ export const MAX_STEPS_PER_ROUND = 15; export const SUPPORTED_DOCUMENT_TYPES = [ 'application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.text' ] as const; /** Supported file extensions for document upload */ -export const SUPPORTED_DOCUMENT_EXTENSIONS = ['.pdf', '.docx', '.pptx'] as const; +export const SUPPORTED_DOCUMENT_EXTENSIONS = ['.pdf', '.docx', '.pptx', '.odt'] as const; /** Token cost estimation heuristics (tokens per item) */ export const TOKEN_COST_ESTIMATES = { diff --git a/packages/core/src/services/agent/agent-tool-schemas.ts b/packages/core/src/services/agent/agent-tool-schemas.ts index a297f8a2..47064281 100644 --- a/packages/core/src/services/agent/agent-tool-schemas.ts +++ b/packages/core/src/services/agent/agent-tool-schemas.ts @@ -42,6 +42,10 @@ export const createLessonParam = z.object({ title: z.string().min(1), order: z.number().int().min(0) }); +export const attachDocumentToLessonParam = z.object({ + lessonId: z.string().describe('ID of the lesson to attach the document to'), + documentId: z.string().describe('documentId of the uploaded document (from the conversation context)') +}); export const updateLessonParam = z .object({ lessonId: z.string(), diff --git a/packages/core/src/services/agent/chat-context.ts b/packages/core/src/services/agent/chat-context.ts index 66ec9513..4995b071 100644 --- a/packages/core/src/services/agent/chat-context.ts +++ b/packages/core/src/services/agent/chat-context.ts @@ -1,7 +1,12 @@ import { AppError } from '@cio/utils/errors'; import { getDocumentText } from './document'; import { redis } from '../../utils/redis/redis'; -import { getCourseSectionBinding, getExerciseCourseBinding, getLessonCourseBinding } from '@cio/db/queries/agent'; +import { + getCourseSectionBinding, + getExerciseCourseBinding, + getLessonCourseBinding, + getChatDocumentsByIds +} from '@cio/db/queries/agent'; import { z } from 'zod'; import { CoursePlanFieldsSchema, type CourseTemplateId } from '@cio/ai-assistant'; @@ -113,6 +118,16 @@ export function collectDocumentIds(messages: unknown[], currentDocumentId?: stri return Array.from(ids); } +export async function loadDocumentsMeta( + documentIds: string[] +): Promise<{ documentId: string; assetId: string | null; fileName: string }[]> { + if (documentIds.length === 0) return []; + + const records = await getChatDocumentsByIds(documentIds); + + return records.map((r) => ({ documentId: r.id, assetId: r.assetId, fileName: r.fileName })); +} + export async function loadDocumentsText(documentIds: string[], userId: string): Promise { const loaded = await Promise.all( documentIds.map(async (id) => { diff --git a/packages/core/src/services/agent/chat-tools.ts b/packages/core/src/services/agent/chat-tools.ts index 952aa821..284e17b4 100644 --- a/packages/core/src/services/agent/chat-tools.ts +++ b/packages/core/src/services/agent/chat-tools.ts @@ -44,6 +44,7 @@ import { coursePlanParam, createExerciseParam, createExerciseSectionParam, + attachDocumentToLessonParam, createLessonParam, createSectionParam, emptyParam, @@ -61,6 +62,7 @@ import { updateQuestionsParam, updateSectionParam } from './agent-tool-schemas'; +import { attachAssetService } from '../assets/assets'; import { fetchDocumentationUrl } from './fetch-url'; const DURABLE_AGENT_TOOL_NAMES = new Set([ @@ -69,6 +71,7 @@ const DURABLE_AGENT_TOOL_NAMES = new Set([ 'create_lesson', 'update_lesson', 'update_lesson_content', + 'attach_document_to_lesson', 'create_exercise', 'update_exercise', 'update_exercise_section', @@ -89,6 +92,8 @@ export interface BuildAgentToolsOptions { * premium-only question types. */ isOrgOnPaidPlan?: boolean; + /** Documents uploaded in this conversation — used by attach_document_to_lesson. */ + documentAssets?: { documentId: string; assetId: string | null; fileName: string }[]; } function summarizeAgentDebugValue(value: unknown, depth = 0): unknown { @@ -451,6 +456,7 @@ export function buildAgentTools( options: BuildAgentToolsOptions = {} ) { const isOrgOnPaidPlan = options.isOrgOnPaidPlan ?? true; + const documentAssets = options.documentAssets ?? []; const executeAgentTool = ( toolName: string, @@ -611,6 +617,44 @@ export function buildAgentTools( } }), + attach_document_to_lesson: tool({ + description: + 'Attach an uploaded document from this conversation to a lesson as a downloadable material. Use this to create the Course Materials lesson — create the lesson first (with no content), then call this tool.', + inputSchema: attachDocumentToLessonParam, + execute: async (args) => { + return executeAgentTool('attach_document_to_lesson', { orgId, userId, courseId, args }, async () => { + await verifyLessonBelongsToCourse(args.lessonId, courseId); + + const docAsset = documentAssets.find((d) => d.documentId === args.documentId); + + if (!docAsset) { + throw new AppError( + `Document "${args.documentId}" not found in this conversation`, + 'DOCUMENT_NOT_FOUND', + 404 + ); + } + + if (!docAsset.assetId) { + throw new AppError( + `Document "${args.documentId}" has no associated asset and cannot be attached`, + 'DOCUMENT_NO_ASSET', + 422 + ); + } + + await attachAssetService(orgId, docAsset.assetId, userId, { + targetType: 'lesson', + targetId: args.lessonId, + slotType: 'lesson_document', + position: 0 + }); + + return { lessonId: args.lessonId, fileName: docAsset.fileName, attached: true }; + }); + } + }), + create_exercise: tool({ description: 'Create a new exercise with questions and answer options in this course.', inputSchema: createExerciseParam, diff --git a/packages/core/src/services/agent/course-generation-runner.ts b/packages/core/src/services/agent/course-generation-runner.ts index 37ee063e..beb22bac 100644 --- a/packages/core/src/services/agent/course-generation-runner.ts +++ b/packages/core/src/services/agent/course-generation-runner.ts @@ -20,6 +20,7 @@ import { collectDocumentIds, getActiveCourseTemplateId, getLatestImplementationPlan, + loadDocumentsMeta, loadDocumentsText } from './chat-context'; import { buildAgentTools } from './chat-tools'; @@ -145,7 +146,10 @@ export async function runAgentCourseGenerationJob(input: RunAgentCourseGeneratio : null; const visibleMessages = (conversation?.messages ?? []) as Array>; const documentIds = collectDocumentIds(visibleMessages); - const documentText = documentIds.length > 0 ? await loadDocumentsText(documentIds, initialRun.userId) : undefined; + const [documentText, documentAssets] = await Promise.all([ + documentIds.length > 0 ? loadDocumentsText(documentIds, initialRun.userId) : Promise.resolve(undefined), + documentIds.length > 0 ? loadDocumentsMeta(documentIds) : Promise.resolve([]) + ]); const existingSections = await listCourseSections(initialRun.courseId); const executionCursor = initialRun.executionCursor ?? {}; const modelId = getRunModelId(executionCursor); @@ -166,6 +170,7 @@ export async function runAgentCourseGenerationJob(input: RunAgentCourseGeneratio role: AgentRole.TEACHER, locale: 'en', documentText, + documentAssets, existingSectionCount: existingSections.length }; const approvedPlan = initialRun.approvedPlan ?? getLatestImplementationPlan(visibleMessages); @@ -208,7 +213,8 @@ export async function runAgentCourseGenerationJob(input: RunAgentCourseGeneratio const isOrgPaid = await isOrgOnPaidPlan(initialRun.orgId); const agentTools = buildAgentTools(initialRun.orgId, initialRun.userId, initialRun.courseId, visibleMessages, { runId: initialRun.id, - isOrgOnPaidPlan: isOrgPaid + isOrgOnPaidPlan: isOrgPaid, + documentAssets }); for (let roundIndex = 0; roundIndex < MAX_WORKER_ROUNDS; roundIndex += 1) { diff --git a/packages/core/src/services/agent/document.ts b/packages/core/src/services/agent/document.ts index f07fd134..ca66c029 100644 --- a/packages/core/src/services/agent/document.ts +++ b/packages/core/src/services/agent/document.ts @@ -18,7 +18,7 @@ import { createAssetFromUploadService } from '../assets/assets'; /** * Parse an uploaded document, store extracted text in Redis (hot cache) and Postgres - * (durable, scoped to a conversation). Supports PDF, DOCX, and PPTX files. + * (durable, scoped to a conversation). Supports PDF, DOCX, PPTX, and ODT files. */ export async function parseAndStoreDocument( file: File, @@ -56,6 +56,9 @@ export async function parseAndStoreDocument( case 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ({ text: extractedText, pageCount } = await extractPptxText(buffer)); break; + case 'application/vnd.oasis.opendocument.text': + extractedText = await extractOdtText(buffer); + break; default: throw new AppError('Unsupported file type', 'UNSUPPORTED_FILE_TYPE', 415); } @@ -197,6 +200,32 @@ async function extractDocxText(buffer: Buffer): Promise { return result.value; } +async function extractOdtText(buffer: Buffer): Promise { + try { + const JSZip = (await import('jszip')).default; + const zip = await JSZip.loadAsync(buffer); + + const contentFile = zip.files['content.xml']; + + if (!contentFile) { + throw new Error('content.xml not found in ODT archive'); + } + + const xml = await contentFile.async('text'); + const text = xml + .replace(/]*\/?>/g, '\n') + .replace(/<\/text:p>/g, '\n') + .replace(/<\/text:h>/g, '\n') + .replace(/<[^>]+>/g, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + return text; + } catch { + throw new AppError('Failed to parse ODT file', 'ODT_PARSE_ERROR', 422); + } +} + async function extractPptxText(buffer: Buffer): Promise<{ text: string; pageCount: number }> { // pptx-parser may not be available yet — use a simpler approach // For now, try to use officegen or a basic XML extraction