diff --git a/apps/web/app/courses/(directory)/page.tsx b/apps/web/app/courses/(directory)/page.tsx index 2c513394..ee5a3d59 100644 --- a/apps/web/app/courses/(directory)/page.tsx +++ b/apps/web/app/courses/(directory)/page.tsx @@ -7,15 +7,21 @@ import { DataTableShell } from "@/ui/admin/catalogue-table/catalogue-table"; import { FilterBar } from "@/ui/common/filter-bar"; import { loadAcademicYearOptions, + loadCourseFilterOptions, loadPublishedCoursePage, + type CourseFilterOptions, type PublishedCoursePage, } from "@/lib/coursemap/published-courses"; import { CourseDirectory } from "../course-directory"; type CoursesSearchParams = { q?: string | string[]; + subject?: string | string[]; level?: string | string[]; session?: string | string[]; + college?: string | string[]; + area?: string | string[]; + tag?: string | string[]; year?: string | string[]; page?: string | string[]; }; @@ -33,12 +39,25 @@ export default async function CoursesPage({ const page = Math.max(1, Number(firstParam(params.page)) || 1); const query = firstParam(params.q).slice(0, 100); const levelParam = firstParam(params.level); - const level = ["1", "2", "3", "4"].includes(levelParam) ? levelParam : ""; + // A digit is that level; a digit and + is that level or higher, which is + // how degree rules ask for units ("2000-level or higher"). + const level = /^[1-9]\+?$/u.test(levelParam) ? levelParam : ""; const sessionParam = firstParam(params.session); const session = ["Semester 1", "Semester 2"].includes(sessionParam) ? sessionParam : ""; - const filters = { query, level, session }; + const subjectParam = firstParam(params.subject).toUpperCase(); + const subject = /^[A-Z]{4}$/u.test(subjectParam) ? subjectParam : ""; + const college = firstParam(params.college).slice(0, 120); + const area = firstParam(params.area).slice(0, 120); + const tag = firstParam(params.tag).slice(0, 120); + const filters = { query, subject, level, session, college, area, tag }; + let filterOptions: CourseFilterOptions = { + subjects: [], + colleges: [], + areas: [], + tags: [], + }; let yearOptions: Awaited> = []; let selectedAcademicYear = new Date().getFullYear(); let result: PublishedCoursePage = { @@ -58,19 +77,26 @@ export default async function CoursesPage({ : availableYears.has(currentYear) ? currentYear : (yearOptions[0]?.year ?? currentYear); - result = await loadPublishedCoursePage({ - academicYear: selectedAcademicYear, - page, - filters, - }); + [result, filterOptions] = await Promise.all([ + loadPublishedCoursePage({ + academicYear: selectedAcademicYear, + page, + filters, + }), + loadCourseFilterOptions(selectedAcademicYear), + ]); } catch { // Show an explicit outage state rather than an empty catalogue. catalogueUnavailable = true; } const paginationSearchParams = { q: query || undefined, + subject: subject || undefined, level: level || undefined, session: session || undefined, + college: college || undefined, + area: area || undefined, + tag: tag || undefined, year: String(selectedAcademicYear), }; @@ -124,6 +150,16 @@ export default async function CoursesPage({ : `${option.year} · No published courses`, })), }, + { + key: "subject", + label: "Subject", + options: filterOptions.subjects.map((option) => ({ + value: option.code, + label: option.name + ? `${option.code} · ${option.name}` + : option.code, + })), + }, { key: "level", label: "Level", @@ -132,6 +168,8 @@ export default async function CoursesPage({ { value: "2", label: "2000 level" }, { value: "3", label: "3000 level" }, { value: "4", label: "4000 level" }, + { value: "2+", label: "2000 level or higher" }, + { value: "3+", label: "3000 level or higher" }, ], }, { @@ -142,6 +180,24 @@ export default async function CoursesPage({ { value: "Semester 2", label: "Semester 2" }, ], }, + ...( + [ + ["college", "College", filterOptions.colleges], + ["area", "Area of interest", filterOptions.areas], + ["tag", "Tag", filterOptions.tags], + ] as const + ).flatMap(([key, label, values]) => + // A filter with nothing to choose would only ever empty the list. + values.length + ? [ + { + key, + label, + options: values.map((value) => ({ value, label: value })), + }, + ] + : [], + ), ]} /> diff --git a/apps/web/lib/catalogue-import/kinds/course/adapter.ts b/apps/web/lib/catalogue-import/kinds/course/adapter.ts index ba8b7299..aff675b6 100644 --- a/apps/web/lib/catalogue-import/kinds/course/adapter.ts +++ b/apps/web/lib/catalogue-import/kinds/course/adapter.ts @@ -17,6 +17,7 @@ import { buildCourseExtractionUserPrompt, } from "./prompt.ts"; import { fetchAnuCoursePage } from "./source.ts"; +import { loadKnownCourseTags } from "./tags.ts"; export const courseKindAdapter: CatalogueSyncAdapter = { kinds: ["course"], @@ -45,10 +46,14 @@ export const courseKindAdapter: CatalogueSyncAdapter = { }); }, buildSystemPrompt: buildCourseExtractionSystemPrompt, - buildUserPrompt(claim, pageMarkdown) { + async loadPromptContext(sql) { + return { knownTags: await loadKnownCourseTags(sql) }; + }, + buildUserPrompt(claim, pageMarkdown, context) { return buildCourseExtractionUserPrompt({ expectedCode: claim.code, academicYear: claim.academicYear, + knownTags: context?.knownTags ?? [], pageMarkdown, }); }, diff --git a/apps/web/lib/catalogue-import/kinds/course/contract.ts b/apps/web/lib/catalogue-import/kinds/course/contract.ts index 69d05684..bfc0572f 100644 --- a/apps/web/lib/catalogue-import/kinds/course/contract.ts +++ b/apps/web/lib/catalogue-import/kinds/course/contract.ts @@ -153,6 +153,8 @@ export type CourseExtraction = { offeringStatus: "offered" | "not_offered" | "unknown"; sourceUpdatedAt: string | null; areasOfInterest: string[]; + /** Free-form categories degree rules can count units against. */ + tags: string[]; fees: CourseFee[]; learningOutcomes: CourseLearningOutcome[]; assessmentItems: CourseAssessmentItem[]; @@ -613,6 +615,7 @@ function validateExtractionShape( "offeringStatus", "sourceUpdatedAt", "areasOfInterest", + "tags", "fees", "learningOutcomes", "assessmentItems", @@ -698,6 +701,9 @@ function validateExtractionShape( issues, (item, path) => requireString(item, path, issues), ); + requireArray(record.tags, "$.tags", issues, (item, path) => + requireString(item, path, issues), + ); requireArray(record.fees, "$.fees", issues, (item, path) => { const fee = exactRecord( item, @@ -1179,6 +1185,7 @@ export const COURSE_EXTRACTION_JSON_SCHEMA = { "offeringStatus", "sourceUpdatedAt", "areasOfInterest", + "tags", "fees", "learningOutcomes", "assessmentItems", @@ -1255,6 +1262,7 @@ export const COURSE_EXTRACTION_JSON_SCHEMA = { offeringStatus: { enum: ["offered", "not_offered", "unknown"] }, sourceUpdatedAt: { $ref: "#/$defs/nullableInstant" }, areasOfInterest: { type: "array", items: { type: "string", minLength: 1 } }, + tags: { type: "array", items: { type: "string", minLength: 1 } }, fees: { type: "array", items: { $ref: "#/$defs/fee" } }, learningOutcomes: { type: "array", items: { $ref: "#/$defs/outcome" } }, assessmentItems: { type: "array", items: { $ref: "#/$defs/assessment" } }, diff --git a/apps/web/lib/catalogue-import/kinds/course/finalise.ts b/apps/web/lib/catalogue-import/kinds/course/finalise.ts index 16fdbf38..af89fee2 100644 --- a/apps/web/lib/catalogue-import/kinds/course/finalise.ts +++ b/apps/web/lib/catalogue-import/kinds/course/finalise.ts @@ -63,6 +63,7 @@ export function emptyCourseExtraction({ offeringStatus: "unknown", sourceUpdatedAt: null, areasOfInterest: [], + tags: [], fees: [], learningOutcomes: [], assessmentItems: [], diff --git a/apps/web/lib/catalogue-import/kinds/course/project.ts b/apps/web/lib/catalogue-import/kinds/course/project.ts index 36711b89..03b40ee5 100644 --- a/apps/web/lib/catalogue-import/kinds/course/project.ts +++ b/apps/web/lib/catalogue-import/kinds/course/project.ts @@ -114,6 +114,7 @@ export type CourseSnapshotProjectionData = { sourceText: string; }>; areasOfInterest: Array<{ position: number; name: string }>; + tags: Array<{ position: number; name: string }>; attributes: Array<{ position: number; attributeKind: CourseExtraction["attributes"][number]["attributeKind"]; @@ -857,6 +858,18 @@ export function projectCourseSnapshot( areasOfInterest.map(({ name }) => name), "areas of interest", ); + // A tag is one category however it is capitalised, so a repeat collapses + // into the first spelling given. + const seenTags = new Set(); + const tags = (extraction.tags ?? []) + .map((name) => cleanText(name)) + .filter((name) => { + const key = name.toLowerCase(); + if (!name || seenTags.has(key)) return false; + seenTags.add(key); + return true; + }) + .map((name, index) => ({ position: index + 1, name })); const attributes = rowsByPosition(extraction.attributes, "attributes").map( (attribute) => ({ ...attribute, @@ -984,6 +997,7 @@ export function projectCourseSnapshot( unitOptions: unitValue.options, fees, areasOfInterest, + tags, attributes, relatedCourses, courseOffering: diff --git a/apps/web/lib/catalogue-import/kinds/course/prompt.ts b/apps/web/lib/catalogue-import/kinds/course/prompt.ts index a73e6165..1762accf 100644 --- a/apps/web/lib/catalogue-import/kinds/course/prompt.ts +++ b/apps/web/lib/catalogue-import/kinds/course/prompt.ts @@ -1,7 +1,7 @@ import { COURSE_EXTRACTION_SCHEMA_VERSION } from "./contract.ts"; export const COURSE_IMPORT_PARSER_VERSION = "coursemap-course-parser.v3"; -export const COURSE_IMPORT_PROMPT_VERSION = "coursemap-course-prompt.v4"; +export const COURSE_IMPORT_PROMPT_VERSION = "coursemap-course-prompt.v5"; export const COURSE_SNAPSHOT_SCHEMA_VERSION = "course-snapshot.v1"; /** @@ -30,6 +30,11 @@ Source rules: 10. classSummaryUrl is null or a complete HTTPS URL on programsandcourses.anu.edu.au taken from the page. 11. Use null or [] when the page does not state something. +Tags: +- tags are short categories that degree rules count units against, such as "courses tagged as Science" or "from the Engineering list". Tag a course with every category the page supports: the discipline its college or school teaches (Science, Engineering, Business, Arts, Law, Medicine), and course types the page names, such as research project, capstone, internship or work-integrated learning. +- When a known tag listed with the input fits, use it exactly as written. Coin a new tag only for a category no known tag covers, in title case and at most three words. +- Give evidence for each tag under fieldKey tags, with confidence below 0.8 when the tag is inferred rather than stated. + Writing the record: - Display text (introduction, description, workload, inherent requirements, prescribed texts, convener, delivery summary, assessment titles and learning outcomes) is copied from the page and tidied, never rewritten. Fix capitalisation, British English spelling, obvious typos and broken Markdown formatting, and drop page furniture such as "Back to the top". Do not summarise, shorten, reorder or add wording. Keep every course code, programme code, number, date, name and email address exactly as printed. - Every sourceText and evidence excerpt is the page's exact wording, untidied, so a reviewer can find it on the page. @@ -53,11 +58,14 @@ Evidence and review: export function buildCourseExtractionUserPrompt({ expectedCode, academicYear, + knownTags = [], pageMarkdown, }: { expectedCode: string; academicYear: number; + knownTags?: readonly string[]; pageMarkdown: string; }) { - return `Expected course: ${expectedCode.toUpperCase()}\nSelected academic year: ${academicYear}\n\n${pageMarkdown}`; + const tags = knownTags.length ? `Known tags: ${knownTags.join("; ")}\n` : ""; + return `Expected course: ${expectedCode.toUpperCase()}\nSelected academic year: ${academicYear}\n${tags}\n${pageMarkdown}`; } diff --git a/apps/web/lib/catalogue-import/kinds/course/tags.ts b/apps/web/lib/catalogue-import/kinds/course/tags.ts new file mode 100644 index 00000000..e74da110 --- /dev/null +++ b/apps/web/lib/catalogue-import/kinds/course/tags.ts @@ -0,0 +1,33 @@ +import type { SyncSql } from "../../../catalogue-sync/sync-store.ts"; + +const KNOWN_TAG_LIMIT = 200; + +/** + * The tag names already in use: those degree rules count units against, and + * those earlier courses were given. Offering them to the model keeps one + * category from splitting into near-duplicates such as Science and Sciences, + * which would stop a rule from matching the courses meant for it. + */ +export async function loadKnownCourseTags(sql: SyncSql): Promise { + const rows = await sql` + select name + from ( + select btrim(tag) as name from public.requirement_conditions + where tag is not null and btrim(tag) <> '' + union all + select name from public.course_tags + ) as used + group by lower(name), name + order by count(*) desc, name + limit ${KNOWN_TAG_LIMIT} + `; + const seen = new Set(); + return rows + .map((row) => String(row.name)) + .filter((name) => { + const key = name.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/apps/web/lib/catalogue-import/version-content.ts b/apps/web/lib/catalogue-import/version-content.ts index 3c2cc701..d60f9306 100644 --- a/apps/web/lib/catalogue-import/version-content.ts +++ b/apps/web/lib/catalogue-import/version-content.ts @@ -174,6 +174,7 @@ async function readCourseContent( outcomes, assessments, links, + tags, ] = await Promise.all([ sql`select position, units, label, source_text from public.course_unit_options where version_id = ${versionId} order by position`, sql`select * from public.course_fees where version_id = ${versionId} order by position`, @@ -185,6 +186,7 @@ async function readCourseContent( sql`select id, position, body from public.course_learning_outcomes where version_id = ${versionId} order by position`, sql`select id, position, title, weight, hurdle, due_text, source_text from public.course_assessment_items where version_id = ${versionId} order by position`, sql`select assessment_item_id, learning_outcome_id from public.course_assessment_outcomes where version_id = ${versionId}`, + sql`select position, name from public.course_tags where version_id = ${versionId} order by position`, ]); const outcomePosition = new Map( outcomes.map((row) => [Number(row.id), Number(row.position)]), @@ -242,6 +244,15 @@ async function readCourseContent( position: Number(row.position), name: String(row.name), })), + // Left out when empty, so versions from before tags hash as they did. + ...(tags.length + ? { + tags: tags.map((row) => ({ + position: Number(row.position), + name: String(row.name), + })), + } + : {}), attributes: attributes.map((row) => ({ position: Number(row.position), attributeKind: row.attribute_kind, diff --git a/apps/web/lib/catalogue-sync/kind-adapter.ts b/apps/web/lib/catalogue-sync/kind-adapter.ts index a0a3b294..68b338c2 100644 --- a/apps/web/lib/catalogue-sync/kind-adapter.ts +++ b/apps/web/lib/catalogue-sync/kind-adapter.ts @@ -1,6 +1,12 @@ -import type { ClaimedCatalogueSync } from "./sync-store.ts"; +import type { ClaimedCatalogueSync, SyncSql } from "./sync-store.ts"; import type { CatalogueKind, CatalogueContent } from "../catalogue/content.ts"; +/** Catalogue facts a prompt draws on beyond the page itself. */ +export type PromptContext = { + /** Tag names already in use, so the model reuses them rather than coining near-duplicates. */ + knownTags: string[]; +}; + export type FetchedSourcePage = { sourceUrl: string; canonicalUrl: string; @@ -50,7 +56,16 @@ export type CatalogueSyncAdapter = { /** The whole page as Markdown, which is also the model input. */ prepareInput(claim: ClaimedCatalogueSync, page: FetchedSourcePage): string; buildSystemPrompt(): string; - buildUserPrompt(claim: ClaimedCatalogueSync, pageMarkdown: string): string; + /** Reads what the prompt needs from the catalogue, once per sync. */ + loadPromptContext?( + sql: SyncSql, + claim: ClaimedCatalogueSync, + ): Promise; + buildUserPrompt( + claim: ClaimedCatalogueSync, + pageMarkdown: string, + context?: PromptContext, + ): string; /** Strict validation of raw model output, recorded for the audit trail. */ validateModelOutput( claim: ClaimedCatalogueSync, diff --git a/apps/web/lib/catalogue-sync/persist-source-version.ts b/apps/web/lib/catalogue-sync/persist-source-version.ts index c395e896..24103cf7 100644 --- a/apps/web/lib/catalogue-sync/persist-source-version.ts +++ b/apps/web/lib/catalogue-sync/persist-source-version.ts @@ -134,6 +134,12 @@ async function insertCourseContent( values (${snapshotId}, ${area.position}, ${area.name}) `; } + for (const tag of content.tags ?? []) { + await tx` + insert into public.course_tags (version_id, position, name) + values (${snapshotId}, ${tag.position}, ${tag.name}) + `; + } for (const attribute of content.attributes) { await tx` insert into public.course_attributes (version_id, position, attribute_kind, value, source_text) diff --git a/apps/web/lib/catalogue-sync/process-sync.ts b/apps/web/lib/catalogue-sync/process-sync.ts index df19f4f2..0ae13728 100644 --- a/apps/web/lib/catalogue-sync/process-sync.ts +++ b/apps/web/lib/catalogue-sync/process-sync.ts @@ -331,7 +331,10 @@ async function processClaimedSync({ const userPrompt = await runStage( "model_input_prepare", async (stageId) => { - const prompt = adapter.buildUserPrompt(claim, pageMarkdown); + const context = adapter.loadPromptContext + ? await adapter.loadPromptContext(sql, claim) + : undefined; + const prompt = adapter.buildUserPrompt(claim, pageMarkdown, context); await persistArtifact({ stageId, stageName: "model_input_prepare", diff --git a/apps/web/lib/catalogue/content.ts b/apps/web/lib/catalogue/content.ts index 57eec228..1175a12a 100644 --- a/apps/web/lib/catalogue/content.ts +++ b/apps/web/lib/catalogue/content.ts @@ -141,6 +141,11 @@ export type CourseContentWrite = { unitOptions: CourseSnapshotProjection["unitOptions"]; fees: CourseSnapshotProjection["fees"]; areasOfInterest: CourseSnapshotProjection["areasOfInterest"]; + /** + * Free-form categories that degree rules count units against. Absent when + * a course has none, so content written before tags keeps its hash. + */ + tags?: CourseSnapshotProjection["tags"]; attributes: CourseSnapshotProjection["attributes"]; relatedCourses: CourseSnapshotProjection["relatedCourses"]; offering: CourseSnapshotProjection["courseOffering"]; @@ -422,7 +427,9 @@ export function validateCatalogueContent(value: unknown): CatalogueContent { "learningOutcomes", "assessmentItems", "assessmentOutcomes", - ]) + ]) || + (value.course.tags !== undefined && + !hasObjectRows(value.course, ["tags"])) ) { throw new TypeError("The course content aggregate is incomplete."); } @@ -511,6 +518,7 @@ export function courseCatalogueContent({ unitOptions: projection.unitOptions, fees: projection.fees, areasOfInterest: projection.areasOfInterest, + ...(projection.tags.length ? { tags: projection.tags } : {}), attributes: projection.attributes, relatedCourses: projection.relatedCourses, offering: projection.courseOffering, diff --git a/apps/web/lib/catalogue/review-notes.ts b/apps/web/lib/catalogue/review-notes.ts index f5cc74df..85ba991e 100644 --- a/apps/web/lib/catalogue/review-notes.ts +++ b/apps/web/lib/catalogue/review-notes.ts @@ -54,6 +54,7 @@ const FIELD_NAMES: Record = { rule: "Requirement tree", sourceUpdatedAt: "ANU update date", areasOfInterest: "Areas of interest", + tags: "Tags", relatedCourses: "Related courses", contactText: "Contact", convenerText: "Convener", diff --git a/apps/web/lib/catalogue/review-units.ts b/apps/web/lib/catalogue/review-units.ts index cdaedda3..b8423e0a 100644 --- a/apps/web/lib/catalogue/review-units.ts +++ b/apps/web/lib/catalogue/review-units.ts @@ -96,6 +96,15 @@ export function catalogueReviewUnits( value: content.course[key], }); } + // Tags are optional content, so an empty set and no set are the same + // thing and neither is a change to review. + if (content.course.tags?.length) { + units.push({ + fieldPath: "course.tags", + unitKind: "collection", + value: content.course.tags, + }); + } } if (content.structure) { for (const key of Object.keys(content.structure.details)) { diff --git a/apps/web/lib/coursemap/catalogue-kinds.ts b/apps/web/lib/coursemap/catalogue-kinds.ts index c5a591b5..ace5cd28 100644 --- a/apps/web/lib/coursemap/catalogue-kinds.ts +++ b/apps/web/lib/coursemap/catalogue-kinds.ts @@ -198,6 +198,7 @@ export const FIELD_LABELS: Record = { "course.unitOptions": "Unit options", "course.fees": "Fees", "course.areasOfInterest": "Areas of interest", + "course.tags": "Tags", "course.attributes": "Attributes", "course.relatedCourses": "Related courses", "course.offering": "Offering", diff --git a/apps/web/lib/coursemap/course-types.ts b/apps/web/lib/coursemap/course-types.ts index dd61fb41..0281904f 100644 --- a/apps/web/lib/coursemap/course-types.ts +++ b/apps/web/lib/coursemap/course-types.ts @@ -146,6 +146,8 @@ export type CourseDetails = { academicCareer: string | null; accent: "blue" | "violet" | "mint" | "amber" | "rose" | "cyan"; areasOfInterest: string[]; + /** Free-form categories degree rules count units against. */ + tags: string[]; assessments: CourseAssessment[]; attributes: CourseAttribute[]; code: string; diff --git a/apps/web/lib/coursemap/course-version-view.ts b/apps/web/lib/coursemap/course-version-view.ts index e12f8135..439c5af5 100644 --- a/apps/web/lib/coursemap/course-version-view.ts +++ b/apps/web/lib/coursemap/course-version-view.ts @@ -57,6 +57,7 @@ function courseProjectionFromWrite(write: CatalogueContent): Json { unitOptions: course.unitOptions as unknown as Json, fees: course.fees as unknown as Json, areasOfInterest: course.areasOfInterest as unknown as Json, + tags: (course.tags ?? []) as unknown as Json, attributes: course.attributes as unknown as Json, relatedCourses: course.relatedCourses as unknown as Json, courseOffering: (course.offering ?? null) as unknown as Json, diff --git a/apps/web/lib/coursemap/published-courses.ts b/apps/web/lib/coursemap/published-courses.ts index 359706b6..296df768 100644 --- a/apps/web/lib/coursemap/published-courses.ts +++ b/apps/web/lib/coursemap/published-courses.ts @@ -100,8 +100,19 @@ const SNAPSHOT_LIST_SELECT = export type PublishedCourseFilters = { query?: string; subject?: string; + /** A level digit, such as 2, or that level or higher, such as 2+. */ level?: string; session?: string; + college?: string; + area?: string; + tag?: string; +}; + +export type CourseFilterOptions = { + subjects: Array<{ code: string; name: string | null }>; + colleges: string[]; + areas: string[]; + tags: string[]; }; export type PublishedCoursePage = { @@ -784,6 +795,9 @@ function detailAsCourseDetails(value: Json): CourseDetails | null { const areasOfInterest = readArray(value.areasOfInterest).flatMap((item) => isRecord(item) && readString(item.name) ? [readString(item.name)] : [], ); + const tags = readArray(value.tags).flatMap((item) => + isRecord(item) && readString(item.name) ? [readString(item.name)] : [], + ); const attributes = readArray(value.attributes).flatMap( (item) => isRecord(item) && readString(item.value) @@ -837,6 +851,7 @@ function detailAsCourseDetails(value: Json): CourseDetails | null { academicCareer: readNullableString(snapshot.academicCareer), accent: accentFor(code), areasOfInterest, + tags, assessments: readAssessments(value), assumedKnowledgeText: ruleText(value, "assumed_knowledge"), attributes, @@ -948,6 +963,68 @@ async function loadAcademicYearOptionsUncached(): Promise< ); } +async function loadCourseFilterOptionsUncached( + academicYear: number, +): Promise { + const empty = { subjects: [], colleges: [], areas: [], tags: [] }; + const supabase = createPublicClient(); + const year = await academicYearRecord(supabase, academicYear); + if (!year) return empty; + const [summaries, areas, tags] = await Promise.all([ + supabase + .from("published_course_summaries") + .select("version_id,subject_code,subject_name,college") + .eq("academic_year_id", year.id), + // Only published versions are readable here, so every row belongs to a + // published course of some year; the year's version set narrows them. + supabase.from("course_areas_of_interest").select("version_id,name"), + supabase.from("course_tags").select("version_id,name"), + ]); + for (const result of [summaries, areas, tags]) { + if (result.error) throw result.error; + } + const versions = new Set((summaries.data ?? []).map((row) => row.version_id)); + const distinctNames = (rows: Array<{ version_id: number; name: string }>) => { + const byKey = new Map(); + for (const row of rows) { + if (!versions.has(row.version_id)) continue; + const key = row.name.toLowerCase(); + if (!byKey.has(key)) byKey.set(key, row.name); + } + return [...byKey.values()].sort((left, right) => left.localeCompare(right)); + }; + const subjects = new Map(); + const colleges = new Set(); + for (const row of summaries.data ?? []) { + if (row.subject_code && !subjects.has(row.subject_code)) { + subjects.set(row.subject_code, row.subject_name); + } + if (row.college) colleges.add(row.college); + } + return { + subjects: [...subjects] + .map(([code, name]) => ({ code, name })) + .sort((left, right) => left.code.localeCompare(right.code)), + colleges: [...colleges].sort((left, right) => left.localeCompare(right)), + areas: distinctNames(areas.data ?? []), + tags: distinctNames(tags.data ?? []), + }; +} + +/** The values the course explorer can filter a year's courses by. */ +export async function loadCourseFilterOptions( + academicYear: number, +): Promise { + return unstable_cache( + () => loadCourseFilterOptionsUncached(academicYear), + ["published-course-filter-options", String(academicYear)], + { + revalidate: 300, + tags: [PUBLISHED_COURSE_PAGE_TAG, publishedCourseYearTag(academicYear)], + }, + )(); +} + export async function loadAcademicYearOptions(): Promise { return unstable_cache( loadAcademicYearOptionsUncached, @@ -970,6 +1047,40 @@ function searchPattern(value: string) { .trim(); } +/** Parses a level filter: `2` is 2000 level, `2+` is 2000 level or higher. */ +function levelFilter(value: string) { + const match = /^([1-9])(\+?)$/u.exec(value); + return match + ? { level: Number(match[1]) * 1000, orHigher: match[2] === "+" } + : null; +} + +/** Versions carrying an area of interest or tag, matched however it is cased. */ +async function snapshotIdsNamed( + supabase: SupabaseClient, + table: "course_areas_of_interest" | "course_tags", + name: string, +) { + const { data, error } = await supabase + .from(table) + .select("version_id") + .ilike( + "name", + name.replace(/[\\%_]/gu, (character) => `\\${character}`), + ); + if (error) throw error; + return [...new Set((data ?? []).map((row) => row.version_id))]; +} + +function intersect(lists: Array) { + const present = lists.filter((list): list is number[] => list !== null); + if (present.length === 0) return null; + return present.reduce((kept, list) => { + const allowed = new Set(list); + return kept.filter((id) => allowed.has(id)); + }); +} + async function snapshotIdsForSession( supabase: SupabaseClient, yearId: number, @@ -1167,6 +1278,7 @@ async function loadListRelationships( academicCareer: snapshot.academic_career, accent: accentFor(code), areasOfInterest: [], + tags: [], assessments: [], assumedKnowledgeText: "", attributes: [], @@ -1260,8 +1372,11 @@ async function loadPublishedCoursePageUncached({ const safePageSize = Math.min(100, Math.max(1, Math.floor(pageSize))); const query = firstFilterValue(filters.query); const subject = firstFilterValue(filters.subject).toUpperCase(); - const level = Number(firstFilterValue(filters.level)); + const level = levelFilter(firstFilterValue(filters.level)); const session = firstFilterValue(filters.session); + const college = firstFilterValue(filters.college); + const area = firstFilterValue(filters.area); + const tag = firstFilterValue(filters.tag); const supabase = createPublicClient(); const year = await academicYearRecord(supabase, academicYear); @@ -1269,10 +1384,16 @@ async function loadPublishedCoursePageUncached({ return { courses: [], page: safePage, pageSize: safePageSize, total: 0 }; } const cleanedQuery = searchPattern(query); - const sessionSnapshotIds = session - ? await snapshotIdsForSession(supabase, year.id, session) - : null; - if (sessionSnapshotIds?.length === 0) { + const snapshotIds = intersect( + await Promise.all([ + session ? snapshotIdsForSession(supabase, year.id, session) : null, + area + ? snapshotIdsNamed(supabase, "course_areas_of_interest", area) + : null, + tag ? snapshotIdsNamed(supabase, "course_tags", tag) : null, + ]), + ); + if (snapshotIds?.length === 0) { return { courses: [], page: safePage, pageSize: safePageSize, total: 0 }; } @@ -1281,11 +1402,14 @@ async function loadPublishedCoursePageUncached({ .select(SNAPSHOT_LIST_SELECT, { count: "exact" }) .eq("academic_year_id", year.id); if (subject) snapshotsQuery = snapshotsQuery.eq("subject_code", subject); - if (Number.isInteger(level) && level > 0) { - snapshotsQuery = snapshotsQuery.eq("level", level * 1000); + if (level) { + snapshotsQuery = level.orHigher + ? snapshotsQuery.gte("level", level.level) + : snapshotsQuery.eq("level", level.level); } - if (sessionSnapshotIds) { - snapshotsQuery = snapshotsQuery.in("version_id", sessionSnapshotIds); + if (college) snapshotsQuery = snapshotsQuery.eq("college", college); + if (snapshotIds) { + snapshotsQuery = snapshotsQuery.in("version_id", snapshotIds); } if (cleanedQuery) { const pattern = `*${cleanedQuery}*`; @@ -1318,16 +1442,21 @@ export async function loadPublishedCoursePage(args: { 100, Math.max(1, Math.floor(args.pageSize ?? 24)), ); - const query = firstFilterValue(args.filters?.query); - const subject = firstFilterValue(args.filters?.subject).toUpperCase(); - const level = Number(firstFilterValue(args.filters?.level)); - const session = firstFilterValue(args.filters?.session); + const filters = { + query: firstFilterValue(args.filters?.query), + subject: firstFilterValue(args.filters?.subject).toUpperCase(), + level: firstFilterValue(args.filters?.level), + session: firstFilterValue(args.filters?.session), + college: firstFilterValue(args.filters?.college), + area: firstFilterValue(args.filters?.area), + tag: firstFilterValue(args.filters?.tag), + }; return unstable_cache( () => loadPublishedCoursePageUncached({ academicYear: args.academicYear, - filters: { query, subject, level: String(level), session }, + filters, page: safePage, pageSize: safePageSize, }), @@ -1336,10 +1465,7 @@ export async function loadPublishedCoursePage(args: { String(args.academicYear), String(safePage), String(safePageSize), - query, - subject, - String(level), - session, + ...Object.values(filters), ], { revalidate: 300, diff --git a/apps/web/tests/course-import-transform.test.mjs b/apps/web/tests/course-import-transform.test.mjs index 638490e9..84fc6b71 100644 --- a/apps/web/tests/course-import-transform.test.mjs +++ b/apps/web/tests/course-import-transform.test.mjs @@ -17,10 +17,13 @@ import { } from "../lib/catalogue-import/kinds/course/model-canonical.ts"; import { buildCourseExtractionSystemPrompt, + buildCourseExtractionUserPrompt, COURSE_IMPORT_PARSER_VERSION, COURSE_IMPORT_PROMPT_VERSION, } from "../lib/catalogue-import/kinds/course/prompt.ts"; import { projectCourseSnapshot } from "../lib/catalogue-import/kinds/course/project.ts"; +import { courseCatalogueContent } from "../lib/catalogue/content.ts"; +import { catalogueReviewUnits } from "../lib/catalogue/review-units.ts"; // A complete, valid extraction of the reduced COMP2400 page in // fixtures/course-import, in the shape the model returns. @@ -215,7 +218,7 @@ test("advertises exact model formats in the prompt and JSON Schema", () => { assert.match(prompt, /tidied, never rewritten/); assert.match(prompt, /FINM2001; FINM2002; and, FINM2003 or FINM3011/); assert.equal(COURSE_IMPORT_PARSER_VERSION, "coursemap-course-parser.v3"); - assert.equal(COURSE_IMPORT_PROMPT_VERSION, "coursemap-course-prompt.v4"); + assert.equal(COURSE_IMPORT_PROMPT_VERSION, "coursemap-course-prompt.v5"); assert.equal( COURSE_EXTRACTION_JSON_SCHEMA.properties.schemaVersion.const, "course-extraction.v2", @@ -379,3 +382,50 @@ test("stable serialisation and fingerprints ignore object key insertion order", stableFingerprint({ ...right, list: [1, 2] }), ); }); + +test("tags collapse case-insensitive repeats and stay out of content when empty", () => { + const tagged = projectCourseSnapshot( + finalise({ + ...extraction, + tags: ["Science", " science ", "Research Project"], + }).extraction, + ); + assert.deepEqual(tagged.tags, [ + { position: 1, name: "Science" }, + { position: 2, name: "Research Project" }, + ]); + assert.deepEqual(courseCatalogueContent({ projection: tagged }).course.tags, [ + { position: 1, name: "Science" }, + { position: 2, name: "Research Project" }, + ]); + + // Content written before tags must hash the same as content with none. + const untagged = courseCatalogueContent({ + projection: projectCourseSnapshot(finalise(extraction).extraction), + }); + assert.equal("tags" in untagged.course, false); + assert.equal( + catalogueReviewUnits(untagged).some( + ({ fieldPath }) => fieldPath === "course.tags", + ), + false, + ); +}); + +test("the user prompt offers the tags already in use", () => { + const prompt = buildCourseExtractionUserPrompt({ + expectedCode: "comp2400", + academicYear: 2026, + knownTags: ["Science", "Engineering"], + pageMarkdown: "# COMP2400", + }); + assert.match(prompt, /Known tags: Science; Engineering\n/u); + assert.equal( + buildCourseExtractionUserPrompt({ + expectedCode: "comp2400", + academicYear: 2026, + pageMarkdown: "# COMP2400", + }), + "Expected course: COMP2400\nSelected academic year: 2026\n\n# COMP2400", + ); +}); diff --git a/apps/web/tests/fixtures/course-import/anu-2026-comp2400-extraction.json b/apps/web/tests/fixtures/course-import/anu-2026-comp2400-extraction.json index e59f517d..89c36834 100644 --- a/apps/web/tests/fixtures/course-import/anu-2026-comp2400-extraction.json +++ b/apps/web/tests/fixtures/course-import/anu-2026-comp2400-extraction.json @@ -25,6 +25,7 @@ "offeringStatus": "offered", "sourceUpdatedAt": null, "areasOfInterest": ["Information Technology", "Software Engineering"], + "tags": [], "fees": [ { "position": 1, diff --git a/apps/web/types/database.ts b/apps/web/types/database.ts index c8b6f994..2bb8a6c0 100644 --- a/apps/web/types/database.ts +++ b/apps/web/types/database.ts @@ -2729,6 +2729,45 @@ export type Database = { }, ] } + course_tags: { + Row: { + created_at: string + id: number + name: string + position: number + version_id: number + } + Insert: { + created_at?: string + id?: never + name: string + position: number + version_id: number + } + Update: { + created_at?: string + id?: never + name?: string + position?: number + version_id?: number + } + Relationships: [ + { + foreignKeyName: "course_tags_snapshot_id_fkey" + columns: ["version_id"] + isOneToOne: false + referencedRelation: "catalogue_versions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "course_tags_snapshot_id_fkey" + columns: ["version_id"] + isOneToOne: false + referencedRelation: "published_course_summaries" + referencedColumns: ["version_id"] + }, + ] + } course_unit_options: { Row: { created_at: string diff --git a/apps/web/ui/admin/catalogue/content-editor.tsx b/apps/web/ui/admin/catalogue/content-editor.tsx index 179daf52..feabc823 100644 --- a/apps/web/ui/admin/catalogue/content-editor.tsx +++ b/apps/web/ui/admin/catalogue/content-editor.tsx @@ -36,6 +36,7 @@ import { } from "@/lib/coursemap/requisite-conditions"; import { RequisiteRuleTree } from "@/ui/admin/requisites/requisite-rule-tree"; import { useCatalogueEditor } from "./catalogue-editor-context"; +import { TagsEditor } from "./tags-editor"; import { DetailsEditor, type FieldChoice, RowsEditor } from "./section-editor"; import { JsonCode } from "@/ui/common/json-code"; @@ -317,6 +318,30 @@ export function CatalogueContentEditor() { } /> + {!editing && !write.course.tags?.length ? null : ( +
+ + setWrite((current) => { + if (!current.course) return current; + // No tags is no key, so the content hashes as it did + // before tags existed. + const course: NonNullable = { + ...current.course, + tags, + }; + if (!tags.length) delete course.tags; + return { ...current, course }; + }) + } + /> +
+ )} {!editing && !Object.values(write.course.offering ?? {}).some( (value) => value !== null && value !== "", diff --git a/apps/web/ui/admin/catalogue/record-header.tsx b/apps/web/ui/admin/catalogue/record-header.tsx index 0b0a8dc0..0dc3ade8 100644 --- a/apps/web/ui/admin/catalogue/record-header.tsx +++ b/apps/web/ui/admin/catalogue/record-header.tsx @@ -56,19 +56,18 @@ export function RecordHeader({ {/* Being listed by ANU is the resting state of every record here, so saying so on each one said nothing. Only the delisting is worth a - line, and the source link belongs beside the title it is a link to. + line, and the title itself opens the ANU page. */} -
-

{record.title}

- - View on ANU
+ + {record.title} +