From a39325da5165f023bd82ed3874f13f63aefc133c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 08:14:05 +0000 Subject: [PATCH 1/4] feat: store course tags --- apps/web/types/database.ts | 39 +++++ supabase/migrations/015_course_tags.sql | 211 ++++++++++++++++++++++++ supabase/tests/database/course_tags.sql | 70 ++++++++ 3 files changed, 320 insertions(+) create mode 100644 supabase/migrations/015_course_tags.sql create mode 100644 supabase/tests/database/course_tags.sql 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/supabase/migrations/015_course_tags.sql b/supabase/migrations/015_course_tags.sql new file mode 100644 index 00000000..655350f1 --- /dev/null +++ b/supabase/migrations/015_course_tags.sql @@ -0,0 +1,211 @@ +-- Free-form course tags. +-- +-- Degree rules can ask for units of courses "tagged" with a category, such as +-- Science, that ANU does not publish as a field. A tag is read from the course +-- page by the extraction model, or written by an administrator, and lives on +-- the course version like its areas of interest: it is drafted, reviewed, +-- published and sealed with the rest of the version. The model's confidence +-- for each tag is kept with the version's field provenance, not here. + +create table if not exists public.course_tags ( + id bigint not null, + version_id bigint not null, + position integer not null, + name text not null, + created_at timestamp with time zone default now() not null, + constraint course_tags_name_not_blank_check check ((btrim(name) <> ''::text)), + constraint course_tags_name_trimmed_check check ((name = btrim(name))), + constraint course_tags_position_check check ((position > 0)) +); + +alter table public.course_tags alter column id add generated always as identity ( + sequence name public.course_tags_id_seq + start with 1 + increment by 1 + no minvalue + no maxvalue + cache 1 +); + +alter table only public.course_tags + add constraint course_tags_pkey primary key (id); + +alter table only public.course_tags + add constraint course_tags_snapshot_position_unique unique (version_id, position); + +alter table only public.course_tags + add constraint course_tags_snapshot_id_fkey foreign key (version_id) + references public.catalogue_versions(id) on delete cascade; + +-- A tag is one category however it is capitalised. +create unique index course_tags_snapshot_name_unique + on public.course_tags using btree (version_id, lower(name)); + +create index course_tags_course_snapshot_id_idx + on public.course_tags using btree (version_id); + +-- The course explorer filters by tag. +create index course_tags_name_idx + on public.course_tags using btree (lower(name)); + +create or replace trigger course_tags_guard_sealed + before insert or delete or update on public.course_tags + for each row execute function private.guard_snapshot_child_mutation(); + +alter table public.course_tags enable row level security; + +create policy course_tags_admin_insert on public.course_tags + for insert to authenticated + with check (( select private.can_write_catalogue() as can_write_catalogue)); + +create policy course_tags_read on public.course_tags + for select to authenticated, anon + using (( select private.can_read_version(course_tags.version_id) as can_read_version)); + +revoke all on table public.course_tags from public, anon, authenticated, service_role; +revoke all on sequence public.course_tags_id_seq from public, anon, authenticated, service_role; +grant all on table public.course_tags to service_role; +grant select on table public.course_tags to authenticated; +grant select on table public.course_tags to anon; +grant all on sequence public.course_tags_id_seq to service_role; +grant select,usage on sequence public.course_tags_id_seq to authenticated; + +comment on table public.course_tags is 'Free-form categories on a course version, such as Science, that degree rules count units against.'; + +create or replace function private.course_version_projection(p_version_id bigint) returns jsonb + language sql stable + set search_path to '' + as $$ + with selected_snapshot as ( + select + snapshots.id, + snapshots.record_id, + snapshots.academic_year_id, + snapshots.origin, + snapshots.source_document_id, + snapshots.created_at, + snapshots.sealed_at, + details.*, + items.code as course_code, + academic_years.year as academic_year + from public.catalogue_versions as snapshots + join public.course_version_details as details on details.version_id = snapshots.id + join public.catalogue_records as item_years on item_years.id = snapshots.record_id + join public.catalogue_codes as items on items.id = item_years.code_id + join public.academic_years on academic_years.id = snapshots.academic_year_id + where snapshots.id = p_version_id + ) + select jsonb_build_object( + 'courseCode', snapshot.course_code, + 'academicYear', snapshot.academic_year, + 'origin', snapshot.origin, + 'snapshot', jsonb_build_object( + 'title', snapshot.title, + 'unitValueKind', snapshot.unit_value_kind, + 'units', snapshot.units, + 'minimumUnits', snapshot.minimum_units, + 'maximumUnits', snapshot.maximum_units, + 'eftsl', snapshot.eftsl, + 'level', snapshot.level, + 'subjectCode', snapshot.subject_code, + 'subjectName', snapshot.subject_name, + 'school', snapshot.school, + 'college', snapshot.college, + 'academicCareer', snapshot.academic_career, + 'convenerText', snapshot.convener_text, + 'deliverySummary', snapshot.delivery_summary, + 'introduction', snapshot.introduction, + 'description', snapshot.description, + 'workloadText', snapshot.workload_text, + 'workloadHours', snapshot.workload_hours, + 'inherentRequirements', snapshot.inherent_requirements, + 'prescribedTexts', snapshot.prescribed_texts, + 'offeringStatus', snapshot.offering_status, + 'sourceUpdatedAt', snapshot.source_updated_at + ), + 'unitOptions', coalesce(( + select jsonb_agg(jsonb_build_object( + 'position', options.position, 'units', options.units, + 'label', options.label, 'sourceText', options.source_text + ) order by options.position) + from public.course_unit_options as options where options.version_id = p_version_id + ), '[]'::jsonb), + 'fees', coalesce(( + select jsonb_agg(jsonb_build_object( + 'position', fees.position, 'feeYear', fees.fee_year, 'audience', fees.audience, + 'feeType', fees.fee_type, 'amount', fees.amount, 'currency', fees.currency, + 'basis', fees.basis, 'studentContributionBand', fees.student_contribution_band, + 'sourceLabel', fees.source_label, 'sourceText', fees.source_text + ) order by fees.position) + from public.course_fees as fees where fees.version_id = p_version_id + ), '[]'::jsonb), + 'tags', coalesce(( + select jsonb_agg(jsonb_build_object('position', tags.position, 'name', tags.name) + order by tags.position) + from public.course_tags as tags where tags.version_id = p_version_id + ), '[]'::jsonb), + 'areasOfInterest', coalesce(( + select jsonb_agg(jsonb_build_object('position', areas.position, 'name', areas.name) + order by areas.position) + from public.course_areas_of_interest as areas where areas.version_id = p_version_id + ), '[]'::jsonb), + 'attributes', coalesce(( + select jsonb_agg(jsonb_build_object( + 'position', attributes.position, 'attributeKind', attributes.attribute_kind, + 'value', attributes.value, 'sourceText', attributes.source_text + ) order by attributes.position) + from public.course_attributes as attributes where attributes.version_id = p_version_id + ), '[]'::jsonb), + 'relatedCourses', coalesce(( + select jsonb_agg(jsonb_build_object( + 'position', related.position, 'relationKind', related.relation_kind, + 'sourceCourseCode', related.source_course_code, + 'sourceCourseTitle', related.source_course_title, 'sourceText', related.source_text + ) order by related.position) + from public.course_related_courses as related where related.version_id = p_version_id + ), '[]'::jsonb), + 'courseOffering', ( + select jsonb_build_object('deliveryMode', offerings.delivery_mode, 'location', offerings.location) + from public.course_offerings as offerings where offerings.version_id = p_version_id + ), + 'offeringSessions', coalesce(( + select jsonb_agg(jsonb_build_object( + 'position', sessions.position, 'calendarYear', snapshot.academic_year, + 'academicPeriodCode', sessions.academic_period_code, + 'academicPeriodName', sessions.academic_period_name, + 'classNumber', sessions.class_number, 'startsOn', sessions.starts_on, + 'enrolClosesOn', sessions.enrol_closes_on, 'censusOn', sessions.census_on, + 'endsOn', sessions.ends_on, 'deliveryMode', sessions.delivery_mode, + 'location', sessions.location, 'classSummaryUrl', sessions.class_summary_url, + 'sourceText', sessions.source_text + ) order by sessions.position) + from public.offering_sessions as sessions where sessions.version_id = p_version_id + ), '[]'::jsonb), + 'learningOutcomes', coalesce(( + select jsonb_agg(jsonb_build_object('position', outcomes.position, 'body', outcomes.body) + order by outcomes.position) + from public.course_learning_outcomes as outcomes where outcomes.version_id = p_version_id + ), '[]'::jsonb), + 'assessmentItems', coalesce(( + select jsonb_agg(jsonb_build_object( + 'position', items.position, 'title', items.title, 'weight', items.weight, + 'hurdle', items.hurdle, 'dueText', items.due_text, 'sourceText', items.source_text + ) order by items.position) + from public.course_assessment_items as items where items.version_id = p_version_id + ), '[]'::jsonb), + 'assessmentOutcomes', coalesce(( + select jsonb_agg(jsonb_build_object( + 'assessmentPosition', items.position, 'learningOutcomePosition', outcomes.position + ) order by items.position, outcomes.position) + from public.course_assessment_outcomes as links + join public.course_assessment_items as items on items.id = links.assessment_item_id + join public.course_learning_outcomes as outcomes on outcomes.id = links.learning_outcome_id + where links.version_id = p_version_id + ), '[]'::jsonb), + 'sourceDocumentId', snapshot.source_document_id, + 'sourceUpdatedAt', snapshot.source_updated_at, + 'createdAt', snapshot.created_at, + 'sealedAt', snapshot.sealed_at + ) || private.requirement_projection(p_version_id) + from selected_snapshot as snapshot; +$$; diff --git a/supabase/tests/database/course_tags.sql b/supabase/tests/database/course_tags.sql new file mode 100644 index 00000000..4bd58791 --- /dev/null +++ b/supabase/tests/database/course_tags.sql @@ -0,0 +1,70 @@ +-- Course tags are catalogue content: public wherever the version they belong +-- to is readable, written only by the sync or an administrator's publication, +-- sealed with that version, and one category however they are capitalised. + +begin; + +create extension if not exists pgtap with schema extensions; + +select extensions.plan(8); + +select extensions.has_table('public', 'course_tags', 'course tags are stored'); + +select extensions.ok( + ( + select relrowsecurity from pg_class + where oid = 'public.course_tags'::regclass + ), + 'row level security is enabled on course tags' +); + +select extensions.ok( + not has_table_privilege( + 'authenticated', 'public.course_tags', 'insert, update, delete' + ), + 'authenticated clients cannot write course tags directly' +); + +select extensions.ok( + has_table_privilege('anon', 'public.course_tags', 'select'), + 'anonymous readers may read tags on versions their policy allows' +); + +select extensions.is( + ( + select array_agg(policyname::text order by policyname) + from pg_policies + where schemaname = 'public' and tablename = 'course_tags' + ), + array['course_tags_admin_insert', 'course_tags_read'], + 'tags carry the same policies as areas of interest' +); + +select extensions.ok( + exists ( + select 1 from pg_trigger + where tgrelid = 'public.course_tags'::regclass + and tgname = 'course_tags_guard_sealed' + ), + 'tags are sealed with their version' +); + +select extensions.ok( + exists ( + select 1 from pg_indexes + where schemaname = 'public' + and indexname = 'course_tags_snapshot_name_unique' + and indexdef ilike '%lower(name)%' + ), + 'a version holds each tag once, however it is capitalised' +); + +select extensions.ok( + pg_get_functiondef('private.course_version_projection(bigint)'::regprocedure) + ilike '%''tags''%public.course_tags%', + 'the course projection carries its tags' +); + +select * from extensions.finish(); + +rollback; From fec28621eb7ce5d5ac96495aa8a585c889368200 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 08:14:05 +0000 Subject: [PATCH 2/4] feat: tag courses from their ANU pages --- .../catalogue-import/kinds/course/adapter.ts | 7 ++- .../catalogue-import/kinds/course/contract.ts | 8 +++ .../catalogue-import/kinds/course/finalise.ts | 1 + .../catalogue-import/kinds/course/project.ts | 14 +++++ .../catalogue-import/kinds/course/prompt.ts | 12 ++++- .../lib/catalogue-import/kinds/course/tags.ts | 33 ++++++++++++ .../lib/catalogue-import/version-content.ts | 11 ++++ apps/web/lib/catalogue-sync/kind-adapter.ts | 19 ++++++- .../catalogue-sync/persist-source-version.ts | 6 +++ apps/web/lib/catalogue-sync/process-sync.ts | 5 +- apps/web/lib/catalogue/content.ts | 10 +++- apps/web/lib/catalogue/review-notes.ts | 1 + apps/web/lib/catalogue/review-units.ts | 9 ++++ apps/web/lib/coursemap/catalogue-kinds.ts | 1 + apps/web/lib/coursemap/course-types.ts | 2 + apps/web/lib/coursemap/course-version-view.ts | 1 + .../tests/course-import-transform.test.mjs | 52 ++++++++++++++++++- .../anu-2026-comp2400-extraction.json | 1 + 18 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 apps/web/lib/catalogue-import/kinds/course/tags.ts 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/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, From 4de5287c7f365de77b8a23c4ac98e18ac6c39e07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 08:14:05 +0000 Subject: [PATCH 3/4] feat: edit tags on each course and link the record title to ANU --- .../web/ui/admin/catalogue/content-editor.tsx | 25 +++++ apps/web/ui/admin/catalogue/record-header.tsx | 23 ++-- apps/web/ui/admin/catalogue/tags-editor.tsx | 100 ++++++++++++++++++ 3 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 apps/web/ui/admin/catalogue/tags-editor.tsx 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} +