Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"permissions": {
"allow": [
"mcp__chrome-devtools__click",
"mcp__chrome-devtools__close_page",
"mcp__chrome-devtools__drag",
"mcp__chrome-devtools__emulate",
"mcp__chrome-devtools__evaluate_script",
"mcp__chrome-devtools__fill",
"mcp__chrome-devtools__fill_form",
"mcp__chrome-devtools__get_console_message",
"mcp__chrome-devtools__get_network_request",
"mcp__chrome-devtools__handle_dialog",
"mcp__chrome-devtools__hover",
"mcp__chrome-devtools__lighthouse_audit",
"mcp__chrome-devtools__list_console_messages",
"mcp__chrome-devtools__list_network_requests",
"mcp__chrome-devtools__list_pages",
"mcp__chrome-devtools__navigate_page",
"mcp__chrome-devtools__new_page",
"mcp__chrome-devtools__performance_analyze_insight",
"mcp__chrome-devtools__performance_start_trace",
"mcp__chrome-devtools__performance_stop_trace",
"mcp__chrome-devtools__press_key",
"mcp__chrome-devtools__resize_page",
"mcp__chrome-devtools__select_page",
"mcp__chrome-devtools__take_memory_snapshot",
"mcp__chrome-devtools__take_screenshot",
"mcp__chrome-devtools__take_snapshot",
"mcp__chrome-devtools__type_text",
"mcp__chrome-devtools__upload_file",
"mcp__chrome-devtools__wait_for"
]
}
}
2 changes: 1 addition & 1 deletion apps/backend/src/modules/catalog/catalog-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const evictOldest = () => {
}
};

const getCachedCatalog = async (
export const getCachedCatalog = async (
year: number,
semester: string
): Promise<ICatalogClassItem[]> => {
Expand Down
27 changes: 13 additions & 14 deletions apps/backend/src/modules/catalog/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ import {
} from "@repo/common/models";

import { getFields, hasFieldPath } from "../../utils/graphql";
import { searchSemantic } from "../semantic-search/client";
import { formatClass, formatSection } from "../class/formatter";
import type { ClassModule } from "../class/generated-types/module-types";
import { formatCourse } from "../course/formatter";
import { formatEnrollment } from "../enrollment/formatter";
import type { EnrollmentModule } from "../enrollment/generated-types/module-types";
import type { GradeDistributionModule } from "../grade-distribution/generated-types/module-types";
import { getSearchIndex } from "./catalog-cache";
import { searchSemantic } from "../semantic-search/client";
import { getCachedCatalog, getSearchIndex } from "./catalog-cache";

export interface CatalogQueryParams {
year: number;
Expand Down Expand Up @@ -160,40 +160,39 @@ const getCatalogWithSemanticSearch = async ({
limit: number;
skip: number;
}) => {
// Throws if the semantic service is unavailable — surfaces as GraphQL error
const response = await searchSemantic(searchTerm, year, semester);

if (response.results.length === 0) {
return { results: [], totalCount: 0 };
}

// Throws if the semantic service is unavailable — surfaces as GraphQL error
// Python already returns results sorted by score descending — preserve that order.
// Build a rank map: "subject-courseNumber" → position in Python's ranked list
const rankMap = new Map<string, number>();
response.results.forEach(({ subject, courseNumber }, index) => {
rankMap.set(`${subject}-${courseNumber}`, index);
});

const query = buildFilterQuery(year, semester, filters);
query.$or = response.results.map(({ subject, courseNumber }) => ({
subject,
courseNumber,
})) as unknown as CatalogFilterCondition[];
// Reuse the server-side catalog cache (same one fuzzy search uses) to avoid
// a redundant MongoDB round-trip.
const allCatalog = await getCachedCatalog(year, semester);

// Fetch all matching docs — semantic result sets are small (bounded by threshold)
const allResults = await CatalogClassModel.find(query).lean();
const semanticMatches = allCatalog.filter((item) =>
rankMap.has(`${item.subject}-${item.courseNumber}`)
);

const filtered = applyInMemoryFilters(semanticMatches, filters);

// Sort by Python's relevance rank, then by section number within the same course
allResults.sort((a, b) => {
filtered.sort((a, b) => {
const rankA = rankMap.get(`${a.subject}-${a.courseNumber}`) ?? 999;
const rankB = rankMap.get(`${b.subject}-${b.courseNumber}`) ?? 999;
if (rankA !== rankB) return rankA - rankB;
return a.number.localeCompare(b.number);
});

const totalCount = allResults.length;
const results = allResults.slice(skip, skip + limit);
const totalCount = filtered.length;
const results = filtered.slice(skip, skip + limit);

return { results, totalCount };
};
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/modules/class/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ const resolvers: ClassModule.Resolvers = {
parent.semester,
parent.sessionId,
parent.subject,
parent.courseNumber,
parent.courseId,
parent.number
);

Expand Down
13 changes: 5 additions & 8 deletions apps/backend/src/modules/course/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,10 @@ const resolvers: CourseModule.Resolvers = {
: undefined;

// Cross-listed courses share a courseId, so filter to only classes
// matching this specific course's subject and number.
const matchesCourse = (courseClass: {
subject?: string | null;
courseNumber?: string | null;
}) =>
courseClass.subject === parent.subject &&
courseClass.courseNumber === parent.number;
// matching this specific course's subject. We don't filter by number
// to allow renamed courses (same subject, different number) to match.
const matchesCourse = (courseClass: { subject?: string | null }) =>
courseClass.subject === parent.subject;

if (parent.classes) {
let classes = [...parent.classes];
Expand Down Expand Up @@ -247,7 +244,7 @@ const resolvers: CourseModule.Resolvers = {

const gradeDistribution = await getGradeDistributionByCourse(
parent.subject,
parent.number
parent.courseId
);

return gradeDistribution;
Expand Down
20 changes: 10 additions & 10 deletions apps/backend/src/modules/grade-distribution/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,13 @@ export const getGradeDistributionsByCourseIds = async (courseIds: string[]) => {

export const getGradeDistributionByCourse = async (
subject: string,
number: string
courseId: string
) => {
const subjectQuery = buildSubjectQuery(subject);

const sections = await SectionModel.find({
subject: subjectQuery,
courseNumber: number,
courseId,
primary: true,
})
.select({ sectionId: 1, termId: 1, sessionId: 1 })
Expand All @@ -185,7 +185,7 @@ export const getGradeDistributionByClass = async (
semester: string,
sessionId: string,
subject: string,
courseNumber: string,
courseId: string,
sectionNumber: string
) => {
const subjectQuery = buildSubjectQuery(subject);
Expand All @@ -195,7 +195,7 @@ export const getGradeDistributionByClass = async (
semester,
sessionId,
subject: subjectQuery,
courseNumber,
courseId,
number: sectionNumber,
primary: true,
})
Expand All @@ -212,7 +212,7 @@ export const getGradeDistributionBySemester = async (
semester: string,
sessionId: string,
subject: string,
courseNumber: string
courseId: string
) => {
const subjectQuery = buildSubjectQuery(subject);

Expand All @@ -221,7 +221,7 @@ export const getGradeDistributionBySemester = async (
semester,
sessionId,
subject: subjectQuery,
courseNumber,
courseId,
primary: true,
})
.select({ sectionId: 1, termId: 1, sessionId: 1 })
Expand All @@ -232,15 +232,15 @@ export const getGradeDistributionBySemester = async (

export const getGradeDistributionByInstructor = async (
subject: string,
courseNumber: string,
courseId: string,
familyName: string,
givenName: string
) => {
const subjectQuery = buildSubjectQuery(subject);

const sections = await SectionModel.find({
subject: subjectQuery,
courseNumber,
courseId,
"meetings.instructors.familyName": familyName,
"meetings.instructors.givenName": givenName,
primary: true,
Expand All @@ -258,7 +258,7 @@ export const getGradeDistributionByInstructorAndSemester = async (
semester: string,
sessionId: string,
subject: string,
courseNumber: string,
courseId: string,
familyName: string,
givenName: string
) => {
Expand All @@ -269,7 +269,7 @@ export const getGradeDistributionByInstructorAndSemester = async (
semester,
sessionId,
subject: subjectQuery,
courseNumber,
courseId,
"meetings.instructors.familyName": familyName,
"meetings.instructors.givenName": givenName,
primary: true,
Expand Down
12 changes: 6 additions & 6 deletions apps/backend/src/modules/grade-distribution/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const resolvers: GradeDistributionModule.Resolvers = {
semester,
sessionId,
subject,
courseNumber,
courseId,
classNumber,
familyName,
givenName,
Expand All @@ -31,7 +31,7 @@ const resolvers: GradeDistributionModule.Resolvers = {
semester,
sessionId,
subject,
courseNumber,
courseId,
familyName,
givenName
);
Expand All @@ -43,15 +43,15 @@ const resolvers: GradeDistributionModule.Resolvers = {
semester,
sessionId,
subject,
courseNumber,
courseId,
classNumber
);
}

if (givenName && familyName) {
return await getGradeDistributionByInstructor(
subject,
courseNumber,
courseId,
familyName,
givenName
);
Expand All @@ -63,11 +63,11 @@ const resolvers: GradeDistributionModule.Resolvers = {
semester,
sessionId,
subject,
courseNumber
courseId
);
}

return await getGradeDistributionByCourse(subject, courseNumber);
return await getGradeDistributionByCourse(subject, courseId);
},
},
};
Expand Down
17 changes: 10 additions & 7 deletions apps/backend/src/modules/semantic-search/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@ import { config } from "../../../../../packages/common/src/utils/config";
interface SemanticSearchResult {
subject: string;
courseNumber: string;
title: string;
description: string;
score: number;
text: string;
}

interface SemanticSearchResponse {
Expand Down Expand Up @@ -45,10 +41,17 @@ export async function searchSemantic(
if (!response.ok) {
let detail: string | undefined;
try {
const body = await response.json();
const body = (await response.json()) as {
detail?: string;
error?: string;
};
detail = body?.detail ?? body?.error;
} catch {}
throw new Error(detail ?? `Semantic search service error: ${response.statusText}`);
} catch {
/* ignore parse errors */
}
throw new Error(
detail ?? `Semantic search service error: ${response.statusText}`
);
}

return (await response.json()) as SemanticSearchResponse;
Expand Down
2 changes: 0 additions & 2 deletions apps/backend/src/modules/semantic-search/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,9 @@ export async function searchCourses(req: Request, res: Response) {
thresholdNum
);

// Return lightweight response: only subject + courseNumber + score
const courseIds = results.results.map((r) => ({
subject: r.subject,
courseNumber: r.courseNumber,
score: r.score,
}));

return res.json({
Expand Down
5 changes: 4 additions & 1 deletion apps/datapuller/src/pullers/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,10 @@ export const updateTermsCatalogDataFlags = async (log: Config["log"]) => {
]);

const catalogDataSet = new Set(
termsWithClasses.map((t: { _id: { year: number; semester: string } }) => `${t._id.year} ${t._id.semester}`)
termsWithClasses.map(
(t: { _id: { year: number; semester: string } }) =>
`${t._id.year} ${t._id.semester}`
)
);

const bulkOps = allTerms
Expand Down
29 changes: 29 additions & 0 deletions apps/docs/src/getting-started/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,35 @@ docker compose down
docker compose up --build -d
```

## Docker Compose Profiles

By default, running `docker compose up -d` starts only the **core stack** (backend, frontend, MongoDB, Redis). Additional services are **opt-in** and can be enabled using Docker Compose profiles.

Profiles allow you to start only the services you need for your workflow, keeping local development less resource-intensive.

- `ag` — AG frontend
→ http://localhost:3001

- `staff` — Staff dashboard
→ http://localhost:3002

- `semantic-search` — Semantic course search
→ http://localhost:3010

- `docs` — Docs + Storybook
→ http://localhost:3003 / http://localhost:3005

- `dev` — MinIO (staff photo uploads)
→ http://localhost:3006

```sh
# Start core + staff dashboard
docker compose --profile staff up -d

# Start multiple profiles
docker compose --profile ag --profile staff up -d
```

## Ports
`docker compose up` will automatically setup certain services on your localhost ports. By default, `DEV_PORT_PREFIX` is set to `30`, which means services will be available on ports starting with `30XX`. You can adjust this by setting the `DEV_PORT_PREFIX` environment variable if you need to run multiple instances of the repository in parallel (e.g., for git worktree setups).

Expand Down
Loading
Loading