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:
@@ -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(),
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
const loaded = await Promise.all(
|
||||
documentIds.map(async (id) => {
|
||||
|
||||
@@ -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 = <TArgs, TResult>(
|
||||
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,
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
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) {
|
||||
|
||||
@@ -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<string> {
|
||||
return result.value;
|
||||
}
|
||||
|
||||
async function extractOdtText(buffer: Buffer): Promise<string> {
|
||||
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(/<text:line-break[^>]*\/?>/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
|
||||
|
||||
Reference in New Issue
Block a user