From 91908f591c536df23d14893234a90993b14ba088 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Tue, 8 Apr 2025 20:56:03 -0700 Subject: [PATCH 01/20] frontend for SaveToChatbot for anytime questions. Also re-arranged some things, and added some QoL so now if you click off your answer won't be reset (and changed the 'clear' button to a 'revert') --- .../components/modals/PostResponseModal.tsx | 115 ++++++++++++------ 1 file changed, 81 insertions(+), 34 deletions(-) diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/PostResponseModal.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/PostResponseModal.tsx index 3760bebde..2c6c5ad2e 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/PostResponseModal.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/PostResponseModal.tsx @@ -13,7 +13,12 @@ import { import { AsyncQuestion, asyncQuestionStatus } from '@koh/common' import { getErrorMessage } from '@/app/utils/generalUtils' import { API } from '@/app/api' -import { DeleteOutlined, QuestionCircleOutlined } from '@ant-design/icons' +import { + DeleteOutlined, + QuestionCircleOutlined, + RedoOutlined, + RollbackOutlined, +} from '@ant-design/icons' import { deleteAsyncQuestion } from '../../utils/commonAsyncFunctions' interface FormValues { @@ -38,6 +43,7 @@ const PostResponseModal: React.FC = ({ const [form] = Form.useForm() const [isLoading, setIsLoading] = useState(false) const [deleteLoading, setDeleteLoading] = useState(false) + const [saveToChatbot, setSaveToChatbot] = useState(true) const onFinish = async (values: FormValues) => { setIsLoading(true) @@ -59,6 +65,7 @@ const PostResponseModal: React.FC = ({ visible: values.visible, status: newStatus, verified: values.verified, + saveToChatbot: saveToChatbot, }) .then(() => { message.success('Response Successfully Posted/Edited') @@ -77,8 +84,19 @@ const PostResponseModal: React.FC = ({ = ({ onCancel={onCancel} // display delete button for mobile in footer footer={(_, { OkBtn, CancelBtn }) => ( -
- trigger.parentNode as HTMLElement} - okButtonProps={{ loading: deleteLoading }} - onConfirm={async () => { - setDeleteLoading(true) - await deleteAsyncQuestion(question.id, true, onPostResponse) - setDeleteLoading(false) - }} - > - - -
+
+
+ trigger.parentNode as HTMLElement} + okButtonProps={{ loading: deleteLoading }} + onConfirm={async () => { + setDeleteLoading(true) + await deleteAsyncQuestion(question.id, true, onPostResponse) + setDeleteLoading(false) + }} + > + + +
+
+ setSaveToChatbot(e.target.checked)} + // checkboxes will automatically put its children into a span with some padding, so this targets it to get rid of the padding + className="[&>span]:!pr-0" + > + + + Save to Chatbot + + + +
)} @@ -123,7 +159,7 @@ const PostResponseModal: React.FC = ({ visible: question.visible, verified: question.verified, }} - clearOnDestroy + // clearOnDestroy onFinish={(values) => onFinish(values)} > {dom} @@ -138,25 +174,36 @@ const PostResponseModal: React.FC = ({ + + + ), + }} + onClear={() => { + form.setFieldsValue({ + answerText: question.answerText, + }) + }} /> - Set question visible to all students - - - -
- } + tooltip="Questions can normally only be seen by staff and the student who asked it. This will make it visible to all students (the student themselves will appear anonymous to other students)" + label="Set question visible to all students" valuePropName="checked" + layout="horizontal" > - - Mark as verified by faculty + + ) From aec67c4bd37a2c883d36887a3d3b59a31d3dbea2 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Tue, 8 Apr 2025 23:16:39 -0700 Subject: [PATCH 02/20] added backend for upserting async questions as chatbot doc chunks. Also some other adjustments --- packages/common/index.ts | 36 +++++---- .../settings/chatbot_document_chunks/page.tsx | 74 ++++++++++++++++--- .../components/EditChatbotQuestionModal.tsx | 6 +- .../settings/chatbot_settings/page.tsx | 1 + .../asyncQuestion/asyncQuestion.controller.ts | 21 ++++-- .../src/asyncQuestion/asyncQuestion.module.ts | 17 ++++- .../asyncQuestion/asyncQuestion.service.ts | 37 +++++++++- .../server/src/chatbot/chatbot-api.service.ts | 1 + 8 files changed, 153 insertions(+), 40 deletions(-) diff --git a/packages/common/index.ts b/packages/common/index.ts index 7c25eb705..6c8a798b4 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -298,16 +298,25 @@ interface Loc { pageNumber: number } +export interface ChatbotChunkMetadata { + name: string // name when cited (e.g. "L2 Slides") + type: string // "inserted_question", "inserted_async_question", etc. + source?: string // source url + loc?: Loc + id?: string + courseId?: number + // below only added in chunks from April 2025 onwards + firstInsertedAt?: Date + lastUpdatedAt?: Date + shouldProbablyKeepWhenCloning?: boolean + asyncQuestionId?: number // inserted async questions only +} + // source document return type (from chatbot db) +// Actually this is kinda messy. It's supposed to just be a chunk, not a full document, but it's also used for the full documents on the frontend. TODO: maybe refactor that a little export interface SourceDocument { id?: string - metadata?: { - loc?: Loc - name: string - type?: string - source?: string - courseId?: string - } + metadata?: ChatbotChunkMetadata type?: string // TODO: is it content or pageContent? since this file uses both. EDIT: It seems to be both/either. Gross. content?: string @@ -358,14 +367,7 @@ export interface ChatbotAskSuggestedParams { export interface AddDocumentChunkParams { documentText: string - metadata: { - name: string - type: string - source?: string - loc?: Loc - id?: string - courseId?: number - } + metadata: ChatbotChunkMetadata } export interface UpdateChatbotQuestionParams { @@ -848,6 +850,10 @@ export class AsyncQuestionParams { @IsOptional() @IsInt() votesSum?: number + + @IsOptional() + @IsBoolean() + saveToChatbot?: boolean } export class AsyncQuestionVotes { @IsOptional() diff --git a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_document_chunks/page.tsx b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_document_chunks/page.tsx index 61a4952ff..1450916e0 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_document_chunks/page.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_document_chunks/page.tsx @@ -10,20 +10,22 @@ import EditDocumentChunkModal from './components/EditChatbotDocumentChunkModal' import { AddDocumentChunkParams, SourceDocument } from '@koh/common' import { API } from '@/app/api' import ChunkHelpTooltip from './components/ChunkHelpTooltip' +import { formatDateAndTimeForExcel } from '@/app/utils/timeFormatUtils' interface FormValues { content: string + name: string source: string pageNumber: string } -interface ChatbotDocumentsProps { +interface ChatbotDocumentChunksProps { params: { cid: string } } -export default function ChatbotDocuments({ +export default function ChatbotDocumentChunks({ params, -}: ChatbotDocumentsProps): ReactElement { +}: ChatbotDocumentChunksProps): ReactElement { const courseId = Number(params.cid) const [documents, setDocuments] = useState([]) const [filteredDocuments, setFilteredDocuments] = useState( @@ -36,17 +38,23 @@ export default function ChatbotDocuments({ const [editRecordModalOpen, setEditRecordModalOpen] = useState(false) const [form] = Form.useForm() const [addDocChunkPopupVisible, setAddDocChunkPopupVisible] = useState(false) + const [dataLoading, setDataLoading] = useState(false) const addDocument = async (values: FormValues) => { + const now = new Date() const body: AddDocumentChunkParams = { documentText: values.content, metadata: { - name: 'Manually Inserted Information', + name: values.name ?? 'Manually Inserted Info', type: 'inserted_document', source: values.source ?? undefined, loc: values.pageNumber ? { pageNumber: parseInt(values.pageNumber) } : undefined, + shouldProbablyKeepWhenCloning: true, + courseId: courseId, + firstInsertedAt: now, + lastUpdatedAt: now, }, } await API.chatbot.staffOnly @@ -62,6 +70,7 @@ export default function ChatbotDocuments({ } const fetchDocuments = useCallback(async () => { + setDataLoading(true) await API.chatbot.staffOnly .getAllDocumentChunks(courseId) .then((response) => { @@ -76,7 +85,10 @@ export default function ChatbotDocuments({ const errorMessage = getErrorMessage(e) message.error('Failed to load documents: ' + errorMessage) }) - }, [courseId, setDocuments, setFilteredDocuments]) + .finally(() => { + setDataLoading(false) + }) + }, [courseId, setDocuments, setFilteredDocuments, setDataLoading]) useEffect(() => { if (courseId) { @@ -84,7 +96,7 @@ export default function ChatbotDocuments({ } }, [courseId, fetchDocuments]) - const columns = [ + const columns: any[] = [ { title: 'Name', dataIndex: ['metadata', 'name'], @@ -105,7 +117,12 @@ export default function ChatbotDocuments({ prefetch={false} rel="noopener noreferrer" > - {text} + ), @@ -131,6 +148,24 @@ export default function ChatbotDocuments({ key: 'pageNumber', width: 40, }, + { + title: 'Created At', + dataIndex: ['metadata', 'firstInsertedAt'], + key: 'firstInsertedAt', + defaultSortOrder: 'descend', + width: 90, + sorter: (a: SourceDocument, b: SourceDocument) => { + const A = a.metadata?.firstInsertedAt + ? new Date(a.metadata.firstInsertedAt).getTime() + : 0 + const B = b.metadata?.firstInsertedAt + ? new Date(b.metadata.firstInsertedAt).getTime() + : 0 + return A - B + }, + render: (firstInsertedAt: Date) => + formatDateAndTimeForExcel(firstInsertedAt), + }, { title: 'Actions', key: 'actions', @@ -190,7 +225,8 @@ export default function ChatbotDocuments({ const searchTerm = e.target.value.toLowerCase() const filtered = documents.filter((doc) => { const isNameMatch = doc.pageContent - ? doc.pageContent.toLowerCase().includes(searchTerm) + ? doc.pageContent.toLowerCase().includes(searchTerm) || + doc.metadata?.name?.toLowerCase().includes(searchTerm) : false return isNameMatch }) @@ -258,11 +294,18 @@ export default function ChatbotDocuments({ > + + + {/* */} - +
- +
{editingRecord && ( = ({ const handleOkInsert = async () => { const values = await form.validateFields() setSaveLoading(true) + const now = new Date() const newChunk: AddDocumentChunkParams = { documentText: values.question + '\nAnswer:' + values.answer, metadata: { - name: 'inserted Q&A', + name: 'Previously Asked Question', type: 'inserted_question', id: editingRecord.vectorStoreId, courseId: cid, + firstInsertedAt: now, + lastUpdatedAt: now, + shouldProbablyKeepWhenCloning: true, }, } await API.chatbot.staffOnly diff --git a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_settings/page.tsx b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_settings/page.tsx index 5e102b5f2..2aca90f24 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_settings/page.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/chatbot_settings/page.tsx @@ -151,6 +151,7 @@ export default function ChatbotSettings({ .getAllAggregateDocuments(courseId) .then((response) => { const formattedDocuments = response.map((doc) => ({ + ...doc, key: doc.id, docId: doc.id, docName: doc.pageContent, diff --git a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts index 0c6b5bd8f..e28d066dd 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts @@ -187,7 +187,7 @@ export class asyncQuestionController { @Patch('student/:questionId') @UseGuards(AsyncQuestionRolesGuard) @Roles(Role.STUDENT, Role.TA, Role.PROFESSOR) // since were letting staff post questions, they might end up calling this endpoint to update their own questions - async updateStudentQuestion( + async updateQuestionStudent( @Param('questionId', ParseIntPipe) questionId: number, @Body() body: UpdateAsyncQuestions, @UserId() userId: number, @@ -282,10 +282,10 @@ export class asyncQuestionController { @Patch('faculty/:questionId') @UseGuards(AsyncQuestionRolesGuard) @Roles(Role.TA, Role.PROFESSOR) - async updateTAQuestion( + async updateQuestionStaff( @Param('questionId', ParseIntPipe) questionId: number, @Body() body: UpdateAsyncQuestions, - @UserId() userId: number, + @User(['chat_token']) user: UserModel, ): Promise { const question = await AsyncQuestionModel.findOne({ where: { id: questionId }, @@ -312,7 +312,7 @@ export class asyncQuestionController { // Verify if user is TA/PROF of the course const requester = await UserCourseModel.findOne({ where: { - userId: userId, + userId: user.id, courseId: courseId, }, }); @@ -331,7 +331,7 @@ export class asyncQuestionController { if (body.status === asyncQuestionStatus.HumanAnswered) { question.closedAt = new Date(); - question.taHelpedId = userId; + question.taHelpedId = user.id; await this.asyncQuestionService.sendQuestionAnsweredEmail(question); } else if ( body.status !== asyncQuestionStatus.TADeleted && @@ -348,12 +348,21 @@ export class asyncQuestionController { const updatedQuestion = await question.save(); + // if saveToChatbot is true, add the question to the chatbot + if (body.saveToChatbot) { + await this.asyncQuestionService.upsertQAToChatbot( + updatedQuestion, + courseId, + user.chat_token.token, + ); + } + // Mark as new unread for all students if the question is marked as visible if (body.visible && !oldQuestion.visible) { await this.asyncQuestionService.markUnreadForRoles( updatedQuestion, [Role.STUDENT], - userId, + user.id, ); } // When the question creator gets their question human verified, notify them diff --git a/packages/server/src/asyncQuestion/asyncQuestion.module.ts b/packages/server/src/asyncQuestion/asyncQuestion.module.ts index c65434c6b..6754facb6 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.module.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.module.ts @@ -5,19 +5,21 @@ import { AsyncQuestionService } from './asyncQuestion.service'; import { MailModule, MailTestingModule } from 'mail/mail.module'; import { RedisQueueService } from '../redisQueue/redis-queue.service'; import { ApplicationConfigService } from '../config/application_config.service'; - +import { ChatbotApiService } from '../chatbot/chatbot-api.service'; @Module({ controllers: [asyncQuestionController], providers: [ AsyncQuestionService, RedisQueueService, ApplicationConfigService, + ChatbotApiService, ], imports: [ NotificationModule, MailModule, RedisQueueService, ApplicationConfigService, + ChatbotApiService, ], exports: [AsyncQuestionService], }) @@ -25,8 +27,17 @@ export class asyncQuestionModule {} @Module({ controllers: [asyncQuestionController], - providers: [AsyncQuestionService, ApplicationConfigService], - imports: [NotificationModule, MailTestingModule, ApplicationConfigService], + providers: [ + AsyncQuestionService, + ApplicationConfigService, + ChatbotApiService, + ], + imports: [ + NotificationModule, + MailTestingModule, + ApplicationConfigService, + ChatbotApiService, + ], exports: [AsyncQuestionService], }) export class asyncQuestionTestingModule {} diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index e0af3026b..e965a9c42 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -1,4 +1,4 @@ -import { MailServiceType, Role } from '@koh/common'; +import { AddDocumentChunkParams, MailServiceType, Role } from '@koh/common'; import { Injectable } from '@nestjs/common'; import { MailService } from 'mail/mail.service'; import { UserSubscriptionModel } from 'mail/user-subscriptions.entity'; @@ -8,10 +8,13 @@ import { UserModel } from 'profile/user.entity'; import { AsyncQuestionCommentModel } from './asyncQuestionComment.entity'; import * as Sentry from '@sentry/nestjs'; import { UnreadAsyncQuestionModel } from './unread-async-question.entity'; - +import { ChatbotApiService } from 'chatbot/chatbot-api.service'; @Injectable() export class AsyncQuestionService { - constructor(private mailService: MailService) {} + constructor( + private mailService: MailService, + private readonly chatbotApiService: ChatbotApiService, + ) {} async sendNewCommentOnMyQuestionEmail( commenter: UserModel, @@ -338,6 +341,34 @@ export class AsyncQuestionService { .execute(); } + async upsertQAToChatbot( + question: AsyncQuestionModel, + courseId: number, + userToken: string, + ) { + const now = new Date(); + // Since the name can take up quite a bit of space, no more than 60 characters (show ... if longer) + const chunkName = `${(question.questionAbstract ?? question.questionText).slice(0, 60)}${(question.questionAbstract ?? question.questionText).length > 60 ? '...' : ''}`; + const chunkParams: AddDocumentChunkParams = { + documentText: `Question: ${question.questionText}\nAnswer: ${question.answerText}`, + metadata: { + name: chunkName, + type: 'inserted_async_question', + asyncQuestionId: question.id, + source: `/course/${courseId}/async_centre`, + courseId: courseId, + firstInsertedAt: now, // note that the chatbot will ignore this field if its an update + lastUpdatedAt: now, + shouldProbablyKeepWhenCloning: true, + }, + }; + await this.chatbotApiService.addDocumentChunk( + chunkParams, + courseId, + userToken, + ); + } + /** * Takes in a userId and async questionId and hashes them to return a random index from ANONYMOUS_ANIMAL_AVATAR.ANIMAL_NAMES * Note that 70 is the length of ANONYMOUS_ANIMAL_AVATAR.ANIMAL_NAMES diff --git a/packages/server/src/chatbot/chatbot-api.service.ts b/packages/server/src/chatbot/chatbot-api.service.ts index ad8b3e37f..fb0da112d 100644 --- a/packages/server/src/chatbot/chatbot-api.service.ts +++ b/packages/server/src/chatbot/chatbot-api.service.ts @@ -173,6 +173,7 @@ export class ChatbotApiService { courseId: number, userToken: string, ) { + // note: will perform an upsert if the body.metadata has an asyncQuestionId, rather than just an insert return this.request('POST', 'documentChunk', courseId, userToken, body); } From c460f508cbafe70855c456ce39e4d56bff8df8d6 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Tue, 8 Apr 2025 23:20:16 -0700 Subject: [PATCH 03/20] hovering over chatbot source links now shows the chunk content --- .../[cid]/components/chatbot/Chatbot.tsx | 54 ++++++++++++------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/Chatbot.tsx b/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/Chatbot.tsx index 76e7ab6ec..70cd2538e 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/Chatbot.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/Chatbot.tsx @@ -483,6 +483,10 @@ const Chatbot: React.FC = ({ ) && ( = ({ = ({ docName, sourceLink, part }) => { + documentText?: string +}> = ({ docName, sourceLink, part, documentText }) => { if (!sourceLink) { return null } const pageNumber = part && !isNaN(part) ? Number(part) : undefined return ( - -

- {part ? `p. ${part}` : 'Source'} -

-
+ +

+ {part ? `p. ${part}` : 'Source'} +

+
+ ) } From 6a600d41c06432ffc5a067ef26e07caaf1d1c277 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Wed, 9 Apr 2025 17:56:14 -0700 Subject: [PATCH 04/20] improved file interceptors for upload pfp endpoint --- .../server/src/profile/profile.controller.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/server/src/profile/profile.controller.ts b/packages/server/src/profile/profile.controller.ts index 1cc8ae375..cef5f3fcc 100644 --- a/packages/server/src/profile/profile.controller.ts +++ b/packages/server/src/profile/profile.controller.ts @@ -107,6 +107,36 @@ export class ProfileController { @UseInterceptors( FileInterceptor('file', { storage: memoryStorage(), + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit per file + }, + fileFilter: (req, file, cb) => { + // Check mimetype + if (!file.mimetype.startsWith('image/')) { + cb(new Error('Only image files are allowed'), false); + return; + } + // Check file extension + const allowedExtensions = [ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.bmp', + '.svg', + '.tiff', + '.gif', + ]; + const fileExt = file.originalname + .toLowerCase() + .substring(file.originalname.lastIndexOf('.')); + if (!allowedExtensions.includes(fileExt)) { + cb(new Error('Only image files are allowed'), false); + return; + } + cb(null, true); + }, }), ) async uploadImage( From 72c82f3a8d558a8d9dad14a0899c7161195f8b83 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Fri, 11 Apr 2025 18:46:00 -0700 Subject: [PATCH 05/20] added a space check for chatbot document upload --- packages/server/src/chatbot/chatbot.controller.ts | 11 +++++++++++ packages/server/src/profile/profile.service.ts | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/server/src/chatbot/chatbot.controller.ts b/packages/server/src/chatbot/chatbot.controller.ts index e31c1305e..387d09f54 100644 --- a/packages/server/src/chatbot/chatbot.controller.ts +++ b/packages/server/src/chatbot/chatbot.controller.ts @@ -16,6 +16,7 @@ import { InternalServerErrorException, Res, Req, + ServiceUnavailableException, } from '@nestjs/common'; import { ChatbotService } from './chatbot.service'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; @@ -35,6 +36,7 @@ import { AddDocumentChunkParams, ChatbotQuestionResponseChatbotDB, UpdateChatbotQuestionParams, + ERROR_MESSAGES, } from '@koh/common'; import { CourseRolesGuard } from 'guards/course-roles.guard'; import { Roles } from 'decorators/roles.decorator'; @@ -49,6 +51,8 @@ import { CourseModel } from 'course/course.entity'; import { generateHTMLForMarkdownToPDF } from './markdown-to-pdf-styles'; import { ChatbotDocPdfModel } from './chatbot-doc-pdf.entity'; import { Response, Request } from 'express'; +import checkDiskSpace from 'check-disk-space'; +import path from 'path'; @Controller('chatbot') @UseGuards(JwtAuthGuard, EmailVerifiedGuard) @@ -552,6 +556,13 @@ export class ChatbotController { if (!file) { throw new BadRequestException('No file uploaded'); } + + // Check disk space before proceeding + const spaceLeft = await checkDiskSpace(path.parse(process.cwd()).root); + if (spaceLeft.free < 1_000_000_000) { + throw new ServiceUnavailableException(ERROR_MESSAGES.common.noDiskSpace); + } + const fileExtension = file.originalname.split('.').pop()?.toLowerCase(); // if the file is a text file (including markdown and csv), don't allow sizes over 2 MB (since 4MB of text is actually a lot) diff --git a/packages/server/src/profile/profile.service.ts b/packages/server/src/profile/profile.service.ts index cc969bad4..018b124e8 100644 --- a/packages/server/src/profile/profile.service.ts +++ b/packages/server/src/profile/profile.service.ts @@ -126,8 +126,9 @@ export class ProfileService { // Check disk space before proceeding const spaceLeft = await checkDiskSpace(path.parse(process.cwd()).root); if (spaceLeft.free < 1_000_000_000) { + // 1GB throw new ServiceUnavailableException( - ERROR_MESSAGES.profileController.noDiskSpace, + ERROR_MESSAGES.common.noDiskSpace, ); } From 58b72c084235878ae6e88e13a201d49f9db1d0c0 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Fri, 11 Apr 2025 18:55:22 -0700 Subject: [PATCH 06/20] can now upload images when creating async questions. Also moved the call for getting an AI answer to the backend. Also, the helpme backend will now upload the question with the images to the chatbot backend, which will use ai to generate descriptions for the images, which it then feeds with the rest of the question --- packages/common/index.ts | 23 +- .../modals/CreateAsyncQuestionModal.tsx | 221 ++++++++------- .../components/modals/PostResponseModal.tsx | 2 +- packages/frontend/app/api/index.ts | 2 +- packages/server/ormconfig.ts | 2 + .../asyncQuestion/asyncQuestion.controller.ts | 260 ++++++++++++++---- .../src/asyncQuestion/asyncQuestion.entity.ts | 4 + .../asyncQuestion/asyncQuestion.service.ts | 152 +++++++++- .../asyncQuestionImage.entity.ts | 42 +++ .../server/src/chatbot/chatbot-api.service.ts | 53 +++- 10 files changed, 593 insertions(+), 168 deletions(-) create mode 100644 packages/server/src/asyncQuestion/asyncQuestionImage.entity.ts diff --git a/packages/common/index.ts b/packages/common/index.ts index 6c8a798b4..8cd028d01 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -830,10 +830,6 @@ export class AsyncQuestionParams { @IsString() answerText?: string - @IsOptional() - @IsString() - aiAnswerText?: string - @Type(() => Date) closedAt?: Date @@ -854,6 +850,10 @@ export class AsyncQuestionParams { @IsOptional() @IsBoolean() saveToChatbot?: boolean + + @IsOptional() + @IsBoolean() + refreshAIAnswer?: boolean } export class AsyncQuestionVotes { @IsOptional() @@ -1542,7 +1542,11 @@ export class ResolveGroupParams { queueId!: number } -export class CreateAsyncQuestions extends AsyncQuestionParams {} +export class CreateAsyncQuestions extends AsyncQuestionParams { + @IsOptional() + @IsArray() + images?: any // This will be handled by FormData, so we don't need to specify the type here +} export class UpdateAsyncQuestions extends AsyncQuestionParams {} @@ -2737,6 +2741,8 @@ export enum LMSIntegrationPlatform { export const ERROR_MESSAGES = { common: { pageOutOfBounds: "Can't retrieve out of bounds page.", + noDiskSpace: + 'There is not enough disk space left to store an image (<1GB). Please immediately contact your course staff and let them know. They will contact the HelpMe team as soon as possible.', }, questionService: { getDBClient: 'Error getting DB client', @@ -2952,8 +2958,6 @@ export const ERROR_MESSAGES = { noProfilePicture: "User doesn't have a profile picture", noCoursesToDelete: "User doesn't have any courses to delete", emailInUse: 'Email is already in use', - noDiskSpace: - 'There is no disk space left to store an image. Please immediately contact your course staff and let them know. They will contact the Khoury Office Hours team as soon as possible.', }, alertController: { duplicateAlert: 'This alert has already been sent', @@ -2976,11 +2980,6 @@ export const ERROR_MESSAGES = { publish: 'Publisher client is unable to publish', clientIdNotFound: 'Client ID not found during subscribing to client', }, - resourcesService: { - noDiskSpace: - 'There is no disk space left to store a iCal file. Please immediately contact your course staff and let them know. They will contact the Khoury Office Hours team as soon as possible.', - saveCalError: 'There was an error saving an iCal to disk', - }, questionType: { questionTypeNotFound: 'Question type not found', }, diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx index c14f4f84b..52350e645 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx @@ -8,6 +8,8 @@ import { Tooltip, Button, Popconfirm, + Upload, + Image, } from 'antd' import { useUserInfo } from '@/app/contexts/userContext' import { useQuestionTypes } from '@/app/hooks/useQuestionTypes' @@ -15,15 +17,41 @@ import { QuestionTagSelector } from '../../../components/QuestionTagElement' import { API } from '@/app/api' import { getErrorMessage } from '@/app/utils/generalUtils' import { AsyncQuestion, asyncQuestionStatus } from '@koh/common' -import { DeleteOutlined } from '@ant-design/icons' +import { DeleteOutlined, PlusOutlined } from '@ant-design/icons' import { deleteAsyncQuestion } from '../../utils/commonAsyncFunctions' import { useCourseFeatures } from '@/app/hooks/useCourseFeatures' +import type { GetProp, UploadFile, UploadProps } from 'antd' + +// stuff from antd example code for upload and form +type FileType = Parameters>[0] +const getBase64 = (file: FileType): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = () => resolve(reader.result as string) + reader.onerror = (error) => reject(error) + }) +const UploadButton: React.FC = () => ( + +) +/* I think this is just to make sure the file list is an array */ +const normFile = (e: any) => { + console.log('Upload event:', e) + if (Array.isArray(e)) { + return e + } + return e?.fileList +} interface FormValues { QuestionAbstract: string questionText: string questionTypesInput: number[] refreshAIAnswer: boolean + images: UploadFile[] } interface CreateAsyncQuestionModalProps { @@ -47,32 +75,20 @@ const CreateAsyncQuestionModal: React.FC = ({ const [isLoading, setIsLoading] = useState(false) const [deleteLoading, setDeleteLoading] = useState(false) const courseFeatures = useCourseFeatures(courseId) + const [previewOpen, setPreviewOpen] = useState(false) + const [previewImage, setPreviewImage] = useState('') - const getAiAnswer = async (question: string) => { - if (!courseFeatures?.asyncCentreAIAnswers) { - return '' - } - try { - if (userInfo.chat_token.used < userInfo.chat_token.max_uses) { - const data = { - question: question, - history: [], - onlySaveInChatbotDB: true, - } - const response = await API.chatbot.studentsOrStaff.askQuestion( - courseId, - data, - ) - return response.chatbotRepoVersion.answer - } else { - return 'All AI uses have been used up for today. Please try again tomorrow.' - } - } catch (e) { - return '' + const handlePreviewImage = async (file: UploadFile) => { + if (!file.url && !file.preview) { + file.preview = await getBase64(file.originFileObj as FileType) } + + setPreviewImage(file.url || (file.preview as string)) + setPreviewOpen(true) } const onFinish = async (values: FormValues) => { + console.log(values) setIsLoading(true) const newQuestionTypeInput = values.questionTypesInput && questionTypes @@ -83,86 +99,62 @@ const CreateAsyncQuestionModal: React.FC = ({ // If editing a question, update the question. Else create a new one if (question) { - if (values.refreshAIAnswer) { - await getAiAnswer( - ` - Question Abstract: ${values.QuestionAbstract} - Question Text: ${values.questionText} - Question Types: ${newQuestionTypeInput.map((questionType) => questionType.name).join(', ')} - `, - ).then(async (aiAnswer) => { - await API.asyncQuestions - .studentUpdate(question.id, { - questionTypes: newQuestionTypeInput, - questionText: values.questionText, - questionAbstract: values.QuestionAbstract, - aiAnswerText: aiAnswer, - answerText: aiAnswer, - }) - .then(() => { - message.success('Question Updated') - setIsLoading(false) - onCreateOrUpdateQuestion() - }) - .catch((e) => { - const errorMessage = getErrorMessage(e) - message.error('Error updating question:' + errorMessage) - setIsLoading(false) - }) + await API.asyncQuestions + .studentUpdate(question.id, { + questionTypes: newQuestionTypeInput, + questionText: values.questionText, + questionAbstract: values.QuestionAbstract, + refreshAIAnswer: values.refreshAIAnswer + ? values.refreshAIAnswer + : undefined, + }) + .then(() => { + message.success('Question Updated') + onCreateOrUpdateQuestion() + }) + .catch((e) => { + const errorMessage = getErrorMessage(e) + message.error('Error updating question:' + errorMessage) + }) + .finally(() => { + setIsLoading(false) }) - } else { - await API.asyncQuestions - .studentUpdate(question.id, { - questionTypes: newQuestionTypeInput, - questionText: values.questionText, - questionAbstract: values.QuestionAbstract, - }) - .then(() => { - message.success('Question Updated') - onCreateOrUpdateQuestion() - }) - .catch((e) => { - const errorMessage = getErrorMessage(e) - message.error('Error updating question:' + errorMessage) - }) - .finally(() => { - setIsLoading(false) - }) - } } else { - // since the ai chatbot may not be running, we don't have a catch statement if it fails and instead we just give it a question text of '' - await getAiAnswer( - ` - Question Abstract: ${values.QuestionAbstract} - Question Text: ${values.questionText} - Question Types: ${newQuestionTypeInput.map((questionType) => questionType.name).join(', ')} - `, - ).then(async (aiAnswer) => { - await API.asyncQuestions - .create( - { - questionTypes: newQuestionTypeInput, - questionText: values.questionText, - aiAnswerText: aiAnswer, - answerText: aiAnswer, - questionAbstract: values.QuestionAbstract, - status: courseFeatures?.asyncCentreAIAnswers - ? asyncQuestionStatus.AIAnswered - : asyncQuestionStatus.AIAnsweredNeedsAttention, - }, - courseId, - ) - .then(() => { - message.success('Question Posted') - setIsLoading(false) - onCreateOrUpdateQuestion() - }) - .catch((e) => { - const errorMessage = getErrorMessage(e) - message.error('Error creating question:' + errorMessage) - setIsLoading(false) - }) - }) + // Create FormData for the request + const formData = new FormData() + formData.append('questionText', values.questionText || '') + formData.append('questionAbstract', values.QuestionAbstract) + formData.append('questionTypes', JSON.stringify(newQuestionTypeInput)) + formData.append( + 'status', + courseFeatures?.asyncCentreAIAnswers + ? asyncQuestionStatus.AIAnswered + : asyncQuestionStatus.AIAnsweredNeedsAttention, + ) + + // Append each image file + if (values.images) { + values.images.forEach((file: any) => { + // Only append if it's a real file (antd's Upload component adds some metadata we don't want) + if (file.originFileObj) { + formData.append('images', file.originFileObj) + } + }) + } + + await API.asyncQuestions + .create(formData, courseId) + .then(() => { + message.success('Question Posted') + onCreateOrUpdateQuestion() + }) + .catch((e) => { + const errorMessage = getErrorMessage(e) + message.error('Error creating question:' + errorMessage) + }) + .finally(() => { + setIsLoading(false) + }) } } @@ -271,6 +263,35 @@ const CreateAsyncQuestionModal: React.FC = ({ allowClear /> + + + {form.getFieldValue('images')?.length >= 8 ? null : } + + + {previewImage && ( + setPreviewOpen(visible), + afterOpenChange: (visible) => !visible && setPreviewImage(''), + }} + src={previewImage} + alt={`Preview of ${previewImage}`} + /> + )} {questionTypes && questionTypes.length > 0 && ( = ({ md: '100%', lg: '60%', xl: '50%', - xxl: '40%', + xxl: '35%', }} okButtonProps={{ autoFocus: true, diff --git a/packages/frontend/app/api/index.ts b/packages/frontend/app/api/index.ts index 649e9c058..30b2ceaf1 100644 --- a/packages/frontend/app/api/index.ts +++ b/packages/frontend/app/api/index.ts @@ -514,7 +514,7 @@ class APIClient { asyncQuestions = { get: async (cid: number): Promise => this.req('GET', `/api/v1/asyncQuestions/${cid}`, undefined), - create: async (body: CreateAsyncQuestions, cid: number) => + create: async (body: CreateAsyncQuestions | FormData, cid: number) => this.req( 'POST', `/api/v1/asyncQuestions/${cid}`, diff --git a/packages/server/ormconfig.ts b/packages/server/ormconfig.ts index 9c04e1512..d89b74103 100644 --- a/packages/server/ormconfig.ts +++ b/packages/server/ormconfig.ts @@ -40,6 +40,7 @@ import { LMSAnnouncementModel } from './src/lmsIntegration/lmsAnnouncement.entit import { UnreadAsyncQuestionModel } from './src/asyncQuestion/unread-async-question.entity'; import { AsyncQuestionCommentModel } from './src/asyncQuestion/asyncQuestionComment.entity'; import { ChatbotDocPdfModel } from './src/chatbot/chatbot-doc-pdf.entity'; +import { AsyncQuestionImageModel } from './src/asyncQuestion/asyncQuestionImage.entity'; import { isProd } from '@koh/common'; import * as fs from 'fs'; @@ -115,6 +116,7 @@ const typeorm = { UnreadAsyncQuestionModel, LMSAnnouncementModel, ChatbotDocPdfModel, + AsyncQuestionImageModel, ], keepConnectionAlive: true, logging: diff --git a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts index e28d066dd..f1c1565e6 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts @@ -25,6 +25,10 @@ import { Post, Res, UseGuards, + UseInterceptors, + UploadedFiles, + Query, + BadRequestException, } from '@nestjs/common'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { Roles } from '../decorators/roles.decorator'; @@ -40,10 +44,14 @@ import { CourseRolesGuard } from 'guards/course-roles.guard'; import { AsyncQuestionRolesGuard } from 'guards/async-question-roles.guard'; import { pick } from 'lodash'; import { UserModel } from 'profile/user.entity'; -import { Not } from 'typeorm'; +import { In, Not } from 'typeorm'; import { ApplicationConfigService } from '../config/application_config.service'; -import { AsyncQuestionService } from './asyncQuestion.service'; +import { ChatbotApiService } from 'chatbot/chatbot-api.service'; +import { AsyncQuestionService, tempFile } from './asyncQuestion.service'; import { UnreadAsyncQuestionModel } from './unread-async-question.entity'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { QuestionTypeModel } from 'questionType/question-type.entity'; +import { AsyncQuestionImageModel } from './asyncQuestionImage.entity'; @Controller('asyncQuestions') @UseGuards(JwtAuthGuard, EmailVerifiedGuard) @@ -52,6 +60,7 @@ export class asyncQuestionController { private readonly redisQueueService: RedisQueueService, private readonly appConfig: ApplicationConfigService, private readonly asyncQuestionService: AsyncQuestionService, + private readonly chatbotApiService: ChatbotApiService, ) {} @Post('vote/:qid/:vote') @@ -110,6 +119,7 @@ export class asyncQuestionController { 'comments', 'comments.creator', 'comments.creator.courses', + 'images', ], }); @@ -132,56 +142,137 @@ export class asyncQuestionController { @Post(':cid') @UseGuards(CourseRolesGuard) @Roles(Role.STUDENT, Role.TA, Role.PROFESSOR) // we let staff post questions too since they might want to use the system for demonstration purposes + @UseInterceptors( + FilesInterceptor('images', 8, { + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit per file + }, + fileFilter: (req, file, cb) => { + // Check mimetype (it can be spoofed fyi so we also need to check the file extension) + if (!file.mimetype.startsWith('image/')) { + cb(new Error('Only image files are allowed'), false); + return; + } + // Check file extension + const allowedExtensions = [ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.bmp', + '.svg', + '.tiff', + '.gif', + ]; + const fileExt = file.originalname + .toLowerCase() + .substring(file.originalname.lastIndexOf('.')); + if (!allowedExtensions.includes(fileExt)) { + cb(new Error('Only image files are allowed'), false); + return; + } + cb(null, true); + }, + }), + ) async createQuestion( - @Body() body: CreateAsyncQuestions, + @Body() body: CreateAsyncQuestions | FormData | any, // delete FormData | any for better type checking temporarily @Param('cid', ParseIntPipe) cid: number, - @UserId() userId: number, + @User(['chat_token']) user: UserModel, @Res() res: Response, - ): Promise { - try { - const question = await AsyncQuestionModel.create({ + @UploadedFiles() images?: Express.Multer.File[], + ): Promise { + // the body *should* follow CreateAsyncQuestions, but since we're using formdata, we have to manually parse the fields and validate them + if (body.questionTypes) { + try { + body.questionTypes = JSON.parse(body.questionTypes); + } catch (error) { + throw new BadRequestException( + 'Question Types field must be a valid JSON array', + ); + } + } + if (!body.questionAbstract) { + throw new BadRequestException('Question Abstract is required'); + } + body.questionText = body.questionText ? body.questionText.toString() : ''; + body.questionAbstract = body.questionAbstract + ? body.questionAbstract.toString() + : ''; + body.status = body.status + ? body.status.toString() + : asyncQuestionStatus.AIAnswered; + if (images) { + console.log('images'); + console.log(images); + } + // Convert images to buffers if they exist + let processedImageBuffers: tempFile[] = []; + if (images && images.length > 0) { + processedImageBuffers = + await this.asyncQuestionService.convertAndResizeImages(images); + } + + let aiAnswerText: string | null = null; + if (user.chat_token.used >= user.chat_token.max_uses) { + aiAnswerText = + 'All AI uses have been used up for today. Please try again tomorrow.'; + } else { + const chatbotResponse = await this.chatbotApiService.askQuestion( + ` + Question Abstract: ${body.questionAbstract} + Question Text: ${body.questionText} + Question Types: ${body.questionTypes.map((questionType) => questionType.name).join(', ')} + `, + [], + user.chat_token.token, + cid, + processedImageBuffers.map((result) => result.processedBuffer), // give chatbot the higher-quality, non-preview images + ); + aiAnswerText = chatbotResponse.answer; + } + + const question = await this.asyncQuestionService.createAsyncQuestion( + { courseId: cid, - creatorId: userId, + creatorId: user.id, questionAbstract: body.questionAbstract, questionText: body.questionText || null, - answerText: body.answerText || null, - aiAnswerText: body.aiAnswerText, - questionTypes: body.questionTypes, + answerText: aiAnswerText, // answer text initially becomes the ai answer text + aiAnswerText, + questionTypes: body.questionTypes as QuestionTypeModel[], status: body.status || asyncQuestionStatus.AIAnswered, visible: false, verified: false, createdAt: new Date(), - }).save(); + }, + processedImageBuffers, + ); - const newQuestion = await AsyncQuestionModel.findOne({ - where: { - courseId: cid, - id: question.id, - }, - relations: [ - 'creator', - 'taHelped', - 'votes', - 'comments', - 'comments.creator', - 'comments.creator.courses', - ], - }); + const newQuestion = await AsyncQuestionModel.findOne({ + where: { + courseId: cid, + id: question.id, + }, + relations: [ + 'creator', + 'taHelped', + 'votes', + 'comments', + 'comments.creator', + 'comments.creator.courses', + 'images', + ], + }); - await this.redisQueueService.addAsyncQuestion(`c:${cid}:aq`, newQuestion); - await this.asyncQuestionService.createUnreadNotificationsForQuestion( - newQuestion, - ); + await this.redisQueueService.addAsyncQuestion(`c:${cid}:aq`, newQuestion); + await this.asyncQuestionService.createUnreadNotificationsForQuestion( + newQuestion, + ); - res.status(HttpStatus.CREATED).send(newQuestion); - return; - } catch (err) { - console.error(err); - res - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .send({ message: ERROR_MESSAGES.questionController.saveQError }); - return; - } + res.status(HttpStatus.CREATED).send(newQuestion); + return; } @Patch('student/:questionId') @@ -190,7 +281,7 @@ export class asyncQuestionController { async updateQuestionStudent( @Param('questionId', ParseIntPipe) questionId: number, @Body() body: UpdateAsyncQuestions, - @UserId() userId: number, + @User(['chat_token']) user: UserModel, ): Promise { const question = await AsyncQuestionModel.findOne({ where: { id: questionId }, @@ -200,6 +291,7 @@ export class asyncQuestionController { 'comments', 'comments.creator', 'comments.creator.courses', + 'images', ], }); // deep copy question since it changes @@ -211,7 +303,7 @@ export class asyncQuestionController { throw new NotFoundException('Question Not Found'); } - if (question.creatorId !== userId) { + if (question.creatorId !== user.id) { throw new ForbiddenException('You can only update your own questions'); } // if you created the question (i.e. a student), you can't update the status to illegal ones @@ -237,6 +329,29 @@ export class asyncQuestionController { } }); + if (body.refreshAIAnswer) { + if (user.chat_token.used >= user.chat_token.max_uses) { + question.aiAnswerText = + 'All AI uses have been used up for today. Please try again tomorrow.'; + question.answerText = + 'All AI uses have been used up for today. Please try again tomorrow.'; + } else { + const chatbotResponse = await this.chatbotApiService.askQuestion( + ` + Question Abstract: ${question.questionAbstract} + Question Text: ${question.questionText} + Question Types: ${question.questionTypes.map((questionType) => questionType.name).join(', ')} + `, + [], + user.chat_token.token, + question.courseId, + // TODO: add images support + ); + question.aiAnswerText = chatbotResponse.answer; + question.answerText = chatbotResponse.answer; + } + } + const updatedQuestion = await question.save(); // Mark as new unread for all staff if the question needs attention @@ -247,16 +362,19 @@ export class asyncQuestionController { await this.asyncQuestionService.markUnreadForRoles( updatedQuestion, [Role.TA, Role.PROFESSOR], - userId, + user.id, ); } // if the question is visible and they rewrote their question and got a new answer text, mark it as unread for everyone if ( updatedQuestion.visible && - body.aiAnswerText !== oldQuestion.aiAnswerText && + body.refreshAIAnswer && body.questionText !== oldQuestion.questionText ) { - await this.asyncQuestionService.markUnreadForAll(updatedQuestion, userId); + await this.asyncQuestionService.markUnreadForAll( + updatedQuestion, + user.id, + ); } if (body.status === asyncQuestionStatus.StudentDeleted) { @@ -296,6 +414,7 @@ export class asyncQuestionController { 'comments', 'comments.creator', 'comments.creator.courses', + 'images', ], }); // deep copy question since it changes @@ -435,6 +554,7 @@ export class asyncQuestionController { 'comments', 'comments.creator', 'comments.creator.courses', + 'images', ], }); @@ -553,6 +673,7 @@ export class asyncQuestionController { 'comments', 'comments.creator', 'comments.creator.courses', + 'images', ], }); @@ -630,6 +751,7 @@ export class asyncQuestionController { 'comments', 'comments.creator', 'comments.creator.courses', + 'images', ], }); @@ -665,11 +787,17 @@ export class asyncQuestionController { let all: AsyncQuestionModel[] = []; if (!asyncQuestionKeys || Object.keys(asyncQuestionKeys).length === 0) { - console.log('Fetching from Database'); + console.log('Fetching async questions from Database'); all = await AsyncQuestionModel.find({ where: { courseId, - status: Not(asyncQuestionStatus.StudentDeleted), + // don't include studentDeleted or TADeleted questions + status: Not( + In([ + asyncQuestionStatus.StudentDeleted, + asyncQuestionStatus.TADeleted, + ]), + ), }, relations: [ 'creator', @@ -678,6 +806,7 @@ export class asyncQuestionController { 'comments', 'comments.creator', 'comments.creator.courses', + 'images', ], order: { createdAt: 'DESC', @@ -688,7 +817,7 @@ export class asyncQuestionController { if (all) await this.redisQueueService.setAsyncQuestions(`c:${courseId}:aq`, all); } else { - console.log('Fetching from Redis'); + console.log('Fetching async questions from Redis'); all = Object.values(asyncQuestionKeys).map( (question) => question as AsyncQuestionModel, ); @@ -735,6 +864,7 @@ export class asyncQuestionController { 'questionTypes', 'votesSum', 'isTaskQuestion', + 'images', ]); if (!question.comments) { @@ -852,4 +982,40 @@ export class asyncQuestionController { ); return; } + @Get(':courseId/image/:imageId') + @UseGuards(JwtAuthGuard, CourseRolesGuard) + @Roles(Role.STUDENT, Role.TA, Role.PROFESSOR) + async getImage( + @Param('courseId', ParseIntPipe) courseId: number, // used for guard + @Param('imageId', ParseIntPipe) imageId: number, + @Query('preview') preview: boolean, + @Res() res: Response, + ) { + const image = await this.asyncQuestionService.getImageById( + imageId, + preview, + ); + + if (!image) { + throw new NotFoundException('Image not found'); + } + + // Encode the filename for Content-Disposition + const encodedFilename = encodeURIComponent(image.newFileName) + .replace(/['()]/g, (match) => { + return match === "'" ? '%27' : match === '(' ? '%28' : '%29'; + }) // Replace special chars with percent encoding + .replace(/\*/g, '%2A'); + + // Create filename* parameter with UTF-8 encoding per RFC 5987 + const filenameAsterisk = `UTF-8''${encodedFilename}`; + + res.set({ + 'Content-Type': 'image/webp', + 'Cache-Control': 'public, max-age=1296000', // Cache for 4 months + 'Content-Disposition': `inline; filename="${image.newFileName}"; filename*=${filenameAsterisk}`, + }); + + res.send(image.buffer); + } } diff --git a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts index 665f99fd6..77f04d2d0 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts @@ -19,6 +19,7 @@ import { AsyncQuestionVotesModel } from './asyncQuestionVotes.entity'; import { QuestionTypeModel } from '../questionType/question-type.entity'; import { AsyncQuestionCommentModel } from './asyncQuestionComment.entity'; import { UnreadAsyncQuestionModel } from './unread-async-question.entity'; +import { AsyncQuestionImageModel } from './asyncQuestionImage.entity'; @Entity('async_question_model') export class AsyncQuestionModel extends BaseEntity { @@ -102,4 +103,7 @@ export class AsyncQuestionModel extends BaseEntity { sumVotes() { this.votesSum = this.votes.reduce((acc, vote) => acc + vote.vote, 0); } + + @OneToMany(() => AsyncQuestionImageModel, (image) => image.asyncQuestion) + images: AsyncQuestionImageModel[]; } diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index e965a9c42..a64b6658f 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -1,5 +1,10 @@ -import { AddDocumentChunkParams, MailServiceType, Role } from '@koh/common'; -import { Injectable } from '@nestjs/common'; +import { + AddDocumentChunkParams, + ERROR_MESSAGES, + MailServiceType, + Role, +} from '@koh/common'; +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { MailService } from 'mail/mail.service'; import { UserSubscriptionModel } from 'mail/user-subscriptions.entity'; import { UserCourseModel } from 'profile/user-course.entity'; @@ -9,6 +14,12 @@ import { AsyncQuestionCommentModel } from './asyncQuestionComment.entity'; import * as Sentry from '@sentry/nestjs'; import { UnreadAsyncQuestionModel } from './unread-async-question.entity'; import { ChatbotApiService } from 'chatbot/chatbot-api.service'; +import { AsyncQuestionImageModel } from './asyncQuestionImage.entity'; +import { getManager } from 'typeorm'; +import * as checkDiskSpace from 'check-disk-space'; +import * as path from 'path'; +import * as sharp from 'sharp'; + @Injectable() export class AsyncQuestionService { constructor( @@ -369,6 +380,136 @@ export class AsyncQuestionService { ); } + async createAsyncQuestion( + questionData: Partial, + imageBuffers: tempFile[], + ): Promise { + const startTime = Date.now(); + + // Check disk space before proceeding + const spaceLeft = await checkDiskSpace(path.parse(process.cwd()).root); + if (spaceLeft.free < 1_000_000_000) { + throw new ServiceUnavailableException(ERROR_MESSAGES.common.noDiskSpace); + } + + let question; + // Create the question first + const entityManager = getManager(); + await entityManager.transaction(async (transactionalEntityManager) => { + question = await transactionalEntityManager.save( + AsyncQuestionModel.create(questionData), + ); + + // Process and save images + if (imageBuffers.length > 0) { + const imagePromises = imageBuffers.map(async (buffer) => { + const imageModel = new AsyncQuestionImageModel(); + imageModel.asyncQuestion = question; + imageModel.imageBuffer = buffer.processedBuffer; + imageModel.previewImageBuffer = buffer.previewBuffer; + imageModel.imageSizeBytes = buffer.processedBuffer.length; + imageModel.previewImageSizeBytes = buffer.previewBuffer.length; + + imageModel.originalFileName = buffer.originalFileName; + imageModel.newFileName = buffer.newFileName; + + return transactionalEntityManager.save(imageModel); + }); + + await Promise.all(imagePromises); + } + }); + + const endTime = Date.now(); + const processingTime = endTime - startTime; + + if (processingTime > 10000) { + // more than 10 seconds + console.error(`createAsyncQuestion took too long: ${processingTime}ms`); + } + + return question; + } + + async convertAndResizeImages( + images: Express.Multer.File[], + ): Promise { + const startTime = Date.now(); + const results = await Promise.all( + images.map(async (image) => { + // create both full and preview versions (the preview version is much smaller), they all get converted to webp + const [processedBuffer, previewBuffer] = await Promise.all([ + sharp(image.buffer) + .resize(1920, 1080, { + fit: 'inside', // resize to fit within 1920x1080, but keep aspect ratio + withoutEnlargement: true, // don't enlarge the image + }) + .webp({ quality: 80 }) // convert to webp with quality 80 + .toBuffer(), + sharp(image.buffer) // preview images get sent first so they need to be low quality so they get sent fast. + .resize(400, 300, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 30 }) + .toBuffer(), + ]); + + // Remove the original extension and replace with .webp + const sanitizedFilename = + image.originalname + .replace(/[/\\?%*:|"<>]/g, '_') // Replace unsafe characters with underscores + .replace(/\.[^/.]+$/, '') // Remove the original extension + .trim() + '.webp'; // Add .webp extension + + return { + processedBuffer, + previewBuffer, + originalFileName: image.originalname, + newFileName: sanitizedFilename, + }; + }), + ); + + const endTime = Date.now(); + const processingTime = endTime - startTime; + if (processingTime > 10000) { + // more than 10 seconds + console.error( + `convertAndResizeImages took too long: ${processingTime}ms`, + ); + } + + return results; + } + + async getImageById( + imageId: number, + preview: boolean, + ): Promise<{ + buffer: Buffer; + contentType: string; + newFileName: string; + } | null> { + let image; + if (preview) { + image = await AsyncQuestionImageModel.findOne({ + where: { imageId }, + select: ['previewImageBuffer', 'newFileName'], + }); + } else { + image = await AsyncQuestionImageModel.findOne({ + where: { imageId }, + select: ['imageBuffer', 'newFileName'], + }); + } + + if (!image) return null; + + return { + buffer: preview ? image.previewImageBuffer : image.imageBuffer, + contentType: 'image/webp', + newFileName: image.newFileName, + }; + } + /** * Takes in a userId and async questionId and hashes them to return a random index from ANONYMOUS_ANIMAL_AVATAR.ANIMAL_NAMES * Note that 70 is the length of ANONYMOUS_ANIMAL_AVATAR.ANIMAL_NAMES @@ -379,3 +520,10 @@ export class AsyncQuestionService { return hash % 70; } } + +export interface tempFile { + processedBuffer: Buffer; + previewBuffer: Buffer; + originalFileName: string; + newFileName: string; +} diff --git a/packages/server/src/asyncQuestion/asyncQuestionImage.entity.ts b/packages/server/src/asyncQuestion/asyncQuestionImage.entity.ts new file mode 100644 index 000000000..d76310783 --- /dev/null +++ b/packages/server/src/asyncQuestion/asyncQuestionImage.entity.ts @@ -0,0 +1,42 @@ +import { + BaseEntity, + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { AsyncQuestionModel } from './asyncQuestion.entity'; + +@Entity('async_question_image_model') +export class AsyncQuestionImageModel extends BaseEntity { + @PrimaryGeneratedColumn() + imageId: number; + + @Column() + asyncQuestionId: number; + + @ManyToOne((type) => AsyncQuestionModel, (question) => question.images, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'asyncQuestionId' }) + asyncQuestion: AsyncQuestionModel; + + @Column() + originalFileName: string; + + @Column() + newFileName: string; + + @Column({ type: 'bytea', select: false }) // don't include these in select statements unless you specifically ask for them + imageBuffer: Buffer; + + @Column({ type: 'bytea', select: false }) + previewImageBuffer: Buffer; + + @Column({ default: 0 }) + imageSizeBytes: number; + + @Column({ default: 0 }) + previewImageSizeBytes: number; +} diff --git a/packages/server/src/chatbot/chatbot-api.service.ts b/packages/server/src/chatbot/chatbot-api.service.ts index fb0da112d..1810ec9e1 100644 --- a/packages/server/src/chatbot/chatbot-api.service.ts +++ b/packages/server/src/chatbot/chatbot-api.service.ts @@ -5,6 +5,7 @@ import { AddDocumentChunkParams, ChatbotQuestionResponseChatbotDB, UpdateChatbotQuestionParams, + ChatbotAskResponseChatbotDB, } from '@koh/common'; import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -91,11 +92,53 @@ export class ChatbotApiService { history: any, userToken: string, courseId: number, - ) { - return this.request('POST', 'ask', courseId, userToken, { - question, - history, - }); + images?: Buffer[], + ): Promise { + try { + const formData = new FormData(); + formData.append('question', question); + formData.append('history', JSON.stringify(history)); + + // Add images if they exist + if (images && images.length > 0) { + images.forEach((imageBuffer, index) => { + formData.append( + 'images', + new Blob([imageBuffer], { type: 'image/jpeg' }), + `image${index + 1}.jpg`, + ); + }); + } + + const url = new URL(`${this.chatbotApiUrl}/${courseId}/ask`); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'HMS-API-KEY': this.chatbotApiKey, + HMS_API_TOKEN: userToken, + }, + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new HttpException( + error.error || 'Error from chatbot service', + response.status, + ); + } + + return await response.json(); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + throw new HttpException( + 'Failed to connect to chatbot service', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } async getAllQuestions(courseId: number, userToken: string) { From 3b126c433104f7796b5c655540d452f1fd900cfa Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Sat, 12 Apr 2025 13:13:10 -0700 Subject: [PATCH 07/20] now displays async question images. Also made it so async questions skip the similarity search for getting a chatbot response (since it's very unlikely and was causing issues where uploading the same image kept giving the same response regardless of my question abstract) --- packages/common/index.ts | 9 +++++++ .../components/AsyncQuestionCard.tsx | 26 ++++++++++++++++++- .../modals/CreateAsyncQuestionModal.tsx | 7 +++++ .../asyncQuestion/asyncQuestion.controller.ts | 6 ++++- .../server/src/chatbot/chatbot-api.service.ts | 5 +++- 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/common/index.ts b/packages/common/index.ts index 8cd028d01..0df6fb9b2 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -788,6 +788,15 @@ export type AsyncQuestion = { votes?: AsyncQuestionVotes[] comments: AsyncQuestionComment[] votesSum: number + images: AsyncQuestionImage[] +} + +export type AsyncQuestionImage = { + imageId: number + originalFileName: string + newFileName: string + imageSizeBytes: number + previewImageSizeBytes: number } /** diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx index 0c4e54b4f..abffa0a7f 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx @@ -1,5 +1,5 @@ import { useEffect, useReducer, useState } from 'react' -import { Button, Col, message, Row, Tag, Tooltip } from 'antd' +import { Button, Col, message, Row, Tag, Tooltip, Image } from 'antd' import { AsyncQuestion, asyncQuestionStatus, Role } from '@koh/common' import { CheckCircleOutlined, @@ -359,6 +359,30 @@ const AsyncQuestionCard: React.FC = ({ )} > {{question.questionText ?? ''}} + {question.images && question.images.length > 0 && ( +
+ {question.images.map((image) => ( +
{ + e.stopPropagation() // stop clicks from expanding card + }} + > + +
+ ))} +
+ )} + {question.answerText && ( <>
diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx index 52350e645..bdc4705e6 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx @@ -223,6 +223,11 @@ const CreateAsyncQuestionModal: React.FC = ({ (questionType) => questionType.id, ) : [], + images: question?.images.map((image) => ({ + uid: image.imageId, + name: image.originalFileName, + url: `/api/v1/asyncQuestions/${courseId}/image/${image.imageId}`, + })), }} clearOnDestroy onFinish={(values) => onFinish(values)} @@ -275,6 +280,8 @@ const CreateAsyncQuestionModal: React.FC = ({ listType="picture-card" accept="image/*" onPreview={handlePreviewImage} + maxCount={8} + multiple={true} > {form.getFieldValue('images')?.length >= 8 ? null : } diff --git a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts index f1c1565e6..60cac2d93 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts @@ -229,6 +229,7 @@ export class asyncQuestionController { user.chat_token.token, cid, processedImageBuffers.map((result) => result.processedBuffer), // give chatbot the higher-quality, non-preview images + true, ); aiAnswerText = chatbotResponse.answer; } @@ -346,6 +347,8 @@ export class asyncQuestionController { user.chat_token.token, question.courseId, // TODO: add images support + [], + true, ); question.aiAnswerText = chatbotResponse.answer; question.answerText = chatbotResponse.answer; @@ -1012,7 +1015,8 @@ export class asyncQuestionController { res.set({ 'Content-Type': 'image/webp', - 'Cache-Control': 'public, max-age=1296000', // Cache for 4 months + // 'Cache-Control': 'public, max-age=1296000', // Cache for 4 months + 'Cache-Control': 'public, max-age=1', // Cache for 4 months 'Content-Disposition': `inline; filename="${image.newFileName}"; filename*=${filenameAsterisk}`, }); diff --git a/packages/server/src/chatbot/chatbot-api.service.ts b/packages/server/src/chatbot/chatbot-api.service.ts index 1810ec9e1..19857f241 100644 --- a/packages/server/src/chatbot/chatbot-api.service.ts +++ b/packages/server/src/chatbot/chatbot-api.service.ts @@ -93,6 +93,7 @@ export class ChatbotApiService { userToken: string, courseId: number, images?: Buffer[], + skipSimilaritySearch?: boolean, ): Promise { try { const formData = new FormData(); @@ -110,7 +111,9 @@ export class ChatbotApiService { }); } - const url = new URL(`${this.chatbotApiUrl}/${courseId}/ask`); + const url = new URL( + `${this.chatbotApiUrl}/${courseId}/ask${skipSimilaritySearch ? '?skipSimilaritySearch=true' : ''}`, + ); const response = await fetch(url, { method: 'POST', From fa471f181d5bd6abbffaa8ddc75993a48412e7a4 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Mon, 14 Apr 2025 18:34:35 -0700 Subject: [PATCH 08/20] now saves generated ai descriptions of uploaded images and turns them into alt text --- packages/common/index.ts | 5 + .../components/AsyncQuestionCard.tsx | 2 +- .../modals/CreateAsyncQuestionModal.tsx | 2 - .../asyncQuestion/asyncQuestion.controller.ts | 120 ++++++++++++------ .../src/asyncQuestion/asyncQuestion.entity.ts | 4 +- .../asyncQuestion/asyncQuestion.service.ts | 47 +++---- .../asyncQuestionImage.entity.ts | 3 + .../server/src/chatbot/chatbot-api.service.ts | 10 +- 8 files changed, 123 insertions(+), 70 deletions(-) diff --git a/packages/common/index.ts b/packages/common/index.ts index 0df6fb9b2..0a00cedfb 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -401,6 +401,10 @@ export interface ChatbotAskResponseChatbotDB { verified: boolean courseId: string isPreviousQuestion: boolean + imageDescriptions?: { + imageId: string // should be a number but I don't trust it + description: string + }[] } export interface AddChatbotQuestionParams { @@ -797,6 +801,7 @@ export type AsyncQuestionImage = { newFileName: string imageSizeBytes: number previewImageSizeBytes: number + aiSummary: string } /** diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx index abffa0a7f..b8e606bc1 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx @@ -373,7 +373,7 @@ const AsyncQuestionCard: React.FC = ({ width={80} loading="lazy" src={`/api/v1/asyncQuestions/${courseId}/image/${image.imageId}?preview=true`} - alt={image.originalFileName} + alt={image.aiSummary || image.originalFileName} preview={{ src: `/api/v1/asyncQuestions/${courseId}/image/${image.imageId}`, }} diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx index bdc4705e6..60c17879e 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx @@ -39,7 +39,6 @@ const UploadButton: React.FC = () => ( ) /* I think this is just to make sure the file list is an array */ const normFile = (e: any) => { - console.log('Upload event:', e) if (Array.isArray(e)) { return e } @@ -88,7 +87,6 @@ const CreateAsyncQuestionModal: React.FC = ({ } const onFinish = async (values: FormValues) => { - console.log(values) setIsLoading(true) const newQuestionTypeInput = values.questionTypesInput && questionTypes diff --git a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts index 60cac2d93..d59e954f5 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts @@ -29,6 +29,7 @@ import { UploadedFiles, Query, BadRequestException, + InternalServerErrorException, } from '@nestjs/common'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { Roles } from '../decorators/roles.decorator'; @@ -44,7 +45,7 @@ import { CourseRolesGuard } from 'guards/course-roles.guard'; import { AsyncQuestionRolesGuard } from 'guards/async-question-roles.guard'; import { pick } from 'lodash'; import { UserModel } from 'profile/user.entity'; -import { In, Not } from 'typeorm'; +import { getManager, In, Not } from 'typeorm'; import { ApplicationConfigService } from '../config/application_config.service'; import { ChatbotApiService } from 'chatbot/chatbot-api.service'; import { AsyncQuestionService, tempFile } from './asyncQuestion.service'; @@ -203,10 +204,7 @@ export class asyncQuestionController { body.status = body.status ? body.status.toString() : asyncQuestionStatus.AIAnswered; - if (images) { - console.log('images'); - console.log(images); - } + // Convert images to buffers if they exist let processedImageBuffers: tempFile[] = []; if (images && images.length > 0) { @@ -214,47 +212,89 @@ export class asyncQuestionController { await this.asyncQuestionService.convertAndResizeImages(images); } - let aiAnswerText: string | null = null; - if (user.chat_token.used >= user.chat_token.max_uses) { - aiAnswerText = - 'All AI uses have been used up for today. Please try again tomorrow.'; - } else { - const chatbotResponse = await this.chatbotApiService.askQuestion( - ` - Question Abstract: ${body.questionAbstract} - Question Text: ${body.questionText} - Question Types: ${body.questionTypes.map((questionType) => questionType.name).join(', ')} - `, - [], - user.chat_token.token, - cid, - processedImageBuffers.map((result) => result.processedBuffer), // give chatbot the higher-quality, non-preview images - true, + let questionId: number | null = null; + const entityManager = getManager(); + await entityManager.transaction(async (transactionalEntityManager) => { + /* order: + 1. create the question to get async question id + 2. save the images to the database to get their ids + 3. query the chatbot to get the ai answer and the image summaries + 4. update the question with the ai answer and the images with their summaries + */ + const question = await this.asyncQuestionService.createAsyncQuestion( + // this also saves the images to the db + { + courseId: cid, + creatorId: user.id, + questionAbstract: body.questionAbstract, + questionText: body.questionText || null, + answerText: '', // both start as an empty string + aiAnswerText: '', + questionTypes: body.questionTypes as QuestionTypeModel[], + status: body.status || asyncQuestionStatus.AIAnswered, + visible: false, + verified: false, + createdAt: new Date(), + }, + processedImageBuffers, + transactionalEntityManager, ); - aiAnswerText = chatbotResponse.answer; - } + // now that we have the images, their ids, and the rest of the question, query the chatbot to get both the ai answer and the image summaries (and then store everything in the db) + let aiAnswerText: string | null = null; + if (user.chat_token.used >= user.chat_token.max_uses) { + aiAnswerText = + 'All AI uses have been used up for today. Please try again tomorrow.'; + } else { + const chatbotResponse = await this.chatbotApiService.askQuestion( + ` + ${body.questionText ? `Question Abstract: ${body.questionAbstract}` : `Question: ${body.questionAbstract}`} + ${body.questionText ? `Question Text: ${body.questionText}` : ''} + ${body.questionTypes && body.questionTypes.length > 0 ? `Question Types: ${body.questionTypes.map((questionType) => questionType.name).join(', ')}` : ''} + `, + [], + user.chat_token.token, + cid, + processedImageBuffers, + true, + ); + aiAnswerText = chatbotResponse.answer; + const imageDescriptions = chatbotResponse.imageDescriptions; + + // update the question with the ai answer text + question.aiAnswerText = aiAnswerText; + question.answerText = aiAnswerText; // answer text initially becomes the ai answer text + await transactionalEntityManager.save(question); + + // map over imageDescriptions and update the images with the descriptions + for (const imageDescription of imageDescriptions) { + if ( + Number.isInteger(imageDescription.imageId) && + Number(imageDescription.imageId) > 0 + ) { + await transactionalEntityManager.update( + AsyncQuestionImageModel, + { + // using .update() instead of .save() so we don't have to load the image into memory again + imageId: Number(imageDescription.imageId), + }, + { + aiSummary: imageDescription.description, + }, + ); + } + } + } + questionId = question.id; + }); - const question = await this.asyncQuestionService.createAsyncQuestion( - { - courseId: cid, - creatorId: user.id, - questionAbstract: body.questionAbstract, - questionText: body.questionText || null, - answerText: aiAnswerText, // answer text initially becomes the ai answer text - aiAnswerText, - questionTypes: body.questionTypes as QuestionTypeModel[], - status: body.status || asyncQuestionStatus.AIAnswered, - visible: false, - verified: false, - createdAt: new Date(), - }, - processedImageBuffers, - ); + if (!questionId) { + throw new InternalServerErrorException('Failed to create question'); + } const newQuestion = await AsyncQuestionModel.findOne({ where: { courseId: cid, - id: question.id, + id: questionId, }, relations: [ 'creator', diff --git a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts index 77f04d2d0..d289095fc 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts @@ -101,7 +101,9 @@ export class AsyncQuestionModel extends BaseEntity { @AfterLoad() sumVotes() { - this.votesSum = this.votes.reduce((acc, vote) => acc + vote.vote, 0); + this.votesSum = this.votes + ? this.votes.reduce((acc, vote) => acc + vote.vote, 0) + : 0; } @OneToMany(() => AsyncQuestionImageModel, (image) => image.asyncQuestion) diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index a64b6658f..ca1aad1da 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -15,7 +15,7 @@ import * as Sentry from '@sentry/nestjs'; import { UnreadAsyncQuestionModel } from './unread-async-question.entity'; import { ChatbotApiService } from 'chatbot/chatbot-api.service'; import { AsyncQuestionImageModel } from './asyncQuestionImage.entity'; -import { getManager } from 'typeorm'; +import { EntityManager, getManager } from 'typeorm'; import * as checkDiskSpace from 'check-disk-space'; import * as path from 'path'; import * as sharp from 'sharp'; @@ -383,6 +383,7 @@ export class AsyncQuestionService { async createAsyncQuestion( questionData: Partial, imageBuffers: tempFile[], + transactionalEntityManager: EntityManager, ): Promise { const startTime = Date.now(); @@ -392,33 +393,32 @@ export class AsyncQuestionService { throw new ServiceUnavailableException(ERROR_MESSAGES.common.noDiskSpace); } - let question; // Create the question first - const entityManager = getManager(); - await entityManager.transaction(async (transactionalEntityManager) => { - question = await transactionalEntityManager.save( - AsyncQuestionModel.create(questionData), - ); + const question = await transactionalEntityManager.save( + AsyncQuestionModel.create(questionData), + ); - // Process and save images - if (imageBuffers.length > 0) { - const imagePromises = imageBuffers.map(async (buffer) => { - const imageModel = new AsyncQuestionImageModel(); - imageModel.asyncQuestion = question; - imageModel.imageBuffer = buffer.processedBuffer; - imageModel.previewImageBuffer = buffer.previewBuffer; - imageModel.imageSizeBytes = buffer.processedBuffer.length; - imageModel.previewImageSizeBytes = buffer.previewBuffer.length; + // Process and save images + if (imageBuffers.length > 0) { + const imagePromises = imageBuffers.map(async (buffer) => { + const imageModel = new AsyncQuestionImageModel(); + imageModel.asyncQuestion = question; + imageModel.imageBuffer = buffer.processedBuffer; + imageModel.previewImageBuffer = buffer.previewBuffer; + imageModel.imageSizeBytes = buffer.processedBuffer.length; + imageModel.previewImageSizeBytes = buffer.previewBuffer.length; - imageModel.originalFileName = buffer.originalFileName; - imageModel.newFileName = buffer.newFileName; + imageModel.originalFileName = buffer.originalFileName; + imageModel.newFileName = buffer.newFileName; - return transactionalEntityManager.save(imageModel); - }); + const image = await transactionalEntityManager.save(imageModel); + buffer.imageId = image.imageId; // add the image id to the buffer so we can use it later (for passing to chatbot) - await Promise.all(imagePromises); - } - }); + return buffer; + }); + + await Promise.all(imagePromises); + } const endTime = Date.now(); const processingTime = endTime - startTime; @@ -526,4 +526,5 @@ export interface tempFile { previewBuffer: Buffer; originalFileName: string; newFileName: string; + imageId?: number; } diff --git a/packages/server/src/asyncQuestion/asyncQuestionImage.entity.ts b/packages/server/src/asyncQuestion/asyncQuestionImage.entity.ts index d76310783..6e3989d42 100644 --- a/packages/server/src/asyncQuestion/asyncQuestionImage.entity.ts +++ b/packages/server/src/asyncQuestion/asyncQuestionImage.entity.ts @@ -39,4 +39,7 @@ export class AsyncQuestionImageModel extends BaseEntity { @Column({ default: 0 }) previewImageSizeBytes: number; + + @Column({ default: '' }) + aiSummary: string; // used for the alt text of the image } diff --git a/packages/server/src/chatbot/chatbot-api.service.ts b/packages/server/src/chatbot/chatbot-api.service.ts index 19857f241..2c7e12da6 100644 --- a/packages/server/src/chatbot/chatbot-api.service.ts +++ b/packages/server/src/chatbot/chatbot-api.service.ts @@ -9,6 +9,7 @@ import { } from '@koh/common'; import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { tempFile } from 'asyncQuestion/asyncQuestion.service'; @Injectable() /* This is a list of all endpoints from the chatbot repo. @@ -92,7 +93,7 @@ export class ChatbotApiService { history: any, userToken: string, courseId: number, - images?: Buffer[], + images?: tempFile[], // only really used for async questions right now, feel free to refactor this to be more generalizable if needed in the future skipSimilaritySearch?: boolean, ): Promise { try { @@ -105,8 +106,11 @@ export class ChatbotApiService { images.forEach((imageBuffer, index) => { formData.append( 'images', - new Blob([imageBuffer], { type: 'image/jpeg' }), - `image${index + 1}.jpg`, + new Blob( + [imageBuffer.processedBuffer], // give chatbot the higher-quality, non-preview images + { type: 'image/webp' }, + ), + `${imageBuffer.imageId ?? `image${index + 1}`}.webp`, ); }); } From ef037df84e51e17e048bf8613cb0837c24313696 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Mon, 14 Apr 2025 18:58:17 -0700 Subject: [PATCH 09/20] move tags for asyncquestioncard to be above answer rather than below comments --- .../components/AsyncQuestionCard.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx index b8e606bc1..19ff617e5 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx @@ -382,6 +382,15 @@ const AsyncQuestionCard: React.FC = ({ ))} )} +
+ {question.questionTypes?.map((questionType, index) => ( + + ))} +
{question.answerText && ( <> @@ -425,15 +434,6 @@ const AsyncQuestionCard: React.FC = ({ showStudents={showStudents} /> -
- {question.questionTypes?.map((questionType, index) => ( - - ))} -
{question.status === asyncQuestionStatus.AIAnswered && userId === question.creatorId && ( From b96091188caef6eb89c59b51956f31045382957b Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Mon, 14 Apr 2025 19:52:56 -0700 Subject: [PATCH 10/20] store ai image descriptions in async question chatbot chunk --- .../asyncQuestion/asyncQuestion.controller.ts | 18 ++++++++---------- .../src/asyncQuestion/asyncQuestion.service.ts | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts index d59e954f5..8e7115b5f 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts @@ -246,11 +246,10 @@ export class asyncQuestionController { 'All AI uses have been used up for today. Please try again tomorrow.'; } else { const chatbotResponse = await this.chatbotApiService.askQuestion( - ` - ${body.questionText ? `Question Abstract: ${body.questionAbstract}` : `Question: ${body.questionAbstract}`} - ${body.questionText ? `Question Text: ${body.questionText}` : ''} - ${body.questionTypes && body.questionTypes.length > 0 ? `Question Types: ${body.questionTypes.map((questionType) => questionType.name).join(', ')}` : ''} - `, + this.asyncQuestionService.formatQuestionTextForChatbot( + question, + false, + ), [], user.chat_token.token, cid, @@ -378,11 +377,10 @@ export class asyncQuestionController { 'All AI uses have been used up for today. Please try again tomorrow.'; } else { const chatbotResponse = await this.chatbotApiService.askQuestion( - ` - Question Abstract: ${question.questionAbstract} - Question Text: ${question.questionText} - Question Types: ${question.questionTypes.map((questionType) => questionType.name).join(', ')} - `, + this.asyncQuestionService.formatQuestionTextForChatbot( + question, + true, + ), [], user.chat_token.token, question.courseId, diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index ca1aad1da..757d272b8 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -361,7 +361,7 @@ export class AsyncQuestionService { // Since the name can take up quite a bit of space, no more than 60 characters (show ... if longer) const chunkName = `${(question.questionAbstract ?? question.questionText).slice(0, 60)}${(question.questionAbstract ?? question.questionText).length > 60 ? '...' : ''}`; const chunkParams: AddDocumentChunkParams = { - documentText: `Question: ${question.questionText}\nAnswer: ${question.answerText}`, + documentText: `${this.formatQuestionTextForChatbot(question, true)}\n\nAnswer: ${question.answerText}`, metadata: { name: chunkName, type: 'inserted_async_question', @@ -380,6 +380,20 @@ export class AsyncQuestionService { ); } + /* Just for formatting the details of the question for sending to the chatbot or for a chunk. + Does stuff like if there's only an abstract, the abstract will just be called "Question" instead of having "Question Abstract" and "Question Text" + */ + formatQuestionTextForChatbot( + question: AsyncQuestionModel, + includeImageDescriptions = false, + ) { + return `${question.questionText ? `Question Abstract: ${question.questionAbstract}` : `Question: ${question.questionAbstract}`} + ${question.questionText ? `Question Text: ${question.questionText}` : ''} + ${question.questionTypes && question.questionTypes.length > 0 ? `Question Types: ${question.questionTypes.map((questionType) => questionType.name).join(', ')}` : ''} + ${includeImageDescriptions ? `Question Image Descriptions: ${question.images.map((image, idx) => `Image ${idx + 1}: ${image.aiSummary}`).join('\n')}` : ''} +`; + } + async createAsyncQuestion( questionData: Partial, imageBuffers: tempFile[], From 7fd05f58fe01187ab4ae90998ff5580029ef0db3 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Mon, 14 Apr 2025 20:30:49 -0700 Subject: [PATCH 11/20] finally fixed issue with AsyncQuestionCards expanding to take the whole width of the page! Had to set a min width to allow the flex item to shrink smaller than the content's width --- .../app/(dashboard)/course/[cid]/async_centre/page.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/page.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/page.tsx index cad09385d..ff48d7017 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/page.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/page.tsx @@ -352,7 +352,13 @@ export default function AsyncCentrePage({ } /> -
+ {/* Learnt a thing: So flex items have a default of min-width: auto, which prevents them + from shrinking below their content's natural width. So, if there's some child element that wants + more width, it will cause all question cards to grow past the parent's max width (causing an overflow). + Doing overflow-hidden will cause outlines to be cut off. So, the real solution here is to add + min-w-0, which overrides min-width: auto and thus allows this flex item to shrink smaller than + its content's natural width. */} +
{/* Filters on DESKTOP ONLY */}

From aae323ffb5ad389944a27c2e018abb8a37f31331 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Mon, 14 Apr 2025 23:32:32 -0700 Subject: [PATCH 12/20] can now edit the images of async questions. It will only re-upload and re-process new images that are uploaded and re-use existing ones (unless those existing ones were deleted). Some other refactorings for backend of async questions, especially updateQuestionStudent. --- packages/common/index.ts | 10 +- .../modals/CreateAsyncQuestionModal.tsx | 42 ++- packages/frontend/app/api/index.ts | 2 +- .../asyncQuestion/asyncQuestion.controller.ts | 349 +++++++++++------- .../asyncQuestion/asyncQuestion.service.ts | 244 ++++++++++-- 5 files changed, 459 insertions(+), 188 deletions(-) diff --git a/packages/common/index.ts b/packages/common/index.ts index 0a00cedfb..d8014250c 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -1562,7 +1562,15 @@ export class CreateAsyncQuestions extends AsyncQuestionParams { images?: any // This will be handled by FormData, so we don't need to specify the type here } -export class UpdateAsyncQuestions extends AsyncQuestionParams {} +export class UpdateAsyncQuestions extends AsyncQuestionParams { + @IsOptional() + @IsArray() + newImages?: any // This will be handled by FormData, so we don't need to specify the type here + + @IsOptional() + @IsArray() + deletedImageIds?: number[] +} export type TAUpdateStatusResponse = QueuePartial export type QueueNotePayloadType = { diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx index 60c17879e..a8801cf0b 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx @@ -97,15 +97,41 @@ const CreateAsyncQuestionModal: React.FC = ({ // If editing a question, update the question. Else create a new one if (question) { - await API.asyncQuestions - .studentUpdate(question.id, { - questionTypes: newQuestionTypeInput, - questionText: values.questionText, - questionAbstract: values.QuestionAbstract, - refreshAIAnswer: values.refreshAIAnswer - ? values.refreshAIAnswer - : undefined, + // create FormData for the request + const formData = new FormData() + formData.append('questionText', values.questionText || '') + formData.append('questionAbstract', values.QuestionAbstract) + formData.append('questionTypes', JSON.stringify(newQuestionTypeInput)) + if (values.refreshAIAnswer) { + formData.append('refreshAIAnswer', 'true') + } + + // to find out what images are deleted, compare question.images.imageId with values.images.uid + const deletedImageIds = question.images + .filter( + (image) => + !values.images.some((file) => Number(file.uid) === image.imageId), + ) + .map((image) => image.imageId) + formData.append('deletedImageIds', JSON.stringify(deletedImageIds)) + console.log(deletedImageIds) + + // to find out what images are new, get all values.images where uid is NaN + const newImages = values.images.filter((file) => isNaN(Number(file.uid))) + console.log(newImages) + + // Append each new image file + if (newImages) { + newImages.forEach((file: any) => { + // Only append if it's a real file (antd's Upload component adds some metadata we don't want) + if (file.originFileObj) { + formData.append('newImages', file.originFileObj) + } }) + } + + await API.asyncQuestions + .studentUpdate(question.id, formData) .then(() => { message.success('Question Updated') onCreateOrUpdateQuestion() diff --git a/packages/frontend/app/api/index.ts b/packages/frontend/app/api/index.ts index 30b2ceaf1..270a1e0b6 100644 --- a/packages/frontend/app/api/index.ts +++ b/packages/frontend/app/api/index.ts @@ -521,7 +521,7 @@ class APIClient { AsyncQuestionParams, body, ), - studentUpdate: async (qid: number, body: UpdateAsyncQuestions) => + studentUpdate: async (qid: number, body: UpdateAsyncQuestions | FormData) => this.req( 'PATCH', `/api/v1/asyncQuestions/student/${qid}`, diff --git a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts index 8e7115b5f..f66eab657 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts @@ -184,28 +184,9 @@ export class asyncQuestionController { @Res() res: Response, @UploadedFiles() images?: Express.Multer.File[], ): Promise { - // the body *should* follow CreateAsyncQuestions, but since we're using formdata, we have to manually parse the fields and validate them - if (body.questionTypes) { - try { - body.questionTypes = JSON.parse(body.questionTypes); - } catch (error) { - throw new BadRequestException( - 'Question Types field must be a valid JSON array', - ); - } - } - if (!body.questionAbstract) { - throw new BadRequestException('Question Abstract is required'); - } - body.questionText = body.questionText ? body.questionText.toString() : ''; - body.questionAbstract = body.questionAbstract - ? body.questionAbstract.toString() - : ''; - body.status = body.status - ? body.status.toString() - : asyncQuestionStatus.AIAnswered; - - // Convert images to buffers if they exist + this.asyncQuestionService.validateBodyCreateAsyncQuestions(body); // note that this *will* mutate body + + // Convert & resize images to buffers if they exist let processedImageBuffers: tempFile[] = []; if (images && images.length > 0) { processedImageBuffers = @@ -261,27 +242,13 @@ export class asyncQuestionController { // update the question with the ai answer text question.aiAnswerText = aiAnswerText; - question.answerText = aiAnswerText; // answer text initially becomes the ai answer text + question.answerText = aiAnswerText; // answer text initially becomes the ai answer text (staff can later edit it) await transactionalEntityManager.save(question); - // map over imageDescriptions and update the images with the descriptions - for (const imageDescription of imageDescriptions) { - if ( - Number.isInteger(imageDescription.imageId) && - Number(imageDescription.imageId) > 0 - ) { - await transactionalEntityManager.update( - AsyncQuestionImageModel, - { - // using .update() instead of .save() so we don't have to load the image into memory again - imageId: Number(imageDescription.imageId), - }, - { - aiSummary: imageDescription.description, - }, - ); - } - } + await this.asyncQuestionService.saveImageDescriptions( + imageDescriptions, + transactionalEntityManager, + ); } questionId = question.id; }); @@ -318,122 +285,218 @@ export class asyncQuestionController { @Patch('student/:questionId') @UseGuards(AsyncQuestionRolesGuard) @Roles(Role.STUDENT, Role.TA, Role.PROFESSOR) // since were letting staff post questions, they might end up calling this endpoint to update their own questions + @UseInterceptors( + FilesInterceptor('newImages', 8, { + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit per file + }, + fileFilter: (req, file, cb) => { + // Check mimetype (it can be spoofed fyi so we also need to check the file extension) + if (!file.mimetype.startsWith('image/')) { + cb(new Error('Only image files are allowed'), false); + return; + } + // Check file extension + const allowedExtensions = [ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.bmp', + '.svg', + '.tiff', + '.gif', + ]; + const fileExt = file.originalname + .toLowerCase() + .substring(file.originalname.lastIndexOf('.')); + if (!allowedExtensions.includes(fileExt)) { + cb(new Error('Only image files are allowed'), false); + return; + } + cb(null, true); + }, + }), + ) async updateQuestionStudent( @Param('questionId', ParseIntPipe) questionId: number, - @Body() body: UpdateAsyncQuestions, + @Body() body: UpdateAsyncQuestions | FormData | any, // delete FormData | any for better type checking temporarily @User(['chat_token']) user: UserModel, + @UploadedFiles() newImages?: Express.Multer.File[], ): Promise { - const question = await AsyncQuestionModel.findOne({ - where: { id: questionId }, - relations: [ - 'creator', - 'votes', - 'comments', - 'comments.creator', - 'comments.creator.courses', - 'images', - ], - }); - // deep copy question since it changes - const oldQuestion: AsyncQuestionModel = JSON.parse( - JSON.stringify(question), - ); + this.asyncQuestionService.validateBodyUpdateAsyncQuestions(body); // note that this *will* mutate body - if (!question) { - throw new NotFoundException('Question Not Found'); - } + let question: AsyncQuestionModel | null = null; + const entityManager = getManager(); + await entityManager.transaction(async (transactionalEntityManager) => { + question = await transactionalEntityManager.findOne(AsyncQuestionModel, { + where: { id: questionId }, + relations: [ + 'creator', + 'votes', + 'comments', + 'comments.creator', + 'comments.creator.courses', + 'images', + ], + }); + if (!question) { + throw new NotFoundException('Question Not Found'); + } + if (question.creatorId !== user.id) { + throw new ForbiddenException('You can only update your own questions'); + } - if (question.creatorId !== user.id) { - throw new ForbiddenException('You can only update your own questions'); - } - // if you created the question (i.e. a student), you can't update the status to illegal ones - if ( - body.status === asyncQuestionStatus.TADeleted || - body.status === asyncQuestionStatus.HumanAnswered - ) { - throw new ForbiddenException( - `You cannot update your own question's status to ${body.status}`, + // deep copy question since it changes + const oldQuestion: AsyncQuestionModel = JSON.parse( + JSON.stringify(question), ); - } - if ( - body.status === asyncQuestionStatus.AIAnsweredNeedsAttention && - question.status != asyncQuestionStatus.AIAnsweredNeedsAttention - ) { - await this.asyncQuestionService.sendNeedsAttentionEmail(question); - } + // this doesn't include if question types have changed but meh + const isChangingQuestion = + question.questionText !== oldQuestion.questionText || + question.questionAbstract !== oldQuestion.questionAbstract || + (newImages && newImages.length > 0) || + (body.deleteImageIds && body.deleteImageIds.length > 0); + + // if they're changing the status to needs attention, send the email but don't change anything else + if ( + body.status === asyncQuestionStatus.AIAnsweredNeedsAttention && + question.status != asyncQuestionStatus.AIAnsweredNeedsAttention + ) { + await this.asyncQuestionService.sendNeedsAttentionEmail(question); + // Mark as new unread for all staff if the question needs attention + await this.asyncQuestionService.markUnreadForRoles( + question, + [Role.TA, Role.PROFESSOR], + user.id, + transactionalEntityManager, + ); + } else { + // Update allowed fields + Object.keys(body).forEach((key) => { + if ( + body[key] !== undefined && + body[key] !== null && + question[key] !== undefined + ) { + question[key] = body[key]; + } + }); - // Update allowed fields - Object.keys(body).forEach((key) => { - if (body[key] !== undefined && body[key] !== null) { - question[key] = body[key]; - } - }); + // delete any images that are in the deleteImageIds array + if (body.deleteImageIds && body.deleteImageIds.length > 0) { + await this.asyncQuestionService.deleteImages( + questionId, + body.deleteImageIds, + transactionalEntityManager, + ); + } - if (body.refreshAIAnswer) { - if (user.chat_token.used >= user.chat_token.max_uses) { - question.aiAnswerText = - 'All AI uses have been used up for today. Please try again tomorrow.'; - question.answerText = - 'All AI uses have been used up for today. Please try again tomorrow.'; - } else { - const chatbotResponse = await this.chatbotApiService.askQuestion( - this.asyncQuestionService.formatQuestionTextForChatbot( + // Convert & resize images to buffers if they exist + let processedImageBuffers: tempFile[] = []; + if (newImages && newImages.length > 0) { + const currentImageCount = + await this.asyncQuestionService.getCurrentImageCount( + questionId, + transactionalEntityManager, + ); + if (currentImageCount + newImages.length > 8) { + throw new BadRequestException( + 'You can have at most 8 images uploaded per question', + ); + } + processedImageBuffers = + await this.asyncQuestionService.convertAndResizeImages(newImages); + // save the images to the db (while also setting the imageIds of processedImageBuffers) + await this.asyncQuestionService.saveImagesToDb( question, - true, - ), - [], - user.chat_token.token, - question.courseId, - // TODO: add images support - [], - true, - ); - question.aiAnswerText = chatbotResponse.answer; - question.answerText = chatbotResponse.answer; - } - } + processedImageBuffers, + transactionalEntityManager, + ); + } - const updatedQuestion = await question.save(); + if (body.refreshAIAnswer) { + if (user.chat_token.used >= user.chat_token.max_uses) { + question.aiAnswerText = + 'All AI uses have been used up for today. Please try again tomorrow.'; + question.answerText = + 'All AI uses have been used up for today. Please try again tomorrow.'; + } else { + // before we ask the chatbot, we need to gather any previously uploaded images from the database and append them onto processedImageBuffers + const images = await transactionalEntityManager.find( + AsyncQuestionImageModel, + { + where: { + asyncQuestionId: questionId, + }, + select: ['imageBuffer', 'newFileName', 'imageId'], // not including the old aiSummaries since getting a new description for them may give a better ai answer + }, + ); + processedImageBuffers.push( + ...images.map((image) => ({ + processedBuffer: image.imageBuffer, + previewBuffer: image.imageBuffer, // unused. Didn't want to include it in the query so that less memory is used + originalFileName: image.originalFileName, + newFileName: image.newFileName, + imageId: image.imageId, + })), + ); - // Mark as new unread for all staff if the question needs attention - if ( - body.status === asyncQuestionStatus.AIAnsweredNeedsAttention && - oldQuestion.status !== asyncQuestionStatus.AIAnsweredNeedsAttention - ) { - await this.asyncQuestionService.markUnreadForRoles( - updatedQuestion, - [Role.TA, Role.PROFESSOR], - user.id, - ); - } - // if the question is visible and they rewrote their question and got a new answer text, mark it as unread for everyone - if ( - updatedQuestion.visible && - body.refreshAIAnswer && - body.questionText !== oldQuestion.questionText - ) { - await this.asyncQuestionService.markUnreadForAll( - updatedQuestion, - user.id, - ); - } + const chatbotResponse = await this.chatbotApiService.askQuestion( + this.asyncQuestionService.formatQuestionTextForChatbot( + question, + false, + ), + [], + user.chat_token.token, + question.courseId, + processedImageBuffers, + true, + ); + question.aiAnswerText = chatbotResponse.answer; + question.answerText = chatbotResponse.answer; + await this.asyncQuestionService.saveImageDescriptions( + chatbotResponse.imageDescriptions, + transactionalEntityManager, + ); + } + } - if (body.status === asyncQuestionStatus.StudentDeleted) { - await this.redisQueueService.deleteAsyncQuestion( - `c:${question.courseId}:aq`, - updatedQuestion, - ); - // delete all unread notifications for this question - await UnreadAsyncQuestionModel.delete({ asyncQuestionId: questionId }); - } else { - await this.redisQueueService.updateAsyncQuestion( - `c:${question.courseId}:aq`, - updatedQuestion, - ); - } - delete question.taHelped; - delete question.votes; + const updatedQuestion = await transactionalEntityManager.save(question); + + // if the question is visible and they rewrote their question and got a new answer text, mark it as unread for everyone + if ( + updatedQuestion.visible && + body.refreshAIAnswer && + isChangingQuestion + ) { + await this.asyncQuestionService.markUnreadForAll( + updatedQuestion, + user.id, + transactionalEntityManager, + ); + } + if (body.status === asyncQuestionStatus.StudentDeleted) { + await this.redisQueueService.deleteAsyncQuestion( + `c:${question.courseId}:aq`, + updatedQuestion, + ); + // delete all unread notifications for this question + await UnreadAsyncQuestionModel.delete({ + asyncQuestionId: questionId, + }); + } else { + await this.redisQueueService.updateAsyncQuestion( + `c:${question.courseId}:aq`, + updatedQuestion, + ); + } + delete question.taHelped; + delete question.votes; + } + }); return question; } diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index 757d272b8..c75b82606 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -1,10 +1,18 @@ import { AddDocumentChunkParams, + asyncQuestionStatus, + CreateAsyncQuestions, ERROR_MESSAGES, MailServiceType, Role, + UpdateAsyncQuestions, } from '@koh/common'; -import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Injectable, + ServiceUnavailableException, +} from '@nestjs/common'; import { MailService } from 'mail/mail.service'; import { UserSubscriptionModel } from 'mail/user-subscriptions.entity'; import { UserCourseModel } from 'profile/user-course.entity'; @@ -15,7 +23,7 @@ import * as Sentry from '@sentry/nestjs'; import { UnreadAsyncQuestionModel } from './unread-async-question.entity'; import { ChatbotApiService } from 'chatbot/chatbot-api.service'; import { AsyncQuestionImageModel } from './asyncQuestionImage.entity'; -import { EntityManager, getManager } from 'typeorm'; +import { EntityManager, getManager, In } from 'typeorm'; import * as checkDiskSpace from 'check-disk-space'; import * as path from 'path'; import * as sharp from 'sharp'; @@ -301,41 +309,79 @@ export class AsyncQuestionService { question: AsyncQuestionModel, roles: Role[], userToNotNotifyId: number, + transactionalEntityManager?: EntityManager, ) { - await UnreadAsyncQuestionModel.createQueryBuilder() - .update(UnreadAsyncQuestionModel) - .set({ readLatest: false }) - .where('asyncQuestionId = :asyncQuestionId', { - asyncQuestionId: question.id, - }) - .andWhere('userId != :userId', { userId: userToNotNotifyId }) // don't notify me (person who called endpoint) - // Use a subquery to filter by roles - .andWhere( - `"userId" IN ( + if (transactionalEntityManager) { + await transactionalEntityManager + .createQueryBuilder() + .update(UnreadAsyncQuestionModel) + .set({ readLatest: false }) + .where('asyncQuestionId = :asyncQuestionId', { + asyncQuestionId: question.id, + }) + .andWhere('userId != :userId', { userId: userToNotNotifyId }) // don't notify me (person who called endpoint) + // Use a subquery to filter by roles + .andWhere( + `"userId" IN ( SELECT "user_course_model"."userId" FROM "user_course_model" WHERE "user_course_model"."role" IN (:...roles) )`, - { roles }, // notify all specified roles - ) - .execute(); + { roles }, // notify all specified roles + ) + .execute(); + } else { + await UnreadAsyncQuestionModel.createQueryBuilder() + .update(UnreadAsyncQuestionModel) + .set({ readLatest: false }) + .where('asyncQuestionId = :asyncQuestionId', { + asyncQuestionId: question.id, + }) + .andWhere('userId != :userId', { userId: userToNotNotifyId }) // don't notify me (person who called endpoint) + // Use a subquery to filter by roles + .andWhere( + `"userId" IN ( + SELECT "user_course_model"."userId" + FROM "user_course_model" + WHERE "user_course_model"."role" IN (:...roles) + )`, + { roles }, // notify all specified roles + ) + .execute(); + } } async markUnreadForAll( question: AsyncQuestionModel, userToNotNotifyId: number, + transactionalEntityManager?: EntityManager, ) { - await UnreadAsyncQuestionModel.createQueryBuilder() - .update(UnreadAsyncQuestionModel) - .set({ readLatest: false }) - .where('asyncQuestionId = :asyncQuestionId', { - asyncQuestionId: question.id, - }) - .andWhere( - `userId != :userId`, - { userId: userToNotNotifyId }, // don't notify me (person who called endpoint) - ) - .execute(); + if (transactionalEntityManager) { + await transactionalEntityManager + .createQueryBuilder() + .update(UnreadAsyncQuestionModel) + .set({ readLatest: false }) + .where('asyncQuestionId = :asyncQuestionId', { + asyncQuestionId: question.id, + }) + .andWhere( + `userId != :userId`, + { userId: userToNotNotifyId }, // don't notify me (person who called endpoint) + ) + .execute(); + } else { + await UnreadAsyncQuestionModel.createQueryBuilder() + .update(UnreadAsyncQuestionModel) + .set({ readLatest: false }) + .where('asyncQuestionId = :asyncQuestionId', { + asyncQuestionId: question.id, + }) + .andWhere( + `userId != :userId`, + { userId: userToNotNotifyId }, // don't notify me (person who called endpoint) + ) + .execute(); + } } async markUnreadForCreator(question: AsyncQuestionModel) { @@ -385,7 +431,7 @@ export class AsyncQuestionService { */ formatQuestionTextForChatbot( question: AsyncQuestionModel, - includeImageDescriptions = false, + includeImageDescriptions = false, // unused atm ) { return `${question.questionText ? `Question Abstract: ${question.questionAbstract}` : `Question: ${question.questionAbstract}`} ${question.questionText ? `Question Text: ${question.questionText}` : ''} @@ -399,18 +445,32 @@ export class AsyncQuestionService { imageBuffers: tempFile[], transactionalEntityManager: EntityManager, ): Promise { - const startTime = Date.now(); + // Create the question first + const question = await transactionalEntityManager.save( + AsyncQuestionModel.create(questionData), + ); + // Process and save images + await this.saveImagesToDb( + question, + imageBuffers, + transactionalEntityManager, + ); + + return question; + } + + async saveImagesToDb( + question: AsyncQuestionModel, + imageBuffers: tempFile[], + transactionalEntityManager: EntityManager, + ) { // Check disk space before proceeding const spaceLeft = await checkDiskSpace(path.parse(process.cwd()).root); if (spaceLeft.free < 1_000_000_000) { throw new ServiceUnavailableException(ERROR_MESSAGES.common.noDiskSpace); } - - // Create the question first - const question = await transactionalEntityManager.save( - AsyncQuestionModel.create(questionData), - ); + const startTime = Date.now(); // Process and save images if (imageBuffers.length > 0) { @@ -439,10 +499,43 @@ export class AsyncQuestionService { if (processingTime > 10000) { // more than 10 seconds - console.error(`createAsyncQuestion took too long: ${processingTime}ms`); + console.error(`saveImagesToDb took too long: ${processingTime}ms`); } + } - return question; + async deleteImages( + questionId: number, + imageIds: number[], + transactionalEntityManager: EntityManager, + ) { + await transactionalEntityManager.delete(AsyncQuestionImageModel, { + imageId: In(imageIds), + asyncQuestionId: questionId, + }); + } + + async saveImageDescriptions( + imageDescriptions: { imageId: string; description: string }[], + transactionalEntityManager: EntityManager, + ) { + // map over imageDescriptions and update the images with the descriptions + for (const imageDescription of imageDescriptions) { + if ( + Number.isInteger(imageDescription.imageId) && + Number(imageDescription.imageId) > 0 + ) { + await transactionalEntityManager.update( + AsyncQuestionImageModel, + { + // using .update() instead of .save() so we don't have to load the image into memory again + imageId: Number(imageDescription.imageId), + }, + { + aiSummary: imageDescription.description, + }, + ); + } + } } async convertAndResizeImages( @@ -524,6 +617,15 @@ export class AsyncQuestionService { }; } + async getCurrentImageCount( + questionId: number, + transactionalEntityManager: EntityManager, + ) { + return await transactionalEntityManager.count(AsyncQuestionImageModel, { + where: { asyncQuestionId: questionId }, + }); + } + /** * Takes in a userId and async questionId and hashes them to return a random index from ANONYMOUS_ANIMAL_AVATAR.ANIMAL_NAMES * Note that 70 is the length of ANONYMOUS_ANIMAL_AVATAR.ANIMAL_NAMES @@ -533,6 +635,78 @@ export class AsyncQuestionService { const hash = userId + questionId; return hash % 70; } + + validateBodyCreateAsyncQuestions( + body: CreateAsyncQuestions | FormData | any, + ) { + // the body *should* follow CreateAsyncQuestions, but since we're using formdata, we have to manually parse the fields and validate them + if (body.questionTypes) { + try { + body.questionTypes = JSON.parse(body.questionTypes); + } catch (error) { + throw new BadRequestException( + 'Question Types field must be a valid JSON array', + ); + } + } + if (!body.questionAbstract) { + throw new BadRequestException('Question Abstract is required'); + } + body.questionText = body.questionText ? body.questionText.toString() : ''; + body.questionAbstract = body.questionAbstract + ? body.questionAbstract.toString() + : ''; + body.status = body.status + ? body.status.toString() + : asyncQuestionStatus.AIAnswered; + } + + validateBodyUpdateAsyncQuestions( + body: UpdateAsyncQuestions | FormData | any, + ) { + // the body *should* follow UpdateAsyncQuestions, but since we're using formdata, we have to manually parse the fields and validate them + if (body.questionTypes) { + try { + body.questionTypes = JSON.parse(body.questionTypes); + } catch (error) { + throw new BadRequestException( + 'Question Types field must be a valid JSON array', + ); + } + } + + body.questionText = body.questionText ? body.questionText.toString() : ''; + body.questionAbstract = body.questionAbstract + ? body.questionAbstract.toString() + : ''; + body.status = body.status + ? body.status.toString() + : asyncQuestionStatus.AIAnswered; + + if (body.deleteImageIds) { + try { + body.deleteImageIds = JSON.parse(body.deleteImageIds); + } catch (error) { + throw new BadRequestException( + 'Delete Image Ids field must be a valid JSON array', + ); + } + } + + // if you created the question (i.e. a student), you can't update the status to illegal ones + if ( + body.status === asyncQuestionStatus.TADeleted || + body.status === asyncQuestionStatus.HumanAnswered + ) { + throw new ForbiddenException( + `You cannot update your own question's status to ${body.status}`, + ); + } + + if (body.refreshAIAnswer) { + body.refreshAIAnswer = body.refreshAIAnswer.toString() === 'true'; + } + } } export interface tempFile { From cbbc6576c65107f75d0a9d50f1efa3bd667fb7a0 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Tue, 15 Apr 2025 14:45:14 -0700 Subject: [PATCH 13/20] fixed issue with image descriptions not getting updated in redis after update --- .../asyncQuestion/asyncQuestion.controller.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts index f66eab657..ef52c16e7 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts @@ -462,9 +462,24 @@ export class asyncQuestionController { ); } } + // save the changes + await transactionalEntityManager.save(question); - const updatedQuestion = await transactionalEntityManager.save(question); - + // re-fetch the updated question + const updatedQuestion = await transactionalEntityManager.findOne( + AsyncQuestionModel, + { + where: { id: questionId }, + relations: [ + 'creator', + 'votes', + 'comments', + 'comments.creator', + 'comments.creator.courses', + 'images', + ], + }, + ); // if the question is visible and they rewrote their question and got a new answer text, mark it as unread for everyone if ( updatedQuestion.visible && From 4ffa626ce42a871eb377016858f4701a2f17fee9 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Tue, 15 Apr 2025 15:46:52 -0700 Subject: [PATCH 14/20] got async image update (deleting old images, not changing others, and adding new ones) fully working! --- .../src/asyncQuestion/asyncQuestion.controller.ts | 13 ++++++++----- .../src/asyncQuestion/asyncQuestion.service.ts | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts index ef52c16e7..49c9042e5 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts @@ -338,7 +338,6 @@ export class asyncQuestionController { 'comments', 'comments.creator', 'comments.creator.courses', - 'images', ], }); if (!question) { @@ -357,7 +356,7 @@ export class asyncQuestionController { question.questionText !== oldQuestion.questionText || question.questionAbstract !== oldQuestion.questionAbstract || (newImages && newImages.length > 0) || - (body.deleteImageIds && body.deleteImageIds.length > 0); + (body.deletedImageIds && body.deletedImageIds.length > 0); // if they're changing the status to needs attention, send the email but don't change anything else if ( @@ -384,11 +383,11 @@ export class asyncQuestionController { } }); - // delete any images that are in the deleteImageIds array - if (body.deleteImageIds && body.deleteImageIds.length > 0) { + // delete any images that are in the deletedImageIds array + if (body.deletedImageIds && body.deletedImageIds.length > 0) { await this.asyncQuestionService.deleteImages( questionId, - body.deleteImageIds, + body.deletedImageIds, transactionalEntityManager, ); } @@ -424,11 +423,15 @@ export class asyncQuestionController { 'All AI uses have been used up for today. Please try again tomorrow.'; } else { // before we ask the chatbot, we need to gather any previously uploaded images from the database and append them onto processedImageBuffers + const alreadyProcessedImageIds = processedImageBuffers.map( + (image) => image.imageId, + ); const images = await transactionalEntityManager.find( AsyncQuestionImageModel, { where: { asyncQuestionId: questionId, + imageId: Not(In(alreadyProcessedImageIds)), // don't retrieve from the db the image buffers we just uploaded to it (to save memory) }, select: ['imageBuffer', 'newFileName', 'imageId'], // not including the old aiSummaries since getting a new description for them may give a better ai answer }, diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index c75b82606..f6d721923 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -683,9 +683,9 @@ export class AsyncQuestionService { ? body.status.toString() : asyncQuestionStatus.AIAnswered; - if (body.deleteImageIds) { + if (body.deletedImageIds) { try { - body.deleteImageIds = JSON.parse(body.deleteImageIds); + body.deletedImageIds = JSON.parse(body.deletedImageIds); } catch (error) { throw new BadRequestException( 'Delete Image Ids field must be a valid JSON array', From 272c993c543f8cf8f01b7572a97e9c511835a137 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Tue, 15 Apr 2025 16:36:36 -0700 Subject: [PATCH 15/20] fix issue with importing sharp and checkDiskSpace --- packages/server/src/profile/profile.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/src/profile/profile.service.ts b/packages/server/src/profile/profile.service.ts index 018b124e8..b2daeaf80 100644 --- a/packages/server/src/profile/profile.service.ts +++ b/packages/server/src/profile/profile.service.ts @@ -19,10 +19,10 @@ import { UserModel } from './user.entity'; import { RedisProfileService } from '../redisProfile/redis-profile.service'; import { pick } from 'lodash'; import { OrganizationService } from '../organization/organization.service'; -import checkDiskSpace from 'check-disk-space'; +import * as checkDiskSpace from 'check-disk-space'; import * as path from 'path'; import * as fs from 'fs'; -import sharp from 'sharp'; +import * as sharp from 'sharp'; @Injectable() export class ProfileService { From 53d756935b93f3f4d708ec82bb729456e267cbb9 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Tue, 15 Apr 2025 16:47:39 -0700 Subject: [PATCH 16/20] added ability for users to clear their own profile cache (clearing both redis and resetting userinfo). Also fixed some issues with chatbot_token.used not updating on client-side --- .../modals/CreateAsyncQuestionModal.tsx | 11 ++- .../profile/components/AdvancedSettings.tsx | 71 +++++++++++++++++++ .../profile/components/ProfileSettings.tsx | 31 ++++---- .../profile/components/SettingsMenu.tsx | 25 ++++++- packages/frontend/app/api/index.ts | 2 + packages/frontend/app/typings/enum.ts | 1 + .../asyncQuestion/asyncQuestion.controller.ts | 8 ++- .../src/asyncQuestion/asyncQuestion.module.ts | 4 ++ .../asyncQuestion/asyncQuestion.service.ts | 2 +- .../server/src/profile/profile.controller.ts | 8 ++- 10 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 packages/frontend/app/(dashboard)/profile/components/AdvancedSettings.tsx diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx index a8801cf0b..20b70a85a 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx @@ -68,7 +68,7 @@ const CreateAsyncQuestionModal: React.FC = ({ onCreateOrUpdateQuestion, question, }) => { - const { userInfo } = useUserInfo() + const { userInfo, setUserInfo } = useUserInfo() const [questionTypes] = useQuestionTypes(courseId, null) const [form] = Form.useForm() const [isLoading, setIsLoading] = useState(false) @@ -141,6 +141,15 @@ const CreateAsyncQuestionModal: React.FC = ({ message.error('Error updating question:' + errorMessage) }) .finally(() => { + if (values.refreshAIAnswer) { + setUserInfo({ + ...userInfo, + chat_token: { + ...userInfo.chat_token, + used: userInfo.chat_token.used + 1, + }, + }) + } setIsLoading(false) }) } else { diff --git a/packages/frontend/app/(dashboard)/profile/components/AdvancedSettings.tsx b/packages/frontend/app/(dashboard)/profile/components/AdvancedSettings.tsx new file mode 100644 index 000000000..f3d905885 --- /dev/null +++ b/packages/frontend/app/(dashboard)/profile/components/AdvancedSettings.tsx @@ -0,0 +1,71 @@ +import { API } from '@/app/api' +import { userApi } from '@/app/api/userApi' +import { useUserInfo } from '@/app/contexts/userContext' +import { getErrorMessage } from '@/app/utils/generalUtils' +import { + DeleteOutlined, + QuestionCircleOutlined, + SettingOutlined, +} from '@ant-design/icons' +import { Button, Card, message, Tooltip } from 'antd' +import { useState } from 'react' + +const AdvancedSettings: React.FC = () => { + const { userInfo, setUserInfo } = useUserInfo() + const [isLoading, setIsLoading] = useState(false) + + return ( + userInfo && ( + + Advanced Settings +

+ } + bordered + classNames={{ body: 'py-2' }} + > + + Clear Profile Cache + + + + + ) + ) +} + +export default AdvancedSettings diff --git a/packages/frontend/app/(dashboard)/profile/components/ProfileSettings.tsx b/packages/frontend/app/(dashboard)/profile/components/ProfileSettings.tsx index ada667ed0..bbb2cc5f5 100644 --- a/packages/frontend/app/(dashboard)/profile/components/ProfileSettings.tsx +++ b/packages/frontend/app/(dashboard)/profile/components/ProfileSettings.tsx @@ -9,23 +9,24 @@ import EditProfile from './EditProfile' import NotificationsSettings from './NotificationsSettings' import CoursePreference from './CoursePreference' import EmailNotifications from './EmailNotifications' -import { useSearchParams } from 'next/navigation'; +import { useSearchParams } from 'next/navigation' +import AdvancedSettings from './AdvancedSettings' const ProfileSettings: React.FC = () => { const params = useSearchParams() - const [currentSettings, setCurrentSettings] = useState( - () => { - switch(params.get("page")) { - case "notifications": - return SettingsOptions.NOTIFICATIONS - case "preferences": - return SettingsOptions.PREFERENCES - default: - return SettingsOptions.PROFILE - } + const [currentSettings, setCurrentSettings] = useState(() => { + switch (params.get('page')) { + case 'notifications': + return SettingsOptions.NOTIFICATIONS + case 'preferences': + return SettingsOptions.PREFERENCES + case 'advanced': + return SettingsOptions.ADVANCED + default: + return SettingsOptions.PROFILE } - ) + }) return ( @@ -34,7 +35,10 @@ const ProfileSettings: React.FC = () => { className="mx-auto mt-2 h-fit w-full max-w-max text-center md:mx-0 md:mt-0" > - +
{ {currentSettings === SettingsOptions.PREFERENCES && ( )} + {currentSettings === SettingsOptions.ADVANCED && } diff --git a/packages/frontend/app/(dashboard)/profile/components/SettingsMenu.tsx b/packages/frontend/app/(dashboard)/profile/components/SettingsMenu.tsx index e48d7ed1b..0c62942e6 100644 --- a/packages/frontend/app/(dashboard)/profile/components/SettingsMenu.tsx +++ b/packages/frontend/app/(dashboard)/profile/components/SettingsMenu.tsx @@ -2,18 +2,27 @@ import { Collapse, Menu } from 'antd' import EditProfile from './EditProfile' -import { BellOutlined, BookOutlined, UserOutlined } from '@ant-design/icons' +import { + BellOutlined, + BookOutlined, + SettingOutlined, + UserOutlined, +} from '@ant-design/icons' import { SettingsOptions } from '@/app/typings/enum' import NotificationsSettings from './NotificationsSettings' import CoursePreference from './CoursePreference' import { useMediaQuery } from '@/app/hooks/useMediaQuery' import EmailNotifications from './EmailNotifications' +import AdvancedSettings from './AdvancedSettings' interface SettingsMenuProps { - currentSettings: SettingsOptions; + currentSettings: SettingsOptions setCurrentSettings: (settings: SettingsOptions) => void } -const SettingsMenu: React.FC = ({ currentSettings, setCurrentSettings }) => { +const SettingsMenu: React.FC = ({ + currentSettings, + setCurrentSettings, +}) => { const isMobile = useMediaQuery('(max-width: 768px)') return isMobile ? ( @@ -42,6 +51,11 @@ const SettingsMenu: React.FC = ({ currentSettings, setCurrent label: 'Course Preferences', children: , }, + { + key: SettingsOptions.ADVANCED, + label: 'Advanced Settings', + children: , + }, ]} /> ) : ( @@ -65,6 +79,11 @@ const SettingsMenu: React.FC = ({ currentSettings, setCurrent label: 'Course Preferences', icon: , }, + { + key: SettingsOptions.ADVANCED, + label: 'Advanced Settings', + icon: , + }, ]} /> ) diff --git a/packages/frontend/app/api/index.ts b/packages/frontend/app/api/index.ts index 270a1e0b6..2dc949ba3 100644 --- a/packages/frontend/app/api/index.ts +++ b/packages/frontend/app/api/index.ts @@ -160,6 +160,8 @@ class APIClient { this.req('DELETE', `/api/v1/profile/delete_profile_picture`), readChangelog: async (): Promise => this.req('PATCH', `/api/v1/profile/read_changelog`, undefined), + clearCache: async (): Promise => + this.req('DELETE', `/api/v1/profile/clear_cache`), } chatbot = { diff --git a/packages/frontend/app/typings/enum.ts b/packages/frontend/app/typings/enum.ts index 4fe4996d2..9c01289e4 100644 --- a/packages/frontend/app/typings/enum.ts +++ b/packages/frontend/app/typings/enum.ts @@ -3,4 +3,5 @@ export enum SettingsOptions { NOTIFICATIONS = 'NOTIFICATIONS', TEAMS_SETTINGS = 'TEAMS_SETTINGS', PREFERENCES = 'PREFERENCES', + ADVANCED = 'ADVANCED', } diff --git a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts index 49c9042e5..9d1b22e58 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts @@ -53,7 +53,7 @@ import { UnreadAsyncQuestionModel } from './unread-async-question.entity'; import { FilesInterceptor } from '@nestjs/platform-express'; import { QuestionTypeModel } from 'questionType/question-type.entity'; import { AsyncQuestionImageModel } from './asyncQuestionImage.entity'; - +import { RedisProfileService } from 'redisProfile/redis-profile.service'; @Controller('asyncQuestions') @UseGuards(JwtAuthGuard, EmailVerifiedGuard) export class asyncQuestionController { @@ -62,6 +62,7 @@ export class asyncQuestionController { private readonly appConfig: ApplicationConfigService, private readonly asyncQuestionService: AsyncQuestionService, private readonly chatbotApiService: ChatbotApiService, + private readonly redisProfileService: RedisProfileService, ) {} @Post('vote/:qid/:vote') @@ -457,6 +458,7 @@ export class asyncQuestionController { processedImageBuffers, true, ); + console.log(chatbotResponse); question.aiAnswerText = chatbotResponse.answer; question.answerText = chatbotResponse.answer; await this.asyncQuestionService.saveImageDescriptions( @@ -464,6 +466,10 @@ export class asyncQuestionController { transactionalEntityManager, ); } + // delete the old redis cache since chat_token 'used' got updated (only do if max_uses - used < 10) + if (user.chat_token.max_uses - user.chat_token.used < 10) { + await this.redisProfileService.deleteProfile(`u:${user.id}`); + } } // save the changes await transactionalEntityManager.save(question); diff --git a/packages/server/src/asyncQuestion/asyncQuestion.module.ts b/packages/server/src/asyncQuestion/asyncQuestion.module.ts index 6754facb6..7c9c3ccfa 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.module.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.module.ts @@ -6,6 +6,7 @@ import { MailModule, MailTestingModule } from 'mail/mail.module'; import { RedisQueueService } from '../redisQueue/redis-queue.service'; import { ApplicationConfigService } from '../config/application_config.service'; import { ChatbotApiService } from '../chatbot/chatbot-api.service'; +import { RedisProfileService } from 'redisProfile/redis-profile.service'; @Module({ controllers: [asyncQuestionController], providers: [ @@ -13,6 +14,7 @@ import { ChatbotApiService } from '../chatbot/chatbot-api.service'; RedisQueueService, ApplicationConfigService, ChatbotApiService, + RedisProfileService, ], imports: [ NotificationModule, @@ -31,12 +33,14 @@ export class asyncQuestionModule {} AsyncQuestionService, ApplicationConfigService, ChatbotApiService, + RedisProfileService, ], imports: [ NotificationModule, MailTestingModule, ApplicationConfigService, ChatbotApiService, + RedisProfileService, ], exports: [AsyncQuestionService], }) diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index f6d721923..4f2901abf 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -23,7 +23,7 @@ import * as Sentry from '@sentry/nestjs'; import { UnreadAsyncQuestionModel } from './unread-async-question.entity'; import { ChatbotApiService } from 'chatbot/chatbot-api.service'; import { AsyncQuestionImageModel } from './asyncQuestionImage.entity'; -import { EntityManager, getManager, In } from 'typeorm'; +import { EntityManager, In } from 'typeorm'; import * as checkDiskSpace from 'check-disk-space'; import * as path from 'path'; import * as sharp from 'sharp'; diff --git a/packages/server/src/profile/profile.controller.ts b/packages/server/src/profile/profile.controller.ts index cef5f3fcc..e6dcb7424 100644 --- a/packages/server/src/profile/profile.controller.ts +++ b/packages/server/src/profile/profile.controller.ts @@ -24,7 +24,7 @@ import * as fs from 'fs'; import { memoryStorage } from 'multer'; import * as path from 'path'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; -import { User } from '../decorators/user.decorator'; +import { User, UserId } from '../decorators/user.decorator'; import { UserModel } from './user.entity'; import { ProfileService } from './profile.service'; import { EmailVerifiedGuard } from 'guards/email-verified.guard'; @@ -85,6 +85,12 @@ export class ProfileController { } } + @Delete('/clear_cache') + @UseGuards(JwtAuthGuard, EmailVerifiedGuard) + async clearCache(@UserId() userId: number): Promise { + await this.redisProfileService.deleteProfile(`u:${userId}`); + } + @Patch() @UseGuards(JwtAuthGuard, EmailVerifiedGuard) async patch( From c93dca6422921ad61970a1a99575daf658d7e5d9 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Tue, 15 Apr 2025 16:54:11 -0700 Subject: [PATCH 17/20] fixed issue with course preference table overflowing on mobile due to long instructor emails --- .../(dashboard)/components/EditCourseForm.tsx | 2 +- .../organization/course/add/page.tsx | 2 +- .../profile/components/CoursePreference.tsx | 17 ++++++++++------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx b/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx index 185c167c5..b32b4744c 100644 --- a/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx +++ b/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx @@ -152,7 +152,7 @@ const EditCourseForm: React.FC = ({ diff --git a/packages/frontend/app/(dashboard)/organization/course/add/page.tsx b/packages/frontend/app/(dashboard)/organization/course/add/page.tsx index a33de203e..3543a2985 100644 --- a/packages/frontend/app/(dashboard)/organization/course/add/page.tsx +++ b/packages/frontend/app/(dashboard)/organization/course/add/page.tsx @@ -188,7 +188,7 @@ export default function AddCoursePage(): ReactElement { diff --git a/packages/frontend/app/(dashboard)/profile/components/CoursePreference.tsx b/packages/frontend/app/(dashboard)/profile/components/CoursePreference.tsx index 474053474..b36b2321d 100644 --- a/packages/frontend/app/(dashboard)/profile/components/CoursePreference.tsx +++ b/packages/frontend/app/(dashboard)/profile/components/CoursePreference.tsx @@ -6,7 +6,6 @@ import { getErrorMessage } from '@/app/utils/generalUtils' import { ExclamationCircleOutlined } from '@ant-design/icons' import { UserCourse } from '@koh/common' import { Button, message, Modal, Table, TableColumnsType } from 'antd' -import useSWR from 'swr' const { confirm } = Modal @@ -53,12 +52,6 @@ const CoursePreference: React.FC = () => { }) } - const InstructorCell = ({ courseId }: { courseId: number }) => { - const course = useCourse(courseId) - - return <>{course.course?.coordinator_email} - } - const columns: TableColumnsType = [ { title: 'Course name', @@ -129,4 +122,14 @@ const CoursePreference: React.FC = () => { ) } +const InstructorCell = ({ courseId }: { courseId: number }) => { + const course = useCourse(courseId) + + return ( +
+ {course.course?.coordinator_email} +
+ ) +} + export default CoursePreference From 99057ab1d7c7adb88f1679926c59886f65d3d32d Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Tue, 15 Apr 2025 18:14:24 -0700 Subject: [PATCH 18/20] added async question source document support (I called them 'citations' since SourceDocument was unclear on what it was referring to exactly) --- packages/common/index.ts | 1 + .../components/AsyncQuestionCard.tsx | 5 + .../modals/CreateAsyncQuestionModal.tsx | 7 + .../[cid]/components/chatbot/Chatbot.tsx | 124 +----------------- .../chatbot/SourceLinkCitationButton.tsx | 44 +++++++ .../chatbot/SourceLinkCitations.tsx | 77 +++++++++++ packages/server/ormconfig.ts | 4 +- .../asyncQuestion/asyncQuestion.controller.ts | 27 +++- .../src/asyncQuestion/asyncQuestion.entity.ts | 11 ++ .../asyncQuestion/asyncQuestion.service.ts | 36 +++++ .../src/chatbot/questionDocument.entity.ts | 48 +++++-- .../studentTaskProgress.entity.ts | 2 +- 12 files changed, 255 insertions(+), 131 deletions(-) create mode 100644 packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/SourceLinkCitationButton.tsx create mode 100644 packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/SourceLinkCitations.tsx diff --git a/packages/common/index.ts b/packages/common/index.ts index d8014250c..c8d5b37f8 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -793,6 +793,7 @@ export type AsyncQuestion = { comments: AsyncQuestionComment[] votesSum: number images: AsyncQuestionImage[] + citations: SourceDocument[] } export type AsyncQuestionImage = { diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx index 19ff617e5..c911b9290 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx @@ -24,6 +24,7 @@ import { AsyncQuestionCardUIReducer, initialUIState, } from './AsyncQuestionCardUIReducer' +import SourceLinkCitations from '../../components/chatbot/SourceLinkCitations' const statusDisplayMap = { // if the question has no answer text, it will say "awaiting answer" @@ -416,6 +417,10 @@ const AsyncQuestionCard: React.FC = ({ {thinkText ? cleanAnswer : question.answerText} + )}
diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx index 20b70a85a..53c83f5a6 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx @@ -186,6 +186,13 @@ const CreateAsyncQuestionModal: React.FC = ({ message.error('Error creating question:' + errorMessage) }) .finally(() => { + setUserInfo({ + ...userInfo, + chat_token: { + ...userInfo.chat_token, + used: userInfo.chat_token.used + 1, + }, + }) setIsLoading(false) }) } diff --git a/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/Chatbot.tsx b/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/Chatbot.tsx index 70cd2538e..ae95fafd2 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/Chatbot.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/Chatbot.tsx @@ -37,6 +37,8 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' import { PreDeterminedQuestion, Role, Message } from '@koh/common' import { Bot } from 'lucide-react' +import SourceLinkCitationButton from './SourceLinkCitationButton' +import SourceLinkCitations from './SourceLinkCitations' const { TextArea } = Input @@ -243,13 +245,6 @@ const Chatbot: React.FC = ({ setInput('') } - const extractLMSLink = (content?: string) => { - if (!content) return undefined - const idx = content.indexOf('Page Link:') - if (idx < 0) return undefined - return content.substring(idx + 'Page Link:'.length).trim() - } - if (!cid || !courseFeatures?.chatBotEnabled) { return <> } else { @@ -448,76 +443,10 @@ const Chatbot: React.FC = ({ )}
-
- {item.sourceDocuments && - chatbotQuestionType === 'System' ? ( -
-

User Guide

- -
- ) : ( - item.sourceDocuments && - item.sourceDocuments.map( - (sourceDocument, idx) => ( - -
-

- {sourceDocument.docName} -

- {sourceDocument.type == - 'inserted_lms_document' && - extractLMSLink( - sourceDocument.content, - ) && ( - - )} - {sourceDocument.pageNumbers && - sourceDocument.pageNumbers.map( - (part) => ( - - ), - )} -
-
- ), - ) - )} -
+ {item.type === 'apiMessage' && index === messages.length - 1 && index !== 0 && ( @@ -634,44 +563,3 @@ const Chatbot: React.FC = ({ } export default Chatbot - -const SourceLinkButton: React.FC<{ - docName: string - sourceLink?: string - part?: number - documentText?: string -}> = ({ docName, sourceLink, part, documentText }) => { - if (!sourceLink) { - return null - } - const pageNumber = part && !isNaN(part) ? Number(part) : undefined - - return ( - - -

- {part ? `p. ${part}` : 'Source'} -

-
-
- ) -} diff --git a/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/SourceLinkCitationButton.tsx b/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/SourceLinkCitationButton.tsx new file mode 100644 index 000000000..c6244aaa6 --- /dev/null +++ b/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/SourceLinkCitationButton.tsx @@ -0,0 +1,44 @@ +import { Tooltip } from 'antd' + +const SourceLinkCitationButton: React.FC<{ + docName: string + sourceLink?: string + part?: number + documentText?: string +}> = ({ docName, sourceLink, part, documentText }) => { + if (!sourceLink) { + return null + } + const pageNumber = part && !isNaN(part) ? Number(part) : undefined + + return ( + + +

+ {part ? `p. ${part}` : 'Source'} +

+
+
+ ) +} + +export default SourceLinkCitationButton diff --git a/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/SourceLinkCitations.tsx b/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/SourceLinkCitations.tsx new file mode 100644 index 000000000..962310506 --- /dev/null +++ b/packages/frontend/app/(dashboard)/course/[cid]/components/chatbot/SourceLinkCitations.tsx @@ -0,0 +1,77 @@ +import { SourceDocument } from '@koh/common' +import { ChatbotQuestionType } from '@/app/typings/chatbot' +import SourceLinkCitationButton from './SourceLinkCitationButton' +import { Tooltip } from 'antd' + +interface SourceLinkCitationsProps { + sourceDocuments: SourceDocument[] | undefined + chatbotQuestionType: ChatbotQuestionType +} + +const extractLMSLink = (content?: string) => { + if (!content) return undefined + const idx = content.indexOf('Page Link:') + if (idx < 0) return undefined + return content.substring(idx + 'Page Link:'.length).trim() +} + +const SourceLinkCitations: React.FC = ({ + sourceDocuments, + chatbotQuestionType, +}) => { + if (!sourceDocuments) return null + return ( +
+ {chatbotQuestionType === 'System' ? ( +
+

User Guide

+ +
+ ) : ( + sourceDocuments.map((sourceDocument, idx) => ( + +
+

{sourceDocument.docName}

+ {sourceDocument.type == 'inserted_lms_document' && + extractLMSLink(sourceDocument.content) && ( + + )} + {sourceDocument.pageNumbers && + sourceDocument.pageNumbers.map((part) => ( + + ))} +
+
+ )) + )} +
+ ) +} + +export default SourceLinkCitations diff --git a/packages/server/ormconfig.ts b/packages/server/ormconfig.ts index d89b74103..9efff8a1a 100644 --- a/packages/server/ormconfig.ts +++ b/packages/server/ormconfig.ts @@ -16,7 +16,7 @@ import { QuestionTypeModel } from './src/questionType/question-type.entity'; import { AsyncQuestionModel } from './src/asyncQuestion/asyncQuestion.entity'; import { ChatbotQuestionModel } from './src/chatbot/question.entity'; import { InteractionModel } from './src/chatbot/interaction.entity'; -import { QuestionDocumentModel } from './src/chatbot/questionDocument.entity'; +import { ChatbotQuestionSourceDocumentCitationModel } from './src/chatbot/questionDocument.entity'; import { CalendarModel } from './src/calendar/calendar.entity'; import { CalendarStaffModel } from './src/calendar/calendar-staff.entity'; import { OrganizationUserModel } from './src/organization/organization-user.entity'; @@ -96,7 +96,7 @@ const typeorm = { CalendarModel, CalendarStaffModel, LastRegistrationModel, - QuestionDocumentModel, + ChatbotQuestionSourceDocumentCitationModel, OrganizationUserModel, OrganizationModel, OrganizationCourseModel, diff --git a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts index 9d1b22e58..37065ebd1 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.controller.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.controller.ts @@ -54,6 +54,7 @@ import { FilesInterceptor } from '@nestjs/platform-express'; import { QuestionTypeModel } from 'questionType/question-type.entity'; import { AsyncQuestionImageModel } from './asyncQuestionImage.entity'; import { RedisProfileService } from 'redisProfile/redis-profile.service'; +import { ChatbotQuestionSourceDocumentCitationModel } from 'chatbot/questionDocument.entity'; @Controller('asyncQuestions') @UseGuards(JwtAuthGuard, EmailVerifiedGuard) export class asyncQuestionController { @@ -122,6 +123,7 @@ export class asyncQuestionController { 'comments.creator', 'comments.creator.courses', 'images', + 'citations', ], }); @@ -250,6 +252,12 @@ export class asyncQuestionController { imageDescriptions, transactionalEntityManager, ); + + await this.asyncQuestionService.saveCitations( + chatbotResponse.sourceDocuments, + question, + transactionalEntityManager, + ); } questionId = question.id; }); @@ -271,6 +279,7 @@ export class asyncQuestionController { 'comments.creator', 'comments.creator.courses', 'images', + 'citations', ], }); @@ -458,13 +467,22 @@ export class asyncQuestionController { processedImageBuffers, true, ); - console.log(chatbotResponse); question.aiAnswerText = chatbotResponse.answer; question.answerText = chatbotResponse.answer; await this.asyncQuestionService.saveImageDescriptions( chatbotResponse.imageDescriptions, transactionalEntityManager, ); + // first delete old citations, then add the new ones + await this.asyncQuestionService.deleteExistingCitations( + questionId, + transactionalEntityManager, + ); + await this.asyncQuestionService.saveCitations( + chatbotResponse.sourceDocuments, + question, + transactionalEntityManager, + ); } // delete the old redis cache since chat_token 'used' got updated (only do if max_uses - used < 10) if (user.chat_token.max_uses - user.chat_token.used < 10) { @@ -486,6 +504,7 @@ export class asyncQuestionController { 'comments.creator', 'comments.creator.courses', 'images', + 'citations', ], }, ); @@ -543,6 +562,7 @@ export class asyncQuestionController { 'comments.creator', 'comments.creator.courses', 'images', + 'citations', ], }); // deep copy question since it changes @@ -683,6 +703,7 @@ export class asyncQuestionController { 'comments.creator', 'comments.creator.courses', 'images', + 'citations', ], }); @@ -802,6 +823,7 @@ export class asyncQuestionController { 'comments.creator', 'comments.creator.courses', 'images', + 'citations', ], }); @@ -880,6 +902,7 @@ export class asyncQuestionController { 'comments.creator', 'comments.creator.courses', 'images', + 'citations', ], }); @@ -935,6 +958,7 @@ export class asyncQuestionController { 'comments.creator', 'comments.creator.courses', 'images', + 'citations', ], order: { createdAt: 'DESC', @@ -993,6 +1017,7 @@ export class asyncQuestionController { 'votesSum', 'isTaskQuestion', 'images', + 'citations', ]); if (!question.comments) { diff --git a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts index d289095fc..404f196d3 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.entity.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.entity.ts @@ -20,6 +20,7 @@ import { QuestionTypeModel } from '../questionType/question-type.entity'; import { AsyncQuestionCommentModel } from './asyncQuestionComment.entity'; import { UnreadAsyncQuestionModel } from './unread-async-question.entity'; import { AsyncQuestionImageModel } from './asyncQuestionImage.entity'; +import { ChatbotQuestionSourceDocumentCitationModel } from 'chatbot/questionDocument.entity'; @Entity('async_question_model') export class AsyncQuestionModel extends BaseEntity { @@ -108,4 +109,14 @@ export class AsyncQuestionModel extends BaseEntity { @OneToMany(() => AsyncQuestionImageModel, (image) => image.asyncQuestion) images: AsyncQuestionImageModel[]; + + @OneToMany( + () => ChatbotQuestionSourceDocumentCitationModel, + (citation) => citation.asyncQuestion, + ) + citations: ChatbotQuestionSourceDocumentCitationModel[]; + + // this is the chatbot question id of the aiAnswer + @Column({ nullable: true }) + chatbotQuestionId: string; } diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index 4f2901abf..d96fef6bd 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -5,6 +5,7 @@ import { ERROR_MESSAGES, MailServiceType, Role, + SourceDocument, UpdateAsyncQuestions, } from '@koh/common'; import { @@ -27,6 +28,7 @@ import { EntityManager, In } from 'typeorm'; import * as checkDiskSpace from 'check-disk-space'; import * as path from 'path'; import * as sharp from 'sharp'; +import { ChatbotQuestionSourceDocumentCitationModel } from 'chatbot/questionDocument.entity'; @Injectable() export class AsyncQuestionService { @@ -707,6 +709,40 @@ export class AsyncQuestionService { body.refreshAIAnswer = body.refreshAIAnswer.toString() === 'true'; } } + + async deleteExistingCitations( + questionId: number, + transactionalEntityManager: EntityManager, + ) { + await transactionalEntityManager.delete( + ChatbotQuestionSourceDocumentCitationModel, + { asyncQuestionId: questionId }, + ); + } + + async saveCitations( + sourceDocuments: SourceDocument[], + question: AsyncQuestionModel, + transactionalEntityManager: EntityManager, + ) { + const citations = sourceDocuments.map((sourceDocument) => { + const citation = new ChatbotQuestionSourceDocumentCitationModel(); + citation.asyncQuestion = question; + citation.pageContent = sourceDocument.pageContent; + citation.content = sourceDocument.content; + citation.sourceDocumentChunkId = sourceDocument.id; + citation.metadata = sourceDocument.metadata; + citation.docName = sourceDocument.docName; + citation.sourceLink = sourceDocument.sourceLink; + citation.pageNumbers = sourceDocument.pageNumbers; + citation.pageNumber = sourceDocument.pageNumber; + return citation; + }); + await transactionalEntityManager.save( + ChatbotQuestionSourceDocumentCitationModel, + citations, + ); + } } export interface tempFile { diff --git a/packages/server/src/chatbot/questionDocument.entity.ts b/packages/server/src/chatbot/questionDocument.entity.ts index 8a0c5921d..0a406bb6c 100644 --- a/packages/server/src/chatbot/questionDocument.entity.ts +++ b/packages/server/src/chatbot/questionDocument.entity.ts @@ -6,22 +6,52 @@ import { ManyToOne, PrimaryGeneratedColumn, } from 'typeorm'; -import { ChatbotQuestionModel } from './question.entity'; +import { AsyncQuestionModel } from '../asyncQuestion/asyncQuestion.entity'; +import { ChatbotChunkMetadata } from '@koh/common'; -@Entity('question_document_model') -export class QuestionDocumentModel extends BaseEntity { +/* This is just the citations (i.e. when a question is asked, the relevant source document chunks are gathered and stored here) */ +@Entity('chatbot_question_source_document_citation_model') +export class ChatbotQuestionSourceDocumentCitationModel extends BaseEntity { @PrimaryGeneratedColumn() - public id: number; + citationId: number; @Column() - questionId: number; + sourceDocumentChunkId: string; @Column() - name: string; + pageContent: string; + + @Column({ nullable: true }) + content: string; + + @Column() + docName: string; + + @Column({ type: 'jsonb' }) + metadata: ChatbotChunkMetadata; + + @Column({ nullable: true }) + sourceLink: string; + + @Column('integer', { array: true, nullable: true }) + pageNumbers: number[]; + + @Column({ nullable: true }) + pageNumber: number; + // not relating it to chatbot questions themselves just yet TODO: do this + // (partially because we get the source documents from the chatbot repo api and not from helpme db) + // @ManyToOne(() => ChatbotQuestionModel) + // @JoinColumn({ name: 'questionId' }) + // question: ChatbotQuestionModel; @Column() - type: string; + asyncQuestionId: number; - @Column('text', { array: true }) - parts: string[]; + // only really using for async questions atm + @ManyToOne( + () => AsyncQuestionModel, + (asyncQuestion) => asyncQuestion.citations, + ) + @JoinColumn({ name: 'asyncQuestionId' }) + asyncQuestion: AsyncQuestionModel; } diff --git a/packages/server/src/studentTaskProgress/studentTaskProgress.entity.ts b/packages/server/src/studentTaskProgress/studentTaskProgress.entity.ts index 1f0df9581..61b1cd932 100644 --- a/packages/server/src/studentTaskProgress/studentTaskProgress.entity.ts +++ b/packages/server/src/studentTaskProgress/studentTaskProgress.entity.ts @@ -12,7 +12,7 @@ import { StudentTaskProgress } from '@koh/common'; @Entity('student_task_progress_model') export class StudentTaskProgressModel extends BaseEntity { - @Column({ type: 'json', nullable: true }) + @Column({ type: 'json', nullable: true }) // todo: maybe migrate this to jsonb for better querying? taskProgress: StudentTaskProgress; // this is the main item that this entity stores @PrimaryColumn() // two primary columns are needed to make the composite primary key (each studentTaskProgress is uniquely defined by each [cid, uid] combo) From fc95c7527fbba428ce8fa28550292d79a007039c Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Tue, 15 Apr 2025 19:15:53 -0700 Subject: [PATCH 19/20] added ability for staff to delete the source document citations off of async questions. Also refactored the updateQuestionStaff method to use a transaction --- packages/common/index.ts | 5 + .../components/AsyncQuestionCard.tsx | 10 +- .../components/modals/PostResponseModal.tsx | 30 ++- .../chatbot/SourceLinkCitations.tsx | 12 +- .../asyncQuestion/asyncQuestion.controller.ts | 216 ++++++++++-------- .../asyncQuestion/asyncQuestion.service.ts | 47 ++-- 6 files changed, 202 insertions(+), 118 deletions(-) diff --git a/packages/common/index.ts b/packages/common/index.ts index c8d5b37f8..8c6c02881 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -1571,6 +1571,11 @@ export class UpdateAsyncQuestions extends AsyncQuestionParams { @IsOptional() @IsArray() deletedImageIds?: number[] + + // used with staff to delete citations when posting a response + @IsOptional() + @IsBoolean() + deleteCitations?: boolean } export type TAUpdateStatusResponse = QueuePartial diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx index c911b9290..610d58881 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/AsyncQuestionCard.tsx @@ -417,10 +417,12 @@ const AsyncQuestionCard: React.FC = ({ {thinkText ? cleanAnswer : question.answerText} - + {question.citations && question.citations.length > 0 && ( + + )} )}
diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/PostResponseModal.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/PostResponseModal.tsx index 7b83b6539..ab9f83785 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/PostResponseModal.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/PostResponseModal.tsx @@ -16,10 +16,10 @@ import { API } from '@/app/api' import { DeleteOutlined, QuestionCircleOutlined, - RedoOutlined, RollbackOutlined, } from '@ant-design/icons' import { deleteAsyncQuestion } from '../../utils/commonAsyncFunctions' +import SourceLinkCitations from '../../../components/chatbot/SourceLinkCitations' interface FormValues { answerText: string @@ -44,6 +44,7 @@ const PostResponseModal: React.FC = ({ const [isLoading, setIsLoading] = useState(false) const [deleteLoading, setDeleteLoading] = useState(false) const [saveToChatbot, setSaveToChatbot] = useState(true) + const [deleteCitations, setDeleteCitations] = useState(false) // put into state rather than FormValues since we need to change(re-render) how the source links appear when toggled const onFinish = async (values: FormValues) => { setIsLoading(true) @@ -66,6 +67,7 @@ const PostResponseModal: React.FC = ({ status: newStatus, verified: values.verified, saveToChatbot: saveToChatbot, + deleteCitations: deleteCitations, }) .then(() => { message.success('Response Successfully Posted/Edited') @@ -188,6 +190,32 @@ const PostResponseModal: React.FC = ({ }} />
+ {question.citations && question.citations.length > 0 && ( + +
+ + + setDeleteCitations(e.target.checked)} + /> + +
+
+ )} { @@ -18,6 +19,7 @@ const extractLMSLink = (content?: string) => { const SourceLinkCitations: React.FC = ({ sourceDocuments, chatbotQuestionType, + appearDeleted = false, }) => { if (!sourceDocuments) return null return ( @@ -42,7 +44,15 @@ const SourceLinkCitations: React.FC = ({ key={idx} >
-

{sourceDocument.docName}

+ {appearDeleted ? ( + +

+ {sourceDocument.docName} +

+
+ ) : ( +

{sourceDocument.docName}

+ )} {sourceDocument.type == 'inserted_lms_document' && extractLMSLink(sourceDocument.content) && ( { - const question = await AsyncQuestionModel.findOne({ - where: { id: questionId }, - relations: [ - 'creator', - 'taHelped', - 'votes', - 'comments', - 'comments.creator', - 'comments.creator.courses', - 'images', - 'citations', - ], - }); - // deep copy question since it changes - const oldQuestion: AsyncQuestionModel = JSON.parse( - JSON.stringify(question), - ); - - if (!question) { - throw new NotFoundException('Question Not Found'); - } + let question: AsyncQuestionModel | null = null; + const entityManager = getManager(); + await entityManager.transaction(async (transactionalEntityManager) => { + question = await transactionalEntityManager.findOne(AsyncQuestionModel, { + where: { id: questionId }, + relations: [ + 'creator', + 'taHelped', + 'votes', + 'comments', + 'comments.creator', + 'comments.creator.courses', + 'images', + 'citations', + ], + }); + // deep copy question since it changes + const oldQuestion: AsyncQuestionModel = JSON.parse( + JSON.stringify(question), + ); - const courseId = question.courseId; + if (!question) { + throw new NotFoundException('Question Not Found'); + } - // Verify if user is TA/PROF of the course - const requester = await UserCourseModel.findOne({ - where: { - userId: user.id, - courseId: courseId, - }, - }); + const courseId = question.courseId; - if (!requester || requester.role === Role.STUDENT) { - throw new ForbiddenException( - 'You must be a TA/PROF to update this question', + // Verify if user is TA/PROF of the course + const requester = await transactionalEntityManager.findOne( + UserCourseModel, + { + where: { + userId: user.id, + courseId: courseId, + }, + }, ); - } - Object.keys(body).forEach((key) => { - if (body[key] !== undefined && body[key] !== null) { - question[key] = body[key]; + if (!requester || requester.role === Role.STUDENT) { + throw new ForbiddenException( + 'You must be a TA/PROF to update this question', + ); } - }); - if (body.status === asyncQuestionStatus.HumanAnswered) { - question.closedAt = new Date(); - question.taHelpedId = user.id; - await this.asyncQuestionService.sendQuestionAnsweredEmail(question); - } else if ( - body.status !== asyncQuestionStatus.TADeleted && - body.status !== asyncQuestionStatus.StudentDeleted - ) { - // don't send status change email if its deleted - // (I don't like the vibes of notifying a student that their question was deleted by staff) - // Though technically speaking this isn't even really used yet since there isn't a status that the TA would really turn it to that isn't HumanAnswered or TADeleted - await this.asyncQuestionService.sendGenericStatusChangeEmail( - question, - body.status, - ); - } + Object.keys(body).forEach((key) => { + if (body[key] !== undefined && body[key] !== null) { + question[key] = body[key]; + } + }); - const updatedQuestion = await question.save(); + if (body.status === asyncQuestionStatus.HumanAnswered) { + question.closedAt = new Date(); + question.taHelpedId = user.id; + await this.asyncQuestionService.sendQuestionAnsweredEmail(question); + } else if ( + body.status !== asyncQuestionStatus.TADeleted && + body.status !== asyncQuestionStatus.StudentDeleted + ) { + // don't send status change email if its deleted + // (I don't like the vibes of notifying a student that their question was deleted by staff) + // Though technically speaking this isn't even really used yet since there isn't a status that the TA would really turn it to that isn't HumanAnswered or TADeleted + await this.asyncQuestionService.sendGenericStatusChangeEmail( + question, + body.status, + ); + } - // if saveToChatbot is true, add the question to the chatbot - if (body.saveToChatbot) { - await this.asyncQuestionService.upsertQAToChatbot( - updatedQuestion, - courseId, - user.chat_token.token, - ); - } + const updatedQuestion = await transactionalEntityManager.save(question); - // Mark as new unread for all students if the question is marked as visible - if (body.visible && !oldQuestion.visible) { - await this.asyncQuestionService.markUnreadForRoles( - updatedQuestion, - [Role.STUDENT], - user.id, - ); - } - // When the question creator gets their question human verified, notify them - if ( - oldQuestion.status !== asyncQuestionStatus.HumanAnswered && - !oldQuestion.verified && - (body.status === asyncQuestionStatus.HumanAnswered || - body.verified === true) - ) { - await this.asyncQuestionService.markUnreadForCreator(updatedQuestion); - } + if (body.deleteCitations) { + await this.asyncQuestionService.deleteExistingCitations( + questionId, + transactionalEntityManager, + ); + question.citations = []; + } + // if saveToChatbot is true, add the question to the chatbot + if (body.saveToChatbot) { + await this.asyncQuestionService.upsertQAToChatbot( + updatedQuestion, + courseId, + user.chat_token.token, + ); + } - if ( - body.status === asyncQuestionStatus.TADeleted || - body.status === asyncQuestionStatus.StudentDeleted - ) { - await this.redisQueueService.deleteAsyncQuestion( - `c:${courseId}:aq`, - updatedQuestion, - ); - // delete all unread notifications for this question - await UnreadAsyncQuestionModel.delete({ asyncQuestionId: questionId }); - } else { - await this.redisQueueService.updateAsyncQuestion( - `c:${courseId}:aq`, - updatedQuestion, - ); - } + // Mark as new unread for all students if the question is marked as visible + if (body.visible && !oldQuestion.visible) { + await this.asyncQuestionService.markUnreadForRoles( + updatedQuestion, + [Role.STUDENT], + user.id, + transactionalEntityManager, + ); + } + // When the question creator gets their question human verified, notify them + if ( + oldQuestion.status !== asyncQuestionStatus.HumanAnswered && + !oldQuestion.verified && + (body.status === asyncQuestionStatus.HumanAnswered || + body.verified === true) + ) { + await this.asyncQuestionService.markUnreadForCreator( + updatedQuestion, + transactionalEntityManager, + ); + } - delete question.taHelped; - delete question.votes; + if ( + body.status === asyncQuestionStatus.TADeleted || + body.status === asyncQuestionStatus.StudentDeleted + ) { + await this.redisQueueService.deleteAsyncQuestion( + `c:${courseId}:aq`, + updatedQuestion, + ); + // delete all unread notifications for this question + await transactionalEntityManager.delete(UnreadAsyncQuestionModel, { + asyncQuestionId: questionId, + }); + } else { + await this.redisQueueService.updateAsyncQuestion( + `c:${courseId}:aq`, + updatedQuestion, + ); + } + + delete question.taHelped; + delete question.votes; + }); return question; } diff --git a/packages/server/src/asyncQuestion/asyncQuestion.service.ts b/packages/server/src/asyncQuestion/asyncQuestion.service.ts index d96fef6bd..58c42725f 100644 --- a/packages/server/src/asyncQuestion/asyncQuestion.service.ts +++ b/packages/server/src/asyncQuestion/asyncQuestion.service.ts @@ -174,9 +174,10 @@ export class AsyncQuestionService { content: `
A new question has been posted on the Anytime Question Hub and has been marked as needing attention:
${question.questionAbstract ? `Question Abstract: ${question.questionAbstract}` : ''}
${question.questionTypes?.length > 0 ? `Question Types: ${question.questionTypes.map((qt) => qt.name).join(', ')}` : ''} -
${question.questionText ? `Question Text: ${question.questionText}` : ''} + ${question.questionText ? `
Question Text: ${question.questionText}` : ''} + ${question.images?.length > 0 ? `
Question also has ${question.images.length} image${question.images.length === 1 ? '' : 's'}.` : ''}
-
Do NOT reply to this email. View and Answer It Here
`, +
Do NOT reply to this email. View and Answer It Here
`, }), ), ); @@ -386,18 +387,36 @@ export class AsyncQuestionService { } } - async markUnreadForCreator(question: AsyncQuestionModel) { - await UnreadAsyncQuestionModel.createQueryBuilder() - .update(UnreadAsyncQuestionModel) - .set({ readLatest: false }) - .where('asyncQuestionId = :asyncQuestionId', { - asyncQuestionId: question.id, - }) - .andWhere( - `userId = :userId`, - { userId: question.creatorId }, // notify ONLY question creator - ) - .execute(); + async markUnreadForCreator( + question: AsyncQuestionModel, + transactionalEntityManager?: EntityManager, + ) { + if (transactionalEntityManager) { + await transactionalEntityManager + .createQueryBuilder() + .update(UnreadAsyncQuestionModel) + .set({ readLatest: false }) + .where('asyncQuestionId = :asyncQuestionId', { + asyncQuestionId: question.id, + }) + .andWhere( + `userId = :userId`, + { userId: question.creatorId }, // notify ONLY question creator + ) + .execute(); + } else { + await UnreadAsyncQuestionModel.createQueryBuilder() + .update(UnreadAsyncQuestionModel) + .set({ readLatest: false }) + .where('asyncQuestionId = :asyncQuestionId', { + asyncQuestionId: question.id, + }) + .andWhere( + `userId = :userId`, + { userId: question.creatorId }, // notify ONLY question creator + ) + .execute(); + } } async upsertQAToChatbot( From bb04a3e2d361d6c1cbcb82dfb0ae2285ebb17233 Mon Sep 17 00:00:00 2001 From: Adam Fipke Date: Tue, 15 Apr 2025 20:36:59 -0700 Subject: [PATCH 20/20] added ability to paste images inside of the question text to upload --- .../modals/CreateAsyncQuestionModal.tsx | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx index 53c83f5a6..f57da2aeb 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/async_centre/components/modals/CreateAsyncQuestionModal.tsx @@ -86,6 +86,42 @@ const CreateAsyncQuestionModal: React.FC = ({ setPreviewOpen(true) } + // Handle pasting images from clipboard + const handlePaste = (e: React.ClipboardEvent) => { + const items = e.clipboardData.items + const MAX_IMAGES = 8 + const currentImages = form.getFieldValue('images') || [] + + if (currentImages.length >= MAX_IMAGES) { + message.warning(`Maximum ${MAX_IMAGES} images allowed`) + return + } + + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + const file = items[i].getAsFile() + if (file) { + // Create a unique name for the pasted image + const fileName = `pasted-image-${Date.now()}.png` + + const uploadFile = { + uid: `paste-${Date.now()}`, + name: fileName, + status: 'done', + originFileObj: file, + } as UploadFile + + // Add the pasted image to the form + const newFileList = [...currentImages, uploadFile] + form.setFieldsValue({ images: newFileList }) + + message.success('Image pasted successfully') + break // Only process one image per paste event + } + } + } + } + const onFinish = async (values: FormValues) => { setIsLoading(true) const newQuestionTypeInput = @@ -300,12 +336,13 @@ const CreateAsyncQuestionModal: React.FC = ({