diff --git a/course-matrix/backend/__tests__/getMinHourDay.test.ts b/course-matrix/backend/__tests__/getMinHourDay.test.ts new file mode 100644 index 00000000..5703db76 --- /dev/null +++ b/course-matrix/backend/__tests__/getMinHourDay.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, test } from "@jest/globals"; + +import { Offering } from "../src/types/generatorTypes"; +import { + createOffering, + getMinHour, + getMinHourDay, +} from "../src/utils/generatorHelpers"; + +describe("getMinHourDay function", () => { + it("Back to back to back courses", async () => { + const offering1: Offering = createOffering({ + id: 1, + course_id: 101, + day: "MO", + start: "09:00:00", + end: "10:00:00", + }); + const offering2: Offering = createOffering({ + id: 2, + course_id: 102, + day: "MO", + start: "10:00:00", + end: "11:00:00", + }); + const offering3: Offering = createOffering({ + id: 3, + course_id: 103, + day: "MO", + start: "11:00:00", + end: "12:00:00", + }); + const schedule: Offering[] = [offering1, offering2, offering3]; + + const result = getMinHourDay(schedule, 0); + + expect(result).toBe(true); + }); + + it("courses that has a max gap of 4 hours", async () => { + const offering1: Offering = createOffering({ + id: 1, + course_id: 101, + day: "MO", + start: "09:00:00", + end: "10:00:00", + }); + const offering2: Offering = createOffering({ + id: 2, + course_id: 102, + day: "MO", + start: "10:00:00", + end: "11:00:00", + }); + const offering3: Offering = createOffering({ + id: 3, + course_id: 103, + day: "MO", + start: "15:00:00", + end: "16:00:00", + }); + const schedule: Offering[] = [offering3, offering2, offering1]; + + const result = getMinHourDay(schedule, 3); + + expect(result).toBe(false); + }); + + it("only 1 offering in list, return 0", async () => { + const offering1: Offering = createOffering({ + id: 1, + course_id: 101, + day: "MO", + start: "09:00:00", + end: "10:00:00", + }); + const schedule: Offering[] = [offering1]; + + const result = getMinHourDay(schedule, 23); + + expect(result).toBe(true); + }); + + it("getMinHour test", async () => { + const arr_day = [ + "MO", + "MO", + "TU", + "TH", + "FR", + "MO", + "TU", + "TH", + "MO", + "MO", + ]; + const arr_start = [ + "09:00:00", + "10:00:00", + "09:00:00", + "12:00:00", + "13:00:00", + "12:00:00", + "14:00:00", + "16:00:00", + "13:00:00", + "15:00:00", + ]; + const arr_end = [ + "10:00:00", + "11:00:00", + "10:00:00", + "15:00:00", + "16:00:00", + "13:00:00", + "19:00:00", + "18:00:00", + "14:00:00", + "18:00:00", + ]; + const schedule: Offering[] = []; + for (let i = 0; i < 10; i++) { + schedule.push( + createOffering({ + id: i, + course_id: 100 + i, + day: arr_day[i], + start: arr_start[i], + end: arr_end[i], + }), + ); + } + + const result = getMinHour(schedule, 4); + + expect(result).toEqual(true); + }); + + it("getMinHour test 2", async () => { + const arr_day = [ + "MO", + "MO", + "TU", + "TH", + "FR", + "MO", + "TU", + "TH", + "MO", + "MO", + ]; + const arr_start = [ + "09:00:00", + "10:00:00", + "09:00:00", + "12:00:00", + "13:00:00", + "12:00:00", + "14:00:00", + "16:00:00", + "13:00:00", + "15:00:00", + ]; + const arr_end = [ + "10:00:00", + "11:00:00", + "10:00:00", + "15:00:00", + "16:00:00", + "13:00:00", + "19:00:00", + "18:00:00", + "14:00:00", + "18:00:00", + ]; + const schedule: Offering[] = []; + for (let i = 0; i < 10; i++) { + schedule.push( + createOffering({ + id: i, + course_id: 100 + i, + day: arr_day[i], + start: arr_start[i], + end: arr_end[i], + }), + ); + } + + const result = getMinHour(schedule, 3); + + expect(result).toEqual(false); + }); +}); diff --git a/course-matrix/backend/__tests__/isValidOffering.test.ts b/course-matrix/backend/__tests__/isValidOffering.test.ts index 70a28cb8..f994bba6 100644 --- a/course-matrix/backend/__tests__/isValidOffering.test.ts +++ b/course-matrix/backend/__tests__/isValidOffering.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, test } from "@jest/globals"; -import { createOffering, isValidOffering } from "../src/utils/generatorHelpers"; import { Offering, Restriction, RestrictionType, } from "../src/types/generatorTypes"; +import { createOffering, isValidOffering } from "../src/utils/generatorHelpers"; describe("isValidOffering", () => { const sampleOffering: Offering = createOffering({ @@ -29,6 +29,7 @@ describe("isValidOffering", () => { endTime: "09:00:00", disabled: true, numDays: 0, + maxGap: 24, }, ]; expect(isValidOffering(sampleOffering, restrictions)).toBe(true); @@ -43,6 +44,7 @@ describe("isValidOffering", () => { endTime: "11:00:00", disabled: false, numDays: 0, + maxGap: 24, }, ]; expect(isValidOffering(sampleOffering, restrictions)).toBe(false); @@ -57,6 +59,7 @@ describe("isValidOffering", () => { endTime: "", disabled: false, numDays: 0, + maxGap: 24, }, ]; expect(isValidOffering(sampleOffering, restrictions)).toBe(false); @@ -71,6 +74,7 @@ describe("isValidOffering", () => { endTime: "12:00:00", disabled: false, numDays: 0, + maxGap: 24, }, ]; expect(isValidOffering(sampleOffering, restrictions)).toBe(false); @@ -85,6 +89,7 @@ describe("isValidOffering", () => { endTime: "", disabled: false, numDays: 0, + maxGap: 24, }, ]; expect(isValidOffering(sampleOffering, restrictions)).toBe(false); @@ -99,6 +104,7 @@ describe("isValidOffering", () => { endTime: "", disabled: false, numDays: 0, + maxGap: 24, }, ]; expect(isValidOffering(sampleOffering, restrictions)).toBe(true); diff --git a/course-matrix/backend/src/constants/availableFunctions.ts b/course-matrix/backend/src/constants/availableFunctions.ts index 0d540011..5ac1f2c4 100644 --- a/course-matrix/backend/src/constants/availableFunctions.ts +++ b/course-matrix/backend/src/constants/availableFunctions.ts @@ -15,9 +15,11 @@ import { categorizeValidOfferings, getFreq, getMaxDays, + getMaxHour, getValidOfferings, groupOfferings, trim, + shuffle, } from "../utils/generatorHelpers"; // Add all possible function names here @@ -199,6 +201,7 @@ export const availableFunctions: AvailableFunctions = { const courseOfferingsList: OfferingList[] = []; const validCourseOfferingsList: GroupedOfferingList[] = []; const maxdays = getMaxDays(restrictions); + const maxhours = getMaxHour(restrictions); const validSchedules: Offering[][] = []; // Fetch offerings for each course for (const course of courses) { @@ -244,19 +247,22 @@ export const availableFunctions: AvailableFunctions = { validCourseOfferingsList.push(groupedOfferings); } - const categorizedOfferings = categorizeValidOfferings( + let categorizedOfferings = categorizeValidOfferings( validCourseOfferingsList, ); + // console.log(JSON.stringify(categorizedOfferings)); // Generate valid schedules for the given courses and restrictions - await getValidSchedules( + categorizedOfferings = shuffle(categorizedOfferings); + getValidSchedules( validSchedules, categorizedOfferings, [], 0, categorizedOfferings.length, maxdays, + maxhours, + false, ); - // Return error if no valid schedules are found if (validSchedules.length === 0) { return { status: 404, error: "No valid schedules found." }; @@ -445,6 +451,7 @@ ${offeringData.meeting_section} `; disabled: restriction?.disabled, num_days: restriction?.numDays, calendar_id: timetableData?.id, + max_gap: restriction?.maxGap, }, ]) .select(); diff --git a/course-matrix/backend/src/controllers/aiController.ts b/course-matrix/backend/src/controllers/aiController.ts index 3b5c6fe4..92fe16bb 100644 --- a/course-matrix/backend/src/controllers/aiController.ts +++ b/course-matrix/backend/src/controllers/aiController.ts @@ -1,7 +1,9 @@ -import asyncHandler from "../middleware/asyncHandler"; import "openai/shims/node"; -import { Request, Response } from "express"; + import { createOpenAI } from "@ai-sdk/openai"; +import { OpenAIEmbeddings } from "@langchain/openai"; +import { PineconeStore } from "@langchain/pinecone"; +import { Index, Pinecone, RecordMetadata } from "@pinecone-database/pinecone"; import { CoreMessage, generateObject, @@ -11,37 +13,37 @@ import { tool, ToolExecutionError, } from "ai"; -import { Index, Pinecone, RecordMetadata } from "@pinecone-database/pinecone"; -import { PineconeStore } from "@langchain/pinecone"; -import { OpenAIEmbeddings } from "@langchain/openai"; +import { Request, Response } from "express"; import { Document } from "langchain/document"; +import OpenAI from "openai"; +import { z } from "zod"; + import { - NAMESPACE_KEYWORDS, - GENERAL_ACADEMIC_TERMS, - DEPARTMENT_CODES, - ASSISTANT_TERMS, - USEFUL_INFO, - BREADTH_REQUIREMENT_KEYWORDS, - YEAR_LEVEL_KEYWORDS, -} from "../constants/promptKeywords"; + availableFunctions, + FunctionNames, +} from "../constants/availableFunctions"; import { CHATBOT_MEMORY_THRESHOLD, CHATBOT_TIMETABLE_CMD, CHATBOT_TOOL_CALL_MAX_STEPS, + namespaceToMinResults, } from "../constants/constants"; -import { namespaceToMinResults } from "../constants/constants"; -import OpenAI from "openai"; -import { convertBreadthRequirement } from "../utils/convert-breadth-requirement"; -import { convertYearLevel } from "../utils/convert-year-level"; import { - availableFunctions, - FunctionNames, -} from "../constants/availableFunctions"; -import { z } from "zod"; -import { analyzeQuery } from "../utils/analyzeQuery"; -import { includeFilters } from "../utils/includeFilters"; + ASSISTANT_TERMS, + BREADTH_REQUIREMENT_KEYWORDS, + DEPARTMENT_CODES, + GENERAL_ACADEMIC_TERMS, + NAMESPACE_KEYWORDS, + USEFUL_INFO, + YEAR_LEVEL_KEYWORDS, +} from "../constants/promptKeywords"; +import asyncHandler from "../middleware/asyncHandler"; import { TimetableFormSchema } from "../models/timetable-form"; import { CreateTimetableArgs } from "../models/timetable-generate"; +import { analyzeQuery } from "../utils/analyzeQuery"; +import { convertBreadthRequirement } from "../utils/convert-breadth-requirement"; +import { convertYearLevel } from "../utils/convert-year-level"; +import { includeFilters } from "../utils/includeFilters"; const openai = createOpenAI({ baseURL: process.env.OPENAI_BASE_URL, @@ -84,7 +86,8 @@ export async function searchSelectedNamespaces( }); try { - // Search results count given by the min result count for a given namespace (or k if k is greater) + // Search results count given by the min result count for a given + // namespace (or k if k is greater) const results = await namespaceStore.similaritySearch( query, Math.max(k, namespaceToMinResults.get(namespace)), @@ -106,7 +109,8 @@ export async function searchSelectedNamespaces( return allResults; } -// Reformulate user query to make more concise query to database, taking into consideration context +// Reformulate user query to make more concise query to database, taking into +// consideration context export async function reformulateQuery( latestQuery: string, conversationHistory: any[], @@ -197,15 +201,21 @@ export async function reformulateQuery( } /** - * @description Handles user queries and generates responses using GPT-4o, with optional knowledge retrieval. + * @description Handles user queries and generates responses using GPT-4o, with + * optional knowledge retrieval. * * @param {Request} req - The Express request object, containing: - * @param {Object[]} req.body.messages - Array of message objects representing the conversation history. - * @param {string} req.body.messages[].role - The role of the message sender (e.g., "user", "assistant"). - * @param {Object[]} req.body.messages[].content - An array containing message content objects. - * @param {string} req.body.messages[].content[].text - The actual text of the message. + * @param {Object[]} req.body.messages - Array of message objects representing + * the conversation history. + * @param {string} req.body.messages[].role - The role of the message sender + * (e.g., "user", "assistant"). + * @param {Object[]} req.body.messages[].content - An array containing message + * content objects. + * @param {string} req.body.messages[].content[].text - The actual text of the + * message. * - * @param {Response} res - The Express response object used to stream the generated response. + * @param {Response} res - The Express response object used to stream the + * generated response. * * @returns {void} Responds with a streamed text response of the AI output * @@ -251,7 +261,9 @@ export const chat = asyncHandler(async (req: Request, res: Response) => { ## Tool call guidelines - Include the timetable ID in all getTimetables tool call responses - - Link: For every tool call, for each timetable that it gets/deletes/modifies/creates, include a link with it displayed as "View Timetable" to ${process.env.CLIENT_APP_URL}/dashboard/timetable?edit=[[TIMETABLE_ID]] , where TIMETABLE_ID is the id of the respective timetable. + - Link: For every tool call, for each timetable that it gets/deletes/modifies/creates, include a link with it displayed as "View Timetable" to ${ + process.env.CLIENT_APP_URL + }/dashboard/timetable?edit=[[TIMETABLE_ID]] , where TIMETABLE_ID is the id of the respective timetable. - If the user provides a course code of length 6 like CSCA08, then assume they mean CSCA08H3 (H3 appended) - If the user wants to create a timetable, first call getCourses to get course information on the requested courses, then call generateTimetable. - Do not make up fake courses or offerings. @@ -299,7 +311,6 @@ export const chat = asyncHandler(async (req: Request, res: Response) => { "Return a list of possible timetables based on provided courses and restrictions.", parameters: TimetableFormSchema, execute: async (args) => { - console.log("Args for generate: ", args); console.log("restrictions :", JSON.stringify(args.restrictions)); const data = await availableFunctions.generateTimetable( args, @@ -319,7 +330,9 @@ export const chat = asyncHandler(async (req: Request, res: Response) => { }, }), }, - maxSteps: CHATBOT_TOOL_CALL_MAX_STEPS, // Controls how many back and forths the model can take with user or calling multiple tools + maxSteps: CHATBOT_TOOL_CALL_MAX_STEPS, // Controls how many back and forths + // the model can take with user or + // calling multiple tools experimental_repairToolCall: async ({ toolCall, tools, @@ -379,7 +392,8 @@ export const chat = asyncHandler(async (req: Request, res: Response) => { console.log(">>>> Original query:", latestMessage); console.log(">>>> Reformulated query:", reformulatedQuery); - // Analyze the query to determine if search is needed and which namespaces to search + // Analyze the query to determine if search is needed and which namespaces + // to search const { requiresSearch, relevantNamespaces } = analyzeQuery(reformulatedQuery); @@ -469,7 +483,8 @@ export const testSimilaritySearch = asyncHandler( async (req: Request, res: Response) => { const { message } = req.body; - // Analyze the query to determine if search is needed and which namespaces to search + // Analyze the query to determine if search is needed and which namespaces + // to search const { requiresSearch, relevantNamespaces } = analyzeQuery(message); let context = "[No context provided]"; diff --git a/course-matrix/backend/src/controllers/generatorController.ts b/course-matrix/backend/src/controllers/generatorController.ts index c88b17f9..b998b93b 100644 --- a/course-matrix/backend/src/controllers/generatorController.ts +++ b/course-matrix/backend/src/controllers/generatorController.ts @@ -13,9 +13,11 @@ import { categorizeValidOfferings, getFreq, getMaxDays, + getMaxHour, getValidOfferings, groupOfferings, trim, + shuffle, } from "../utils/generatorHelpers"; // Express route handler to generate timetables based on user input @@ -28,6 +30,7 @@ export default { const courseOfferingsList: OfferingList[] = []; const validCourseOfferingsList: GroupedOfferingList[] = []; const maxdays = getMaxDays(restrictions); + const maxhours = getMaxHour(restrictions); const validSchedules: Offering[][] = []; // Fetch offerings for each course for (const course of courses) { @@ -72,10 +75,12 @@ export default { validCourseOfferingsList.push(groupedOfferings); } - const categorizedOfferings = categorizeValidOfferings( + let categorizedOfferings = categorizeValidOfferings( validCourseOfferingsList, ); // Generate valid schedules for the given courses and restrictions + categorizedOfferings = shuffle(categorizedOfferings); + await getValidSchedules( validSchedules, categorizedOfferings, @@ -83,6 +88,8 @@ export default { 0, categorizedOfferings.length, maxdays, + maxhours, + false, ); // Return error if no valid schedules are found @@ -90,11 +97,11 @@ export default { return res.status(404).json({ error: "No valid schedules found." }); } // Return the valid schedules + console.log("Total timetables generated", validSchedules.length); const returnVal = { amount: validSchedules.length, schedules: trim(validSchedules), }; - console.log(returnVal); return res.status(200).json(returnVal); } catch (error) { // Catch any error and return the error message diff --git a/course-matrix/backend/src/controllers/restrictionsController.ts b/course-matrix/backend/src/controllers/restrictionsController.ts index 892ee655..1404dab0 100644 --- a/course-matrix/backend/src/controllers/restrictionsController.ts +++ b/course-matrix/backend/src/controllers/restrictionsController.ts @@ -1,8 +1,9 @@ import { Request, Response } from "express"; -import asyncHandler from "../middleware/asyncHandler"; -import { supabase } from "../db/setupDb"; import { start } from "repl"; +import { supabase } from "../db/setupDb"; +import asyncHandler from "../middleware/asyncHandler"; + export default { /** * Create, Read, Update and Delete restrictions @@ -23,18 +24,19 @@ export default { disabled, num_days, calendar_id, + max_gap, } = req.body; const user_id = (req as any).user.id; - //Check for calendar_id + // Check for calendar_id if (!calendar_id) { return res.status(400).json({ error: "calendar id is required" }); } // Function to construct date in local time if ( - !["Restrict Day", "Days Off"].includes(type) && + !["Restrict Day", "Days Off", "Max Gap"].includes(type) && !start_time && !end_time ) { @@ -43,7 +45,7 @@ export default { .json({ error: "Start time or end time must be provided" }); } - //Retrieve users allowed to access the timetable + // Retrieve users allowed to access the timetable const { data: timetableData, error: timetableError } = await supabase .schema("timetable") .from("timetables") @@ -55,14 +57,14 @@ export default { if (timetableError) return res.status(400).json({ error: timetableError.message }); - //Validate timetable validity: + // Validate timetable validity: if (!timetableData || timetableData.length === 0) { return res.status(404).json({ error: "Calendar id not found" }); } const timetable_user_id = timetableData.user_id; - //Validate user access + // Validate user access if (user_id !== timetable_user_id) { return res .status(401) @@ -95,6 +97,7 @@ export default { disabled, num_days, calendar_id, + max_gap, }, ]) .select(); @@ -119,12 +122,12 @@ export default { const { calendar_id } = req.params; const user_id = (req as any).user.id; - //Check for calendar_id + // Check for calendar_id if (!calendar_id) { return res.status(400).json({ error: "calendar id is required" }); } - //Retrieve users allowed to access the timetable + // Retrieve users allowed to access the timetable const { data: timetableData, error: timetableError } = await supabase .schema("timetable") .from("timetables") @@ -136,14 +139,14 @@ export default { if (timetableError) return res.status(400).json({ error: timetableError.message }); - //Validate timetable validity: + // Validate timetable validity: if (!timetableData || timetableData.length === 0) { return res.status(404).json({ error: "Calendar id not found" }); } const timetable_user_id = timetableData.user_id; - //Validate user access + // Validate user access if (user_id !== timetable_user_id) { return res .status(401) @@ -179,12 +182,12 @@ export default { const updateData = req.body; const user_id = (req as any).user.id; - //Check for calendar_id + // Check for calendar_id if (!calendar_id) { return res.status(400).json({ error: "calendar id is required" }); } - //Check restriction id + // Check restriction id const { data: restrictionCurrData, error: restrictionCurrError } = await supabase .schema("timetable") @@ -202,7 +205,7 @@ export default { return res.status(404).json({ error: "Restriction id does not exist" }); } - //Ensure start_time and end_time only contain time value + // Ensure start_time and end_time only contain time value if (updateData.start_time) { const restriction_start_time = new Date(updateData.start_time); updateData.start_time = restriction_start_time @@ -215,7 +218,7 @@ export default { updateData.end_time = restriction_end_time.toISOString().split("T")[1]; } - //Retrieve users allowed to access the timetable + // Retrieve users allowed to access the timetable const { data: timetableData, error: timetableError } = await supabase .schema("timetable") .from("timetables") @@ -227,21 +230,21 @@ export default { if (timetableError) return res.status(400).json({ error: timetableError.message }); - //Validate timetable validity: + // Validate timetable validity: if (!timetableData || timetableData.length === 0) { return res.status(404).json({ error: "Calendar id not found" }); } const timetable_user_id = timetableData.user_id; - //Validate user access + // Validate user access if (user_id !== timetable_user_id) { return res .status(401) .json({ error: "Unauthorized access to timetable restriction" }); } - //Validate mapping restriction id to calendar id + // Validate mapping restriction id to calendar id if (restrictionCurrData.calendar_id !== timetableData.id) { return res.status(400).json({ error: "Restriction id does not belong to the provided calendar id", @@ -287,12 +290,12 @@ export default { const { calendar_id } = req.query; const user_id = (req as any).user.id; - //Check for calendar_id + // Check for calendar_id if (!calendar_id) { return res.status(400).json({ error: "calendar id is required" }); } - //Check restriction id + // Check restriction id const { data: restrictionCurrData, error: restrictionCurrError } = await supabase .schema("timetable") @@ -311,7 +314,7 @@ export default { return res.status(404).json({ error: "Restriction id does not exist" }); } - //Retrieve users allowed to access the timetable + // Retrieve users allowed to access the timetable const { data: timetableData, error: timetableError } = await supabase .schema("timetable") .from("timetables") @@ -324,12 +327,12 @@ export default { return res.status(400).json({ error: timetableError.message }); } - //Validate timetable validity: + // Validate timetable validity: if (!timetableData || timetableData.length === 0) { return res.status(404).json({ error: "Calendar id not found" }); } - //Validate mapping restriction id to calendar id + // Validate mapping restriction id to calendar id if (restrictionCurrData.calendar_id !== timetableData.id) { return res.status(400).json({ error: "Restriction id does not belong to the provided calendar id", @@ -338,7 +341,7 @@ export default { const timetable_user_id = timetableData.user_id; - //Validate user access + // Validate user access if (user_id !== timetable_user_id) { return res .status(401) diff --git a/course-matrix/backend/src/models/timetable-form.ts b/course-matrix/backend/src/models/timetable-form.ts index e9810afe..dc8f4b24 100644 --- a/course-matrix/backend/src/models/timetable-form.ts +++ b/course-matrix/backend/src/models/timetable-form.ts @@ -5,11 +5,7 @@ export type TimetableForm = { date: Date; semester: string; search: string; - courses: { - id: number; - code: string; - name: string; - }[]; + courses: { id: number; code: string; name: string }[]; restrictions: RestrictionForm[]; }; @@ -17,6 +13,7 @@ export type RestrictionForm = { type: string; days?: string[]; numDays?: number; + maxGap?: number; startTime?: string; endTime?: string; disabled?: boolean; @@ -69,6 +66,7 @@ export const RestrictionSchema = z.object({ "Restrict Between", "Restrict Day", "Days Off", + "Max Gap", ]) .describe( "The type of restriction being applied. Restrict before restricts all times before 'endTime', Restrict Before restricts all times after 'startTime', Restrict Between restricts all times between 'startTime' and 'endTime', Restrict Day restricts the entirety of each day in field 'days', and Days Off enforces as least 'numDays' days off per week.", @@ -85,6 +83,14 @@ export const RestrictionSchema = z.object({ .describe( "If type is Days Off, then this field is used and describes min number of days off per week. For example, if set to 2, and 'type' is Days Off, then this means we want at least 2 days off per week.", ), + maxGap: z + .number() + .positive() + .max(23, "Cannot have a gap of an entire day for between courses") + .optional() + .describe( + "If type is Max Gap, then this field is used to describe the maximum gap between courses, in hours. For example, if set to 3, then 2 entires must not be further than 3 hours apart. ", + ), startTime: z .string() .optional() diff --git a/course-matrix/backend/src/services/getValidSchedules.ts b/course-matrix/backend/src/services/getValidSchedules.ts index 982fd14e..466a879e 100644 --- a/course-matrix/backend/src/services/getValidSchedules.ts +++ b/course-matrix/backend/src/services/getValidSchedules.ts @@ -1,23 +1,33 @@ import { CategorizedOfferingList, Offering } from "../types/generatorTypes"; -import { canInsertList, getFrequencyTable } from "../utils/generatorHelpers"; +import { + canInsertList, + getFrequencyTable, + getMinHour, +} from "../utils/generatorHelpers"; // Function to generate all valid schedules based on offerings and restrictions -export async function getValidSchedules( +export function getValidSchedules( validSchedules: Offering[][], courseOfferingsList: CategorizedOfferingList[], curList: Offering[], cur: number, len: number, maxdays: number, + maxhours: number, + exit: boolean, ) { // Base case: if all courses have been considered if (cur == len) { - const freq: Map = getFrequencyTable(curList); + if (validSchedules.length > 200) { + exit = true; + return; + } + const freq: Map = getFrequencyTable(curList); // If the number of unique days is within the allowed limit, add the current - // schedule to the list - if (freq.size <= maxdays) { + // schedule to the list, also checks if max gap is being violated + if (freq.size <= maxdays && getMinHour(curList, maxhours)) { validSchedules.push([...curList]); // Push a copy of the current list } return; @@ -29,20 +39,22 @@ export async function getValidSchedules( for (const [groupKey, offerings] of Object.entries( offeringsForCourse.offerings, )) { - if (await canInsertList(offerings, curList)) { + if (canInsertList(offerings, curList)) { const count = offerings.length; curList.push(...offerings); // Add offering to the current list // Recursively generate schedules for the next course - await getValidSchedules( + getValidSchedules( validSchedules, courseOfferingsList, curList, cur + 1, len, maxdays, + maxhours, + exit, ); - + if (exit) return; // Backtrack: remove the last offering if no valid schedule was found for (let i = 0; i < count; i++) curList.pop(); } diff --git a/course-matrix/backend/src/types/generatorTypes.ts b/course-matrix/backend/src/types/generatorTypes.ts index 16664658..81b2292f 100644 --- a/course-matrix/backend/src/types/generatorTypes.ts +++ b/course-matrix/backend/src/types/generatorTypes.ts @@ -24,6 +24,7 @@ export enum RestrictionType { RestrictBetween = "Restrict Between", RestrictDay = "Restrict Day", RestrictDaysOff = "Days Off", + RestrictMaxGap = "Max Gap", } // Interface for the restriction object @@ -34,6 +35,7 @@ export interface Restriction { endTime: string; disabled: boolean; numDays: number; + maxGap: number; } // Interface for organizing offerings with the same meeting_section together diff --git a/course-matrix/backend/src/utils/generatorHelpers.ts b/course-matrix/backend/src/utils/generatorHelpers.ts index 035f39fc..9590405d 100644 --- a/course-matrix/backend/src/utils/generatorHelpers.ts +++ b/course-matrix/backend/src/utils/generatorHelpers.ts @@ -75,6 +75,17 @@ export function getMaxDays(restrictions: Restriction[]) { return 5; // Default to 5 days if no restrictions } +// Function to get the hour for max gap +export function getMaxHour(restrictions: Restriction[]) { + for (const restriction of restrictions) { + if (restriction.disabled) continue; + if (restriction.type == RestrictionType.RestrictMaxGap) { + return restriction.maxGap; + } + } + return 24; // Default to 5 days if no restrictions +} + // Function to check if an offering satisfies the restrictions export function isValidOffering( offering: Offering, @@ -236,3 +247,39 @@ export function trim(schedules: Offering[][]) { return trim_schedule; } + +export function getMinHourDay(schedule: Offering[], maxhours: number): boolean { + if (schedule.length <= 1) return true; + schedule.sort((a, b) => a.start.localeCompare(b.start)); + for (let i = 1; i < schedule.length; i++) { + const cur = parseInt(schedule[i].start.split(":")[0]); + const prev = parseInt(schedule[i - 1].end.split(":")[0]); + if (cur - prev > maxhours) { + return false; + } + } + return true; +} + +export function getMinHour(schedule: Offering[], maxhours: number): boolean { + if (maxhours == 24) return true; + const scheduleByDay: Record = {}; + schedule.forEach((offering) => { + if (!scheduleByDay[offering.day]) { + scheduleByDay[offering.day] = []; + } + scheduleByDay[offering.day].push(offering); + }); + return Object.values(scheduleByDay).every((x) => getMinHourDay(x, maxhours)); +} + +export function shuffle( + array: CategorizedOfferingList[], +): CategorizedOfferingList[] { + const shuffled = [...array]; // Create a copy to avoid mutating the original array + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; // Swap elements + } + return shuffled; +} diff --git a/course-matrix/frontend/src/models/timetable-form.ts b/course-matrix/frontend/src/models/timetable-form.ts index 37d432e8..ebef4d46 100644 --- a/course-matrix/frontend/src/models/timetable-form.ts +++ b/course-matrix/frontend/src/models/timetable-form.ts @@ -1,4 +1,5 @@ import { z, ZodType } from "zod"; + import { OfferingModel } from "./models"; const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/; @@ -8,11 +9,7 @@ export type TimetableForm = { date: Date; semester: string; search: string; - courses: { - id: number; - code: string; - name: string; - }[]; + courses: { id: number; code: string; name: string }[]; offeringIds: number[]; restrictions: RestrictionForm[]; }; @@ -21,6 +18,7 @@ export type RestrictionForm = { type: string; days?: string[]; numDays?: number; + maxGap?: number; startTime?: Date; endTime?: Date; disabled?: boolean; @@ -99,8 +97,10 @@ export const RestrictionSchema = z .positive() .max(4, "Cannot block all days of the week") .optional(), - // startTime: z.string().regex(timeRegex, "Invalid time format (HH:MM)").optional(), - // endTime: z.string().regex(timeRegex, "Invalid time format (HH:MM)").optional(), + // startTime: z.string().regex(timeRegex, "Invalid time format + // (HH:MM)").optional(), endTime: z.string().regex(timeRegex, "Invalid + // time format (HH:MM)").optional(), + maxGap: z.number().positive().max(23, "Gap too big").optional(), startTime: z.date().optional(), endTime: z.date().optional(), disabled: z.boolean().optional(), diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/Calendar.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/Calendar.tsx index 7aea6476..7270bbde 100644 --- a/course-matrix/frontend/src/pages/TimetableBuilder/Calendar.tsx +++ b/course-matrix/frontend/src/pages/TimetableBuilder/Calendar.tsx @@ -262,6 +262,7 @@ const Calendar = React.memo( end_time: restriction.endTime, disabled: restriction.disabled, num_days: restriction.numDays, + max_gap: restriction.maxGap, }; const { error: restrictionError } = await createRestriction(restrictionObject); @@ -326,6 +327,7 @@ const Calendar = React.memo( end_time: restriction.endTime, disabled: restriction.disabled, num_days: restriction.numDays, + max_gap: restriction.maxGap, }; const { error: restrictionError } = await createRestriction(restrictionObject); diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/CreateCustomSetting.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/CreateCustomSetting.tsx index 2975c2e6..c37d134f 100644 --- a/course-matrix/frontend/src/pages/TimetableBuilder/CreateCustomSetting.tsx +++ b/course-matrix/frontend/src/pages/TimetableBuilder/CreateCustomSetting.tsx @@ -106,6 +106,14 @@ const CreateCustomSetting = ({ return val; }; + const isMaxGapRestrictionApplied = () => { + const val = form + ?.getValues("restrictions") + .some((r) => r.type === "Max Gap"); + console.log(val); + return val; + }; + const getRestrictionType = (value: string) => { if ( value === "Restrict Before" || @@ -117,6 +125,8 @@ const CreateCustomSetting = ({ return "day"; } else if (value === "Days Off") { return "days off"; + } else if (value === "Max Gap") { + return "hours"; } }; @@ -182,6 +192,12 @@ const CreateCustomSetting = ({ > Enforce Minimum Days Off Per Week + + Max Gap Between Offerings + @@ -296,16 +312,42 @@ const CreateCustomSetting = ({ /> )} + ) : restrictionType && + getRestrictionType(restrictionType) === "days off" ? ( + <> + ( + + Minimum no. days off per week + + + field.onChange(e.target.valueAsNumber) + } + /> + + + + )} + /> + ) : ( restrictionType && - getRestrictionType(restrictionType) === "days off" && ( + getRestrictionType(restrictionType) === "hours" && ( <> ( - Minimum no. days off per week + + Max gap allowed between + lectures/tutorials/practicals (hours) + ( calendar_id: newTimetableId, type: restriction.type, days: restriction.days, + max_gap: restriction.maxGap, start_time: restriction.startTime, end_time: restriction.endTime, disabled: restriction.disabled, diff --git a/course-matrix/frontend/src/pages/TimetableBuilder/TimetableBuilder.tsx b/course-matrix/frontend/src/pages/TimetableBuilder/TimetableBuilder.tsx index 7c52ccdb..bb598f97 100644 --- a/course-matrix/frontend/src/pages/TimetableBuilder/TimetableBuilder.tsx +++ b/course-matrix/frontend/src/pages/TimetableBuilder/TimetableBuilder.tsx @@ -255,6 +255,7 @@ const TimetableBuilder = () => { : undefined, type: restriction?.type, numDays: restriction?.num_days, + maxGap: restriction?.max_gap, }) as z.infer, ); form.setValue("restrictions", parsedRestrictions); @@ -557,11 +558,16 @@ const TimetableBuilder = () => { : ""}{" "} {restric.days?.join(" ")}

- ) : ( + ) : restric.type.startsWith("Days") ? (

{restric.type}: At least{" "} {restric.numDays} days off

+ ) : ( +

+ {restric.type}: {restric.maxGap}{" "} + hours +

)}