Skip to content

Commit 4d28b1c

Browse files
Merge branch 'develop' into mt/scrum-137-check-timetable-duplicated-name
2 parents 94d69ed + 3d814b0 commit 4d28b1c

File tree

14 files changed

+315
-60
lines changed

14 files changed

+315
-60
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ The `DATABASE_URL` variable should contain your Supabase project url and the `DA
6161

6262
```
6363
VITE_SERVER_URL="http://localhost:8081"
64+
VITE_PUBLIC_ASSISTANT_BASE_URL=[Insert vite public assistant bas URL]
65+
VITE_ASSISTANT_UI_KEY=[Insert vite assistant UI key]
6466
```
6567

6668
### Running the Application

course-matrix/backend/src/constants/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const yearToCode = (year: number) => {
3939

4040
// Set minimum results wanted for a similarity search on the associated namespace.
4141
export const namespaceToMinResults = new Map();
42-
namespaceToMinResults.set("courses_v2", 10);
42+
namespaceToMinResults.set("courses_v3", 10);
4343
namespaceToMinResults.set("offerings", 16); // Typically, more offering info is wanted.
4444
namespaceToMinResults.set("prerequisites", 5);
4545
namespaceToMinResults.set("corequisites", 5);

course-matrix/backend/src/constants/promptKeywords.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Keywords related to each namespace
22
export const NAMESPACE_KEYWORDS = {
3-
courses_v2: [
3+
courses_v3: [
44
"course",
55
"class",
66
"description",
@@ -61,6 +61,41 @@ export const NAMESPACE_KEYWORDS = {
6161
programs: ["program", "major", "minor", "specialist", "degree", "stream"],
6262
};
6363

64+
export const BREADTH_REQUIREMENT_KEYWORDS = {
65+
ART_LIT_LANG: [
66+
"ART_LIT_LANG",
67+
"art literature",
68+
"arts literature",
69+
"art language",
70+
"arts language",
71+
"literature language",
72+
"art literature language",
73+
"arts literature language",
74+
],
75+
HIS_PHIL_CUL: [
76+
"HIS_PHIL_CUL",
77+
"history philosophy culture",
78+
"history, philosophy, culture",
79+
"history, philosophy, and culture",
80+
"history, philosophy",
81+
"history philosophy",
82+
"philosophy culture",
83+
"philosophy, culture",
84+
"history culture",
85+
"History, Philosophy and Cultural Studies",
86+
],
87+
SOCIAL_SCI: ["SOCIAL_SCI", "social science", "social sciences"],
88+
NAT_SCI: ["NAT_SCI", "natural science", "natural sciences"],
89+
QUANT: ["QUANT", "quantitative reasoning"],
90+
};
91+
92+
export const YEAR_LEVEL_KEYWORDS = {
93+
first_year: ["first year", "first-year", "A-level", "A level", "1st year"],
94+
second_year: ["second year", "second-year", "B-level", "B level", "2nd year"],
95+
third_year: ["third year", "third-year", "C-level", "C level", "3rd year"],
96+
fourth_year: ["fourth year", "fourth-year", "D-level", "D level", "4th year"],
97+
};
98+
6499
// General academic terms that might indicate a search is needed
65100
export const GENERAL_ACADEMIC_TERMS = ["credit", "enroll", "drop"];
66101

course-matrix/backend/src/controllers/aiController.ts

Lines changed: 91 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@ import {
1212
DEPARTMENT_CODES,
1313
ASSISTANT_TERMS,
1414
USEFUL_INFO,
15+
BREADTH_REQUIREMENT_KEYWORDS,
16+
YEAR_LEVEL_KEYWORDS,
1517
} from "../constants/promptKeywords";
1618
import { CHATBOT_MEMORY_THRESHOLD, codeToYear } from "../constants/constants";
1719
import { namespaceToMinResults } from "../constants/constants";
1820
import OpenAI from "openai";
21+
import { convertBreadthRequirement } from "../utils/convert-breadth-requirement";
22+
import { convertYearLevel } from "../utils/convert-year-level";
1923

2024
const openai = createOpenAI({
2125
baseURL: process.env.OPENAI_BASE_URL,
@@ -58,8 +62,8 @@ function analyzeQuery(query: string): {
5862

5963
// If a course code is detected, add tehse namespaces
6064
if (containsCourseCode) {
61-
if (!relevantNamespaces.includes("courses_v2"))
62-
relevantNamespaces.push("courses_v2");
65+
if (!relevantNamespaces.includes("courses_v3"))
66+
relevantNamespaces.push("courses_v3");
6367
if (!relevantNamespaces.includes("offerings"))
6468
relevantNamespaces.push("offerings");
6569
if (!relevantNamespaces.includes("prerequisites"))
@@ -70,8 +74,8 @@ function analyzeQuery(query: string): {
7074
if (DEPARTMENT_CODES.some((code) => lowerQuery.includes(code))) {
7175
if (!relevantNamespaces.includes("departments"))
7276
relevantNamespaces.push("departments");
73-
if (!relevantNamespaces.includes("courses_v2"))
74-
relevantNamespaces.push("courses_v2");
77+
if (!relevantNamespaces.includes("courses_v3"))
78+
relevantNamespaces.push("courses_v3");
7579
}
7680

7781
// If search is required at all
@@ -83,7 +87,7 @@ function analyzeQuery(query: string): {
8387
// If no specific namespaces identified & search required, then search all
8488
if (requiresSearch && relevantNamespaces.length === 0) {
8589
relevantNamespaces.push(
86-
"courses_v2",
90+
"courses_v3",
8791
"offerings",
8892
"prerequisites",
8993
"corequisites",
@@ -106,6 +110,7 @@ async function searchSelectedNamespaces(
106110
query: string,
107111
k: number,
108112
namespaces: string[],
113+
filters?: Object,
109114
): Promise<Document[]> {
110115
let allResults: Document[] = [];
111116

@@ -127,6 +132,7 @@ async function searchSelectedNamespaces(
127132
const results = await namespaceStore.similaritySearch(
128133
query,
129134
Math.max(k, namespaceToMinResults.get(namespace)),
135+
namespace === "courses_v3" ? filters : undefined,
130136
);
131137
console.log(`Found ${results.length} results in namespace: ${namespace}`);
132138
allResults = [...allResults, ...results];
@@ -172,16 +178,18 @@ async function reformulateQuery(
172178
- DO replace pronouns and references with specific names and identifiers
173179
- DO include course codes, names and specific details for academic entities
174180
- If the query is not about university courses & offerings, return exactly a copy of the user's query.
181+
- Append "code: " before course codes For example: "CSCC01" -> "code: CSCC01"
182+
- If a course year level is written as "first year", "second year", etc. Then replace "first" with "1st" and "second" with "2nd" etc.
175183
176184
Examples:
177185
User: "When is it offered?"
178-
Output: "When is CSCA48 Introduction to Computer Science offered in the 2024-2025 academic year?"
186+
Output: "When is CSCA48 offered in the 2024-2025 academic year?"
179187
180188
User: "Tell me more about that"
181-
Output: "What are the details, descriptions, and requirements for MATA31 Calculus I?"
189+
Output: "What are the details, descriptions, and requirements for MATA31?"
182190
183191
User: "Who teaches it?"
184-
Output: "Who are the instructors for MGEA02 Introduction to Microeconomics at UTSC?"
192+
Output: "Who are the instructors for MGEA02 at UTSC?"
185193
186194
User: "What are the course names of those codes?"
187195
Output: "What are the course names of course codes: MGTA01, CSCA08, MATA31, MATA35?"
@@ -192,8 +200,13 @@ async function reformulateQuery(
192200
User: "Give 2nd year math courses."
193201
Output: "What are some 2nd year math courses?"
194202
195-
User: "Give first year math courses."
196-
Output: "What are some 1st year math courses?"`,
203+
User: "Give third year math courses."
204+
Output: "What are some 3rd year math courses?"
205+
206+
User: "What breadth requirement does CSCC01 satisfy?"
207+
Output: "What breadth requirement does code: CSCC01 satisfy?"
208+
209+
`,
197210
},
198211
];
199212

@@ -227,6 +240,69 @@ async function reformulateQuery(
227240
}
228241
}
229242

243+
// Determines whether to apply metadata filtering based on user query.
244+
function includeFilters(query: string) {
245+
const lowerQuery = query.toLocaleLowerCase();
246+
const relaventBreadthRequirements: string[] = [];
247+
const relaventYearLevels: string[] = [];
248+
249+
Object.entries(BREADTH_REQUIREMENT_KEYWORDS).forEach(
250+
([namespace, keywords]) => {
251+
if (keywords.some((keyword) => lowerQuery.includes(keyword))) {
252+
relaventBreadthRequirements.push(convertBreadthRequirement(namespace));
253+
}
254+
},
255+
);
256+
257+
Object.entries(YEAR_LEVEL_KEYWORDS).forEach(([namespace, keywords]) => {
258+
if (keywords.some((keyword) => lowerQuery.includes(keyword))) {
259+
relaventYearLevels.push(convertYearLevel(namespace));
260+
}
261+
});
262+
263+
let filter = {};
264+
if (relaventBreadthRequirements.length > 0 && relaventYearLevels.length > 0) {
265+
filter = {
266+
$and: [
267+
{
268+
$or: relaventBreadthRequirements.map((req) => ({
269+
breadth_requirement: { $eq: req },
270+
})),
271+
},
272+
{
273+
$or: relaventYearLevels.map((yl) => ({ year_level: { $eq: yl } })),
274+
},
275+
],
276+
};
277+
} else if (relaventBreadthRequirements.length > 0) {
278+
filter = {
279+
$or: relaventBreadthRequirements.map((req) => ({
280+
breadth_requirement: { $eq: req },
281+
})),
282+
};
283+
} else if (relaventYearLevels.length > 0) {
284+
filter = {
285+
$or: relaventYearLevels.map((yl) => ({ year_level: { $eq: yl } })),
286+
};
287+
}
288+
return filter;
289+
}
290+
291+
/**
292+
* @description Handles user queries and generates responses using GPT-4o, with optional knowledge retrieval.
293+
*
294+
* @param {Request} req - The Express request object, containing:
295+
* @param {Object[]} req.body.messages - Array of message objects representing the conversation history.
296+
* @param {string} req.body.messages[].role - The role of the message sender (e.g., "user", "assistant").
297+
* @param {Object[]} req.body.messages[].content - An array containing message content objects.
298+
* @param {string} req.body.messages[].content[].text - The actual text of the message.
299+
*
300+
* @param {Response} res - The Express response object used to stream the generated response.
301+
*
302+
* @returns {void} Responds with a streamed text response of the AI output
303+
*
304+
* @throws {Error} If query reformulation or knowledge retrieval fails.
305+
*/
230306
export const chat = asyncHandler(async (req: Request, res: Response) => {
231307
const { messages } = req.body;
232308
const latestMessage = messages[messages.length - 1].content[0].text;
@@ -258,11 +334,15 @@ export const chat = asyncHandler(async (req: Request, res: Response) => {
258334
)}`,
259335
);
260336

337+
const filters = includeFilters(reformulatedQuery);
338+
// console.log("Filters: ", JSON.stringify(filters))
339+
261340
// Search only relevant namespaces
262341
const searchResults = await searchSelectedNamespaces(
263342
reformulatedQuery,
264343
3,
265344
relevantNamespaces,
345+
Object.keys(filters).length === 0 ? undefined : filters,
266346
);
267347
// console.log("Search Results: ", searchResults);
268348

@@ -274,7 +354,7 @@ export const chat = asyncHandler(async (req: Request, res: Response) => {
274354
console.log("Query does not require knowledge retrieval, skipping search");
275355
}
276356

277-
// console.log("CONTEXT: ", context);
357+
console.log("CONTEXT: ", context);
278358

279359
const result = streamText({
280360
model: openai("gpt-4o-mini"),

course-matrix/backend/src/controllers/timetablesController.ts

Lines changed: 20 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,11 @@ export default {
1818
const user_id = (req as any).user.id;
1919

2020
//Retrieve timetable title
21-
const { timetable_title, semester } = req.body;
22-
if (!timetable_title) {
23-
return res.status(400).json({ error: "timetable title is required" });
24-
}
25-
26-
if (!semester) {
21+
const { timetable_title, semester, favorite = false } = req.body;
22+
if (!timetable_title || !semester) {
2723
return res
2824
.status(400)
29-
.json({ error: "timetable semester is required" });
25+
.json({ error: "timetable title and semester are required" });
3026
}
3127

3228
// Check if a timetable with the same title already exist for this user
@@ -53,7 +49,14 @@ export default {
5349
let insertTimetable = supabase
5450
.schema("timetable")
5551
.from("timetables")
56-
.insert([{ user_id, timetable_title, semester }])
52+
.insert([
53+
{
54+
user_id,
55+
timetable_title,
56+
semester,
57+
favorite,
58+
},
59+
])
5760
.select()
5861
.single();
5962

@@ -113,11 +116,11 @@ export default {
113116
const { id } = req.params;
114117

115118
//Retrieve timetable title
116-
const { timetable_title, semester } = req.body;
117-
if (!timetable_title && !semester) {
119+
const { timetable_title, semester, favorite } = req.body;
120+
if (!timetable_title && !semester && favorite === undefined) {
118121
return res.status(400).json({
119122
error:
120-
"New timetable title or semester is required when updating a timetable",
123+
"New timetable title or semester or updated favorite status is required when updating a timetable",
121124
});
122125
}
123126

@@ -134,26 +137,15 @@ export default {
134137
.eq("user_id", user_id)
135138
.maybeSingle();
136139

137-
const timetable_user_id = timetableUserData?.user_id;
138-
139-
if (timetableUserError)
140-
return res.status(400).json({ error: timetableUserError.message });
141-
142-
//Validate timetable validity:
143-
if (!timetableUserData || timetableUserData.length === 0) {
144-
return res.status(404).json({ error: "Calendar id not found" });
145-
}
146-
147-
//Validate user access
148-
if (user_id !== timetable_user_id) {
140+
if (timetableUserError || !timetableUserData)
149141
return res
150-
.status(401)
151-
.json({ error: "Unauthorized access to timetable events" });
152-
}
142+
.status(400)
143+
.json({ error: "Timetable not found or unauthorized" });
153144

154145
let updateData: any = {};
155146
if (timetable_title) updateData.timetable_title = timetable_title;
156147
if (semester) updateData.semester = semester;
148+
if (favorite !== undefined) updateData.favorite = favorite;
157149

158150
//Update timetable title, for authenticated user only
159151
let updateTimetableQuery = supabase
@@ -162,7 +154,8 @@ export default {
162154
.update(updateData)
163155
.eq("id", id)
164156
.eq("user_id", user_id)
165-
.select();
157+
.select()
158+
.single();
166159

167160
const { data: timetableData, error: timetableError } =
168161
await updateTimetableQuery;
@@ -202,7 +195,6 @@ export default {
202195
.eq("id", id)
203196
.eq("user_id", user_id)
204197
.maybeSingle();
205-
const timetable_user_id = timetableUserData?.user_id;
206198

207199
if (timetableUserError)
208200
return res.status(400).json({ error: timetableUserError.message });
@@ -212,13 +204,6 @@ export default {
212204
return res.status(404).json({ error: "Calendar id not found" });
213205
}
214206

215-
//Validate user access
216-
if (user_id !== timetable_user_id) {
217-
return res
218-
.status(401)
219-
.json({ error: "Unauthorized access to timetable events" });
220-
}
221-
222207
// Delete only if the timetable belongs to the authenticated user
223208
let deleteTimetableQuery = supabase
224209
.schema("timetable")

course-matrix/backend/src/routes/aiRouter.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,13 @@ import { authRouter } from "./authRouter";
44

55
export const aiRouter = express.Router();
66

7+
/**
8+
* @route POST /api/ai/chat
9+
* @description Handles user queries and generates responses using GPT-4o, with optional knowledge retrieval.
10+
*/
711
aiRouter.post("/chat", authRouter, chat);
12+
/**
13+
* @route POST /api/ai/test-similarity-search
14+
* @description Test vector database similarity search feature
15+
*/
816
aiRouter.post("/test-similarity-search", testSimilaritySearch);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const convertBreadthRequirement = (code: string) => {
2+
if (code === "ART_LIT_LANG") return "Arts, Literature and Language";
3+
else if (code === "HIS_PHIL_CUL")
4+
return "History, Philosophy and Cultural Studies";
5+
else if (code === "SOCIAL_SCI") return "Social and Behavioral Sciences";
6+
else if (code === "NAT_SCI") return "Natural Sciences";
7+
else if (code === "QUANT") return "Quantitative Reasoning";
8+
else return "";
9+
};

0 commit comments

Comments
 (0)