Skip to content
33 changes: 33 additions & 0 deletions apps/web/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
removePlanCourse,
saveProfileAndPlan,
setCurrentUserPlanExtensionYears,
setRequirementPlacement,
type CoursemapActionResult,
} from "@/lib/coursemap/actions";

Expand Down Expand Up @@ -52,6 +53,11 @@ type AppContextValue = {
attemptedUnits?: number,
) => Promise<CoursemapActionResult>;
removeAttempt: (attemptId: string) => Promise<CoursemapActionResult>;
/** Moves a course to a part of the degree, or back to automatic with null. */
setPlacement: (
courseCode: string,
placement: { structureCode: string; requirementKey: string } | null,
) => Promise<CoursemapActionResult>;
togglePermission: (attemptId: string) => void;
toggleOverloadApproval: (attemptId: string) => void;
notify: (message: string, tone?: ToastTone) => void;
Expand Down Expand Up @@ -332,6 +338,31 @@ export function AppProvider({
}));
}, []);

const setPlacement = useCallback(
async (
courseCode: string,
placement: { structureCode: string; requirementKey: string } | null,
) => {
const previous = state.placements ?? [];
const others = previous.filter(
(choice) => choice.courseCode !== courseCode,
);
// The page reallocates at once; a failed save puts the choice back.
setState((current) => ({
...current,
placements: placement
? [...others, { courseCode, ...placement }]
: others,
}));
const result = await setRequirementPlacement(courseCode, placement);
if (!result.ok) {
setState((current) => ({ ...current, placements: previous }));
}
return result;
},
[state.placements],
);

const toggleOverloadApproval = useCallback((attemptId: string) => {
setState((current) => ({
...current,
Expand All @@ -354,6 +385,7 @@ export function AppProvider({
reorderAttempt,
updateAttempt,
removeAttempt,
setPlacement,
togglePermission,
toggleOverloadApproval,
notify,
Expand All @@ -368,6 +400,7 @@ export function AppProvider({
reorderAttempt,
updateAttempt,
removeAttempt,
setPlacement,
togglePermission,
toggleOverloadApproval,
notify,
Expand Down
77 changes: 74 additions & 3 deletions apps/web/app/requirements/requirements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,16 @@ import type {
} from "@/lib/coursemap/onboarding-catalogue";
import type { SelectableStructureKind } from "@/lib/coursemap/programme-structure-options";
import type { Course } from "@/lib/coursemap/types";
import { requirementTreeProgress } from "@/lib/coursemap/requirement-progress";
import {
allocateRequirements,
placementOptions,
requirementConditionsByKey,
requirementTreeProgress,
} from "@/lib/coursemap/requirement-progress";
import {
conditionHeading,
type RequirementTreeCondition,
} from "@/ui/requirements/requirement-presentation";
import { requirementCourseStatus } from "@/lib/coursemap/requirement-display";
import {
degreeUnitProgress,
Expand Down Expand Up @@ -49,7 +58,7 @@ export function Requirements({
/** The section to open first, such as the major when adding one. */
initialTab?: PlanStructureKind;
}) {
const { state, updateProfile, notify } = useCoursemap();
const { state, updateProfile, notify, setPlacement } = useCoursemap();
const router = useRouter();
const [tab, setTab] = useState<PlanStructureKind>(initialTab);
const [choosing, setChoosing] = useState(false);
Expand Down Expand Up @@ -248,11 +257,72 @@ export function Requirements({
kind === "programme"
? (degree?.units ?? null)
: (option?.units ?? null);
const root = requirements?.root ?? null;
const conditions = requirementConditionsByKey(root);
const nodeKeyFor = (projectionKey: string) =>
[...conditions].find(
([, condition]) => condition.projectionKey === projectionKey,
)?.[0];
// A student's choices name rules by their stable key; a choice
// for a rule this version no longer has is simply not applied.
const pins = new Map(
(state.placements ?? []).flatMap((choice) => {
const nodeKey =
choice.structureCode === code
? nodeKeyFor(choice.requirementKey)
: undefined;
return nodeKey ? [[choice.courseCode, nodeKey] as const] : [];
}),
);
const allocation = allocateRequirements({
root,
attempts: state.attempts,
catalogue,
pins,
});
const treeProgress = requirementTreeProgress({
root: requirements?.root ?? null,
root,
attempts: state.attempts,
catalogue,
allocation,
});
const labelFor = (nodeKey: string) => {
const condition = conditions.get(nodeKey);
return condition
? conditionHeading(condition as RequirementTreeCondition)
: "another requirement";
};
const placement = {
allocation,
labelFor,
optionsFor: (courseCode: string) => {
const attempt = state.attempts.find(
(candidate) => candidate.courseCode === courseCode,
);
const course = attempt
? planningCourseForAttempt(attempt, catalogue)
: undefined;
return course
? placementOptions({ root, course }).map((nodeKey) => ({
nodeKey,
label: labelFor(nodeKey),
}))
: [];
},
onPlace: (courseCode: string, nodeKey: string | null) => {
const projectionKey = nodeKey
? conditions.get(nodeKey)?.projectionKey
: undefined;
void setPlacement(
courseCode,
projectionKey
? { structureCode: code, requirementKey: projectionKey }
: null,
).then((result) => {
if (!result.ok) notify(result.message, "warning");
});
},
};
return (
<div key={code} className="space-y-5">
{kind !== "programme" && (
Expand All @@ -271,6 +341,7 @@ export function Requirements({
context={{
catalogue,
progress: treeProgress,
placement,
attemptStatusByCode: attemptStatuses,
selectedStructureCodes: selectedCodes,
unitTarget: target,
Expand Down
22 changes: 22 additions & 0 deletions apps/web/lib/catalogue-import/kinds/structure/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,22 @@ export type AcademicStructureRequirementGroup = {
key: string;
operator: "all_of" | "any_of" | "minimum_count";
minimumCount: number | null;
/** See {@link AcademicStructureRequirementCondition.scope}. */
scope: RequirementScope;
title: string | null;
sourceText: string;
sourceLocator: string;
children: AcademicStructureRequirementRule[];
};

/**
* `part` fills a share of the degree, and a course counted here counts
* nowhere else. `degree` constrains every course the degree counts, such as
* "of which a maximum of 60 units from 1000-level courses", without using any
* course up.
*/
export type RequirementScope = "part" | "degree";

export type AcademicStructureRequirementCondition = {
type: "condition";
key: string;
Expand All @@ -110,6 +120,9 @@ export type AcademicStructureRequirementCondition = {
maximumLevel: number | null;
tag: string | null;
freeText: string | null;
scope: RequirementScope;
/** A course list that ends "Any other ANU courses": the list is suggestions. */
includesAnyCourse: boolean;
sourceText: string;
sourceLocator: string;
};
Expand Down Expand Up @@ -311,6 +324,8 @@ const requirementConditionSchema: z.ZodType<AcademicStructureRequirementConditio
maximumLevel: z.number().int().min(0).max(9999).nullable().default(null),
tag: nullableString,
freeText: nullableString,
scope: z.enum(["part", "degree"]).default("part"),
includesAnyCourse: z.boolean().default(false),
sourceText: nonEmptyString,
sourceLocator: nonEmptyString,
})
Expand Down Expand Up @@ -535,6 +550,7 @@ const requirementRuleSchema: z.ZodType<AcademicStructureRequirementRule> =
key: nonEmptyString,
operator: z.enum(["all_of", "any_of", "minimum_count"]),
minimumCount: z.number().int().positive().nullable().default(null),
scope: z.enum(["part", "degree"]).default("part"),
title: nullableString,
sourceText: nonEmptyString,
sourceLocator: nonEmptyString,
Expand Down Expand Up @@ -987,6 +1003,7 @@ export const ACADEMIC_STRUCTURE_EXTRACTION_JSON_SCHEMA = {
"key",
"operator",
"minimumCount",
"scope",
"title",
"sourceText",
"sourceLocator",
Expand All @@ -997,6 +1014,7 @@ export const ACADEMIC_STRUCTURE_EXTRACTION_JSON_SCHEMA = {
key: { type: "string", minLength: 1 },
operator: { enum: ["all_of", "any_of", "minimum_count"] },
minimumCount: { type: ["integer", "null"], minimum: 1 },
scope: { enum: ["part", "degree"] },
title: nullableStringSchema,
sourceText: { type: "string", minLength: 1 },
sourceLocator: { type: "string", minLength: 1 },
Expand Down Expand Up @@ -1025,6 +1043,8 @@ export const ACADEMIC_STRUCTURE_EXTRACTION_JSON_SCHEMA = {
"maximumLevel",
"tag",
"freeText",
"scope",
"includesAnyCourse",
"sourceText",
"sourceLocator",
],
Expand Down Expand Up @@ -1069,6 +1089,8 @@ export const ACADEMIC_STRUCTURE_EXTRACTION_JSON_SCHEMA = {
maximumLevel: { type: ["integer", "null"], minimum: 0 },
tag: nullableStringSchema,
freeText: nullableStringSchema,
scope: { enum: ["part", "degree"] },
includesAnyCourse: { type: "boolean" },
sourceText: { type: "string", minLength: 1 },
sourceLocator: { type: "string", minLength: 1 },
},
Expand Down
5 changes: 5 additions & 0 deletions apps/web/lib/catalogue-import/kinds/structure/finalise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
normaliseAcademicStructureModelExtraction,
repairRequirementNodes,
} from "./model-canonical.ts";
import { withListedStructureOptions } from "./listed-options.ts";
import { unsupportedModelWording } from "../../model-evidence.ts";
import {
modelResponseProblem,
Expand Down Expand Up @@ -190,6 +191,10 @@ export function finaliseAcademicStructureExtraction({
const finalised: AcademicStructureExtraction = {
...extraction,
description,
relationships:
kind === "programme"
? withListedStructureOptions(extraction.relationships, pageMarkdown)
: extraction.relationships,
requirements: ensureRequirementRootGroup(extraction.requirements),
reviewItems,
};
Expand Down
85 changes: 85 additions & 0 deletions apps/web/lib/catalogue-import/kinds/structure/listed-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type {
AcademicStructureExtraction,
AcademicStructureKind,
AcademicStructureRelationship,
} from "./contract.ts";

type OptionKind = Exclude<AcademicStructureKind, "programme">;

/** The headings a programme page lists its majors, minors and specialisations under. */
const LIST_HEADINGS: Record<string, OptionKind> = {
majors: "major",
minors: "minor",
specialisations: "specialisation",
specializations: "specialisation",
};

const CODE_SUFFIX: Record<OptionKind, RegExp> = {
major: /-MAJ$/u,
minor: /-MIN$/u,
specialisation: /-(?:HSPC|SPEC)$/u,
};

/** A link the page reader has turned into a record code, or a bare code. */
const LISTED_CODE =
/\[([^\]]+)\]\(([A-Z0-9][A-Z0-9-]{1,31})\)|\b([A-Z0-9]{2,}-(?:MAJ|MIN|SPEC|HSPC))\b/gu;

/**
* The majors, minors and specialisations a programme page lists under its own
* Majors, Minors and Specialisations headings. The model has left these out
* even when asked, and a student can only choose what is recorded as an
* option, so the lists are read from the page as well.
*/
export function listedStructureOptions(
pageMarkdown: string,
): Omit<AcademicStructureRelationship, "position">[] {
const options: Omit<AcademicStructureRelationship, "position">[] = [];
let listing: { kind: OptionKind; heading: string; level: number } | null =
null;
for (const line of pageMarkdown.split("\n")) {
const heading = /^(#{1,4})\s+(.+?)\s*$/u.exec(line);
if (heading) {
const level = heading[1].length;
if (listing && level > listing.level) continue;
const kind = LIST_HEADINGS[heading[2].toLowerCase()];
listing = kind ? { kind, heading: heading[2], level } : null;
continue;
}
if (!listing) continue;
for (const match of line.matchAll(LISTED_CODE)) {
const code = (match[2] ?? match[3]).toUpperCase();
if (!CODE_SUFFIX[listing.kind].test(code)) continue;
const title = match[1]?.trim() || null;
options.push({
relationshipKind: "option",
targetKind: listing.kind,
targetCode: code,
targetTitle: title,
sourceText: title ?? code,
sourceLocator: listing.heading,
});
}
}
return options;
}

/** Adds the listed options the model did not record, after the ones it did. */
export function withListedStructureOptions(
relationships: AcademicStructureExtraction["relationships"],
pageMarkdown: string,
): AcademicStructureExtraction["relationships"] {
const recorded = new Set(
relationships
.filter(({ relationshipKind }) => relationshipKind === "option")
.map(({ targetKind, targetCode }) => `${targetKind}:${targetCode}`),
);
const last = Math.max(0, ...relationships.map(({ position }) => position));
const added: AcademicStructureExtraction["relationships"] = [];
for (const option of listedStructureOptions(pageMarkdown)) {
const key = `${option.targetKind}:${option.targetCode}`;
if (recorded.has(key)) continue;
recorded.add(key);
added.push({ ...option, position: last + added.length + 1 });
}
return added.length ? [...relationships, ...added] : relationships;
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export function ensureRequirementRootGroup(
: "requirements:root",
operator: "all_of" as const,
minimumCount: null,
scope: "part" as const,
title: "Requirements",
sourceText: requirements.sourceText ?? rule.sourceText,
sourceLocator: requirements.sourceLocator ?? rule.sourceLocator,
Expand Down
Loading
Loading