feat(ai-assistant): odt support, document-to-lesson attach, upload UX fixes

- 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
This commit is contained in:
Rotimi Best
2026-06-29 12:29:47 +01:00
parent 773fc873c2
commit 65420320e7
24 changed files with 235 additions and 27 deletions
+12 -3
View File
@@ -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,
+4
View File
@@ -331,6 +331,10 @@ export async function resumeAgentRunState(runId: string, userId: string, orgId:
export async function retryAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
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);
}