From 3ae8df27ee5fa1790c2fca23283d75205fd6c7dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 08:14:09 +0000 Subject: [PATCH 1/6] feat: review a record's first ANU reading and record confidence --- .../catalogue-import/kinds/course/prompt.ts | 2 +- .../catalogue-sync/persist-source-version.ts | 117 ++++++++----- apps/web/lib/catalogue/drafts.ts | 9 + apps/web/lib/catalogue/first-read.ts | 156 ++++++++++++++++++ .../lib/catalogue/source-review-decisions.ts | 38 +++++ apps/web/lib/catalogue/source-review-store.ts | 110 +++++++++++- apps/web/lib/catalogue/source-review.ts | 2 +- .../catalogue-source-review-database.test.mjs | 74 ++++++++- apps/web/tests/first-read.test.ts | 140 ++++++++++++++++ apps/web/types/database.ts | 9 + supabase/migrations/016_first_read_review.sql | 39 +++++ 11 files changed, 651 insertions(+), 45 deletions(-) create mode 100644 apps/web/lib/catalogue/first-read.ts create mode 100644 apps/web/tests/first-read.test.ts create mode 100644 supabase/migrations/016_first_read_review.sql diff --git a/apps/web/lib/catalogue-import/kinds/course/prompt.ts b/apps/web/lib/catalogue-import/kinds/course/prompt.ts index 1762accf..e1871acb 100644 --- a/apps/web/lib/catalogue-import/kinds/course/prompt.ts +++ b/apps/web/lib/catalogue-import/kinds/course/prompt.ts @@ -49,7 +49,7 @@ Requisites: - Model the whole rule whenever the page's punctuation settles its grouping. Use unmodelledText, with a review item, only for wording you genuinely cannot place in the rule. Evidence and review: -- Give evidence for each field you fill. Its fieldKey is the exact field path, such as requisites.prerequisiteRule or offerings. +- Give evidence for every field you fill, not only tags and requisites: title, description, unit value, offerings, fees, assessment, learning outcomes, areas of interest and the rest each get an entry. Its fieldKey is the exact field path, such as title, requisites.prerequisiteRule or offerings. A field without evidence reaches the reviewer with no confidence. - Confidence is how directly the page states the value, from 0 to 1. - Add specific review items for ambiguity, unsupported wording or conflicting statements on the page. - Do not include chain-of-thought, hidden reasoning, commentary or self-evaluation. Only return the schema fields.`; diff --git a/apps/web/lib/catalogue-sync/persist-source-version.ts b/apps/web/lib/catalogue-sync/persist-source-version.ts index 24103cf7..1febfc01 100644 --- a/apps/web/lib/catalogue-sync/persist-source-version.ts +++ b/apps/web/lib/catalogue-sync/persist-source-version.ts @@ -14,7 +14,10 @@ import { contentHashForCatalogueContent, readVersionContent, } from "../catalogue-import/version-content.ts"; -import { generateSourceReview } from "../catalogue/source-review-store.ts"; +import { + generateFirstReadReview, + generateSourceReview, +} from "../catalogue/source-review-store.ts"; export type PersistedSourceVersion = { status: "unchanged" | "review_required" | "applied"; @@ -545,16 +548,85 @@ export async function persistSourceVersion( populatedDraft: Boolean(draftFromSource), }; } + const lockDraft = async () => { + const [row] = + await tx`select content, content_hash from public.catalogue_drafts + where record_id = ${claim.recordId} for update`; + return row; + }; + const hasMeaningfulLocalContent = ( + draft: Record | undefined, + ) => { + const empty = emptyCatalogueContent({ + kind: claim.kind, + code: claim.code, + academicYear: claim.academicYear, + title: + record.listing_title === null ? null : String(record.listing_title), + }); + return ( + record.published_version_id !== null || + (draft !== undefined && + String(draft.content_hash) !== contentHashForCatalogueContent(empty)) + ); + }; + /** + * Fills the draft from a source version and queues every part of it for + * a person. Review rows belong to the sync that made the version, so + * they stay joined to it when this sync found nothing new. + */ + const populateDraft = async ( + sourceVersionId: number, + reviewSyncId: string, + ) => { + await tx`insert into public.catalogue_drafts ( + record_id, base_version_id, content, content_hash, content_schema_version, + revision, updated_by + ) values (${claim.recordId}, ${sourceVersionId}, ${tx.json(write as never)}, + ${write.contentHash}, ${CATALOGUE_CONTENT_SCHEMA_VERSION}, 0, null) + on conflict (record_id) do update set base_version_id = excluded.base_version_id, + content = excluded.content, content_hash = excluded.content_hash, + content_schema_version = excluded.content_schema_version, + revision = public.catalogue_drafts.revision + 1, updated_by = null, + updated_at = now()`; + await tx`delete from public.catalogue_draft_provenance where record_id = ${claim.recordId}`; + await tx`insert into public.catalogue_draft_provenance ( + record_id, field_path, origin, source_version_id, source_evidence_id + ) select ${claim.recordId}, field_path, method, ${sourceVersionId}, id + from public.catalogue_version_provenance where version_id = ${sourceVersionId}`; + await tx`insert into public.catalogue_change_events ( + record_id, draft_revision, event_kind, origin, version_id + ) select ${claim.recordId}, revision, 'source_draft_created', 'source', ${sourceVersionId} + from public.catalogue_drafts where record_id = ${claim.recordId}`; + // The draft took the model's reading whole, so every part of it is + // queued for a person, rated by how sure the reading is. + await generateFirstReadReview(tx, { + syncId: reviewSyncId, + recordId: claim.recordId, + content: write, + }); + }; const previousSourceVersionId = record.latest_source_version_id === null ? null : Number(record.latest_source_version_id); const [previous] = previousSourceVersionId - ? await tx`select content_hash from public.catalogue_versions where id = ${previousSourceVersionId}` + ? await tx`select content_hash, sync_id from public.catalogue_versions where id = ${previousSourceVersionId}` : []; if (previous && String(previous.content_hash) === write.contentHash) { await tx`update public.catalogue_records set source_checked_at = now() where id = ${claim.recordId}`; + // ANU has not changed, but a record whose draft was discarded still + // wants the reading back. + const draft = await lockDraft(); + if (!hasMeaningfulLocalContent(draft) && previous.sync_id !== null) { + await populateDraft(previousSourceVersionId!, String(previous.sync_id)); + return { + status: "applied", + sourceVersionId: previousSourceVersionId!, + populatedDraft: true, + }; + } await tx`insert into public.catalogue_change_events ( record_id, event_kind, origin, actor_id, version_id ) values (${claim.recordId}, 'source_checked', 'source', null, ${previousSourceVersionId})`; @@ -595,42 +667,11 @@ export async function persistSourceVersion( latest_source_version_id = ${sourceVersionId}, source_checked_at = now() where id = ${claim.recordId}`; - const [draft] = - await tx`select content, content_hash from public.catalogue_drafts - where record_id = ${claim.recordId} for update`; - const empty = emptyCatalogueContent({ - kind: claim.kind, - code: claim.code, - academicYear: claim.academicYear, - title: - record.listing_title === null ? null : String(record.listing_title), - }); - const hasMeaningfulLocalContent = - record.published_version_id !== null || - (draft && - String(draft.content_hash) !== contentHashForCatalogueContent(empty)); - const populateDraft = - previousSourceVersionId === null && !hasMeaningfulLocalContent; - if (populateDraft) { - await tx`insert into public.catalogue_drafts ( - record_id, base_version_id, content, content_hash, content_schema_version, - revision, updated_by - ) values (${claim.recordId}, ${sourceVersionId}, ${tx.json(write as never)}, - ${write.contentHash}, ${CATALOGUE_CONTENT_SCHEMA_VERSION}, 0, null) - on conflict (record_id) do update set base_version_id = excluded.base_version_id, - content = excluded.content, content_hash = excluded.content_hash, - content_schema_version = excluded.content_schema_version, - revision = public.catalogue_drafts.revision + 1, updated_by = null, - updated_at = now()`; - await tx`delete from public.catalogue_draft_provenance where record_id = ${claim.recordId}`; - await tx`insert into public.catalogue_draft_provenance ( - record_id, field_path, origin, source_version_id, source_evidence_id - ) select ${claim.recordId}, field_path, method, ${sourceVersionId}, id - from public.catalogue_version_provenance where version_id = ${sourceVersionId}`; - await tx`insert into public.catalogue_change_events ( - record_id, draft_revision, event_kind, origin, version_id - ) select ${claim.recordId}, revision, 'source_draft_created', 'source', ${sourceVersionId} - from public.catalogue_drafts where record_id = ${claim.recordId}`; + const draft = await lockDraft(); + // A record with nothing of its own, never read or discarded since, takes + // the reading whole rather than comparing it with nothing. + if (!hasMeaningfulLocalContent(draft)) { + await populateDraft(sourceVersionId, claim.syncId); return { status: "applied", sourceVersionId, populatedDraft: true }; } diff --git a/apps/web/lib/catalogue/drafts.ts b/apps/web/lib/catalogue/drafts.ts index c6da33b6..ba577eb5 100644 --- a/apps/web/lib/catalogue/drafts.ts +++ b/apps/web/lib/catalogue/drafts.ts @@ -7,6 +7,7 @@ import type { import { withSyncDatabaseClient } from "@/lib/catalogue-sync/sync-store"; import { diffSnapshotWrites } from "@/lib/catalogue-import/changes"; import { insertVersionContent } from "@/lib/catalogue-sync/persist-source-version"; +import { countBlockingFirstReads } from "@/lib/catalogue/source-review-store"; import { contentHashForCatalogueContent, readVersionContent, @@ -540,6 +541,14 @@ export async function publishCatalogueDraft({ const draft = draftFromRow(row); if (draft.revision !== expectedRevision) throw new CatalogueDraftConflictError(draft.revision); + // A first reading from ANU is the model's word until a person has + // looked at the parts it was unsure of. + const blocking = await countBlockingFirstReads(tx, recordId); + if (blocking > 0) + throw new CatalogueDraftError( + `${blocking} ${blocking === 1 ? "part" : "parts"} of the first ANU reading ${blocking === 1 ? "needs" : "need"} review before publishing. Approve or correct ${blocking === 1 ? "it" : "them"} on the Changes tab.`, + "FIRST_READ_REVIEW", + ); const publishedContent = record.published_version_id ? await readVersionContent(tx, Number(record.published_version_id)) : null; diff --git a/apps/web/lib/catalogue/first-read.ts b/apps/web/lib/catalogue/first-read.ts new file mode 100644 index 00000000..78cae698 --- /dev/null +++ b/apps/web/lib/catalogue/first-read.ts @@ -0,0 +1,156 @@ +import type { CatalogueContent } from "./content.ts"; +import { + type CatalogueReviewUnit, + catalogueReviewUnits, + evidenceBelongsToReviewUnit, + reviewUnitEvidence, +} from "./review-units.ts"; + +/** + * How much a first reading needs an administrator: + * + * - `needs_review`: the model was unsure, flagged an error, or wrote a rule + * Coursemap cannot check. Publishing waits until each one is approved or + * corrected. + * - `check`: probably right, but worth a look. Approve in bulk. + * - `accepted`: stated plainly on the page, or given no confidence at all. + * Folded away, and reopenable. + */ +export type FirstReadBand = "needs_review" | "check" | "accepted"; + +/** The reason on a reading an administrator put back up for review. */ +export const MARKED_FOR_REVIEW = "Marked for review by an administrator"; + +/** Below this the reading is a guess, and publishing waits on it. */ +export const NEEDS_REVIEW_BELOW = 0.7; +/** From here up, a reading with no flags is taken as read. */ +export const ACCEPTED_FROM = 0.9; + +/** + * Taken as read without a person: nothing flagged, and either read with full + * confidence or given no confidence to judge by. The draft already holds the + * value, so review leaves it out and lists it only among all fields. + */ +export function isCertainFirstRead(item: { + band: FirstReadBand | null; + confidence: number | null; + reason?: string | null; +}) { + return ( + item.band !== null && + item.reason !== MARKED_FOR_REVIEW && + item.band !== "needs_review" && + (item.confidence === 1 || item.confidence === null) + ); +} + +export type FirstReadItem = { + fieldPath: string; + unitKind: CatalogueReviewUnit["unitKind"]; + value: unknown; + /** The weakest confidence behind the value, or null with no evidence. */ + confidence: number | null; + band: FirstReadBand; + /** Why it landed in its band, in words an administrator acts on. */ + reason: string; +}; + +function isEmpty(value: unknown) { + if (value === null || value === undefined) return true; + if (typeof value === "string") return value.trim() === ""; + if (Array.isArray(value)) return value.length === 0; + return false; +} + +/** + * What the model's own confidence cannot show about a requirement rule: + * wording it could not place, conditions it left for review, and one + * sentence split into several conditions. + */ +function ruleConcerns(content: CatalogueContent, fieldPath: string) { + const [root, ruleKey] = fieldPath.split("."); + if (root !== "requirements" || !ruleKey) + return { concerns: [], lowest: null }; + const conditions = content.requirements.conditions.filter( + (condition) => condition.ruleKey === ruleKey, + ); + const concerns: string[] = []; + if (conditions.some((condition) => condition.kind === "other")) { + concerns.push("Part of the rule is free text Coursemap cannot check"); + } + if (conditions.some((condition) => condition.reviewState === "review")) { + concerns.push("The importer marked part of the rule for review"); + } + const sentences = conditions + .map((condition) => condition.sourceText?.trim()) + .filter((text): text is string => Boolean(text)); + if (new Set(sentences).size < sentences.length) { + concerns.push("One sentence was split into several conditions"); + } + const confidences = conditions.map((condition) => condition.confidence); + return { + concerns, + lowest: confidences.length ? Math.min(...confidences) : null, + }; +} + +/** + * Every filled part of a record read from ANU for the first time, rated for + * how much it needs a person. The model's confidence is how directly the page + * states a value, which it tends to overrate, so its flags and Coursemap's own + * checks on requirement rules can only move an item towards review. + */ +export function classifyFirstRead(content: CatalogueContent): FirstReadItem[] { + return catalogueReviewUnits(content).flatMap((unit) => { + if (isEmpty(unit.value)) return []; + const confidences = reviewUnitEvidence(content, unit.fieldPath) + .map((entry) => entry.confidence) + .filter((confidence): confidence is number => confidence !== null); + const flags = content.flags.filter((flag) => + evidenceBelongsToReviewUnit(unit.fieldPath, flag.fieldPath), + ); + const rule = ruleConcerns(content, unit.fieldPath); + const candidates = [ + ...confidences, + ...(rule.lowest === null ? [] : [rule.lowest]), + ]; + const confidence = candidates.length ? Math.min(...candidates) : null; + const error = flags.find((flag) => flag.severity === "error"); + const warning = flags.find((flag) => flag.severity === "warning"); + + let band: FirstReadBand; + let reason: string; + if (error) { + band = "needs_review"; + reason = error.message; + } else if (rule.concerns.length) { + band = "needs_review"; + reason = rule.concerns.join(". "); + } else if (confidence !== null && confidence < NEEDS_REVIEW_BELOW) { + band = "needs_review"; + reason = "The page does not state this plainly"; + } else if (warning) { + band = "check"; + reason = warning.message; + } else if (confidence === null) { + band = "accepted"; + reason = "No confidence was given, so it was taken as read"; + } else if (confidence < ACCEPTED_FROM) { + band = "check"; + reason = "Probably right, worth a look"; + } else { + band = "accepted"; + reason = "Stated plainly on the page"; + } + return [ + { + fieldPath: unit.fieldPath, + unitKind: unit.unitKind, + value: unit.value, + confidence, + band, + reason, + }, + ]; + }); +} diff --git a/apps/web/lib/catalogue/source-review-decisions.ts b/apps/web/lib/catalogue/source-review-decisions.ts index af39465e..29ef27c5 100644 --- a/apps/web/lib/catalogue/source-review-decisions.ts +++ b/apps/web/lib/catalogue/source-review-decisions.ts @@ -20,6 +20,7 @@ import { catalogueRecordForUpdate, createDraftInTransaction, } from "./drafts"; +import { MARKED_FOR_REVIEW } from "./first-read"; import { applyReviewUnits, evidenceBelongsToReviewUnit } from "./review-units"; import type { SourceReviewDecision } from "./source-review-store"; @@ -215,3 +216,40 @@ export async function resolveSourceChange({ }); return sql ? work(sql) : withSyncDatabaseClient(work); } + +/** + * Puts one field of the current ANU reading back in front of a person: a + * decided change is reopened, and a first reading taken as read is marked for + * review so it is listed again. + */ +export async function markFieldForReview({ + recordId, + fieldPath, +}: { + recordId: number; + fieldPath: string; +}) { + return withSyncDatabaseClient((client) => + client.begin(async (tx) => { + const rows = await tx` + update public.catalogue_sync_changes + set decision = null, resolved_by = null, resolved_at = null, + review_band = case + when classification = 'first_read' and review_band <> 'needs_review' + then 'check' else review_band end, + review_reason = case + when classification = 'first_read' and review_band <> 'needs_review' + then ${MARKED_FOR_REVIEW} else review_reason end + where record_id = ${recordId} and field_path = ${fieldPath} + and superseded_at is null + returning id + `; + if (rows.length === 0) + throw new CatalogueDraftError( + "This field has no ANU reading to review.", + "NOT_FOUND", + ); + return { label: fieldLabel(fieldPath) }; + }), + ); +} diff --git a/apps/web/lib/catalogue/source-review-store.ts b/apps/web/lib/catalogue/source-review-store.ts index ae101115..6a3c82a8 100644 --- a/apps/web/lib/catalogue/source-review-store.ts +++ b/apps/web/lib/catalogue/source-review-store.ts @@ -1,4 +1,7 @@ -import type { SyncTransactionSql } from "../catalogue-sync/sync-store.ts"; +import type { + SyncSql, + SyncTransactionSql, +} from "../catalogue-sync/sync-store.ts"; import { withSyncDatabaseClient } from "../catalogue-sync/sync-store.ts"; import { fieldLabel } from "../coursemap/catalogue-kinds.ts"; import type { CatalogueContent } from "./content.ts"; @@ -6,10 +9,12 @@ import { type CatalogueReviewUnitKind, catalogueReviewUnitMap, } from "./review-units.ts"; +import { type FirstReadBand, classifyFirstRead } from "./first-read.ts"; import { type SourceChangeClassification, classifySourceReview, reclassifyAgainstDraft, + reviewValueHash, } from "./source-review.ts"; export type SourceReviewDecision = "use_source" | "keep_local"; @@ -28,6 +33,11 @@ export type SourceReviewChange = { isStale: boolean; decision: SourceReviewDecision | null; resolvedAt: string | null; + /** The weakest confidence behind the ANU value, or null with no evidence. */ + confidence: number | null; + /** A first reading's band and reason; null otherwise. */ + band: FirstReadBand | null; + reason: string | null; }; export type SourceReview = { @@ -37,6 +47,8 @@ export type SourceReview = { conflicts: SourceReviewChange[]; incoming: SourceReviewChange[]; overrides: SourceReviewChange[]; + /** Open parts of a record's first reading from ANU, least certain first. */ + firstRead: SourceReviewChange[]; resolved: SourceReviewChange[]; }; @@ -73,9 +85,21 @@ function changeFromRow( row.resolved_at === null ? null : new Date(row.resolved_at as string | Date).toISOString(), + confidence: row.confidence === null ? null : Number(row.confidence), + band: + row.review_band === null + ? null + : (String(row.review_band) as FirstReadBand), + reason: row.review_reason === null ? null : String(row.review_reason), }; } +const BAND_ORDER: Record = { + needs_review: 0, + check: 1, + accepted: 2, +}; + /** * The record's one current review, reclassified against the draft as it * stands. Returns null when no sync has ever produced changes for it. @@ -110,6 +134,12 @@ export async function loadSourceReview( conflicts: open("conflict"), incoming: open("source_change"), overrides: open("local_override"), + firstRead: open("first_read").sort( + (left, right) => + BAND_ORDER[left.band ?? "accepted"] - + BAND_ORDER[right.band ?? "accepted"] || + (left.confidence ?? 0) - (right.confidence ?? 0), + ), resolved: changes.filter((change) => change.decision !== null), } satisfies SourceReview; }); @@ -145,6 +175,14 @@ export async function generateSourceReview( const changes = classified.filter( (change) => change.classification !== "converged", ); + // The same weakest-evidence confidence a first reading shows, so every + // change says how sure the model was of the value it read. + const confidence = new Map( + classifyFirstRead(incomingSource).map((item) => [ + item.fieldPath, + item.confidence, + ]), + ); await tx` update public.catalogue_sync_changes set superseded_at = now() where record_id = ${recordId} and superseded_at is null @@ -154,13 +192,14 @@ export async function generateSourceReview( insert into public.catalogue_sync_changes ( sync_id, record_id, field_path, review_unit_kind, classification, base_source_value, local_value, incoming_source_value, local_value_hash, - position + position, confidence ) values ( ${syncId}::uuid, ${recordId}, ${change.fieldPath}, ${change.unitKind}, ${change.classification}, ${tx.json(change.baseSourceValue as never)}, ${tx.json(change.localValue as never)}, ${tx.json(change.incomingSourceValue as never)}, - ${change.localValueHash}, ${change.position} + ${change.localValueHash}, ${change.position}, + ${confidence.get(change.fieldPath) ?? null} ) `; } @@ -176,3 +215,68 @@ export async function generateSourceReview( ).length, }; } + +/** + * Records a record's first reading from ANU for review. The draft already + * holds the reading, so every row starts equal to the draft; approving one + * keeps the value, and correcting it in the editor shows as an edit. A + * reading taken again from the same sync, after its draft was discarded, + * reopens that sync's rows rather than adding a second set. + */ +export async function generateFirstReadReview( + tx: SyncTransactionSql, + { + syncId, + recordId, + content, + }: { syncId: string; recordId: number; content: CatalogueContent }, +) { + const items = classifyFirstRead(content); + await tx` + update public.catalogue_sync_changes set superseded_at = now() + where record_id = ${recordId} and superseded_at is null + `; + for (const [position, item] of items.entries()) { + await tx` + insert into public.catalogue_sync_changes ( + sync_id, record_id, field_path, review_unit_kind, classification, + base_source_value, local_value, incoming_source_value, local_value_hash, + position, confidence, review_band, review_reason + ) values ( + ${syncId}::uuid, ${recordId}, ${item.fieldPath}, ${item.unitKind}, + 'first_read', null, ${tx.json(item.value as never)}, + ${tx.json(item.value as never)}, ${reviewValueHash(item.value)}, + ${position}, ${item.confidence}, ${item.band}, ${item.reason} + ) + on conflict (sync_id, field_path) do update set + review_unit_kind = excluded.review_unit_kind, + classification = excluded.classification, + base_source_value = excluded.base_source_value, + local_value = excluded.local_value, + incoming_source_value = excluded.incoming_source_value, + local_value_hash = excluded.local_value_hash, + position = excluded.position, confidence = excluded.confidence, + review_band = excluded.review_band, + review_reason = excluded.review_reason, + decision = null, resolved_by = null, resolved_at = null, + superseded_at = null + `; + } + return { + total: items.length, + needsReview: items.filter((item) => item.band === "needs_review").length, + }; +} + +/** How many parts of a first reading still wait on a person before publishing. */ +export async function countBlockingFirstReads( + sql: SyncSql | SyncTransactionSql, + recordId: number, +) { + const [row] = await sql` + select count(*)::int as count from public.catalogue_sync_changes + where record_id = ${recordId} and superseded_at is null + and decision is null and review_band = 'needs_review' + `; + return Number(row?.count ?? 0); +} diff --git a/apps/web/lib/catalogue/source-review.ts b/apps/web/lib/catalogue/source-review.ts index 7970b647..42b24c54 100644 --- a/apps/web/lib/catalogue/source-review.ts +++ b/apps/web/lib/catalogue/source-review.ts @@ -16,7 +16,7 @@ import type { CatalogueContent } from "./content.ts"; * something to weigh up. */ export type SourceChangeClassification = - "source_change" | "local_override" | "conflict" | "converged"; + "source_change" | "local_override" | "conflict" | "converged" | "first_read"; /** Classifications that ask the administrator for a decision. */ export const ACTIONABLE_SOURCE_CLASSIFICATIONS: readonly SourceChangeClassification[] = diff --git a/apps/web/tests/catalogue-source-review-database.test.mjs b/apps/web/tests/catalogue-source-review-database.test.mjs index 38f3d9c8..5d93d383 100644 --- a/apps/web/tests/catalogue-source-review-database.test.mjs +++ b/apps/web/tests/catalogue-source-review-database.test.mjs @@ -10,12 +10,14 @@ import { persistSourceVersion } from "../lib/catalogue-sync/persist-source-versi import { ensureAnuSourceId } from "../lib/catalogue-sync/sync-store.ts"; import { loadSourceReview } from "../lib/catalogue/source-review-store.ts"; import { resolveSourceChange } from "../lib/catalogue/source-review-decisions.ts"; +import { publishCatalogueDraft } from "../lib/catalogue/drafts.ts"; import { createLocalDatabaseClient } from "../scripts/catalogue/lib/local-database.mjs"; import { localTestEnvironment } from "../scripts/local/test-environment.mjs"; const YEAR = 2026; const CONFLICT_CODE = "TSTC9201"; const CHANGE_CODE = "TSTC9202"; +const FIRST_READ_CODE = "TSTC9203"; const ADMIN_ID = "99000000-0000-4000-8000-000000000041"; let sql; @@ -46,11 +48,11 @@ function sourceContent(code, title, description) { } async function removeFixtures() { - await sql`delete from public.catalogue_listings where code in (${CONFLICT_CODE}, ${CHANGE_CODE})`; + await sql`delete from public.catalogue_listings where code in (${CONFLICT_CODE}, ${CHANGE_CODE}, ${FIRST_READ_CODE})`; await sql`alter table public.catalogue_source_documents disable trigger catalogue_source_documents_reject_mutation`; await sql`alter table public.catalogue_versions disable trigger catalogue_versions_enforce_immutability`; try { - await sql`delete from public.catalogue_codes where kind = 'course' and code in (${CONFLICT_CODE}, ${CHANGE_CODE})`; + await sql`delete from public.catalogue_codes where kind = 'course' and code in (${CONFLICT_CODE}, ${CHANGE_CODE}, ${FIRST_READ_CODE})`; } finally { await sql`alter table public.catalogue_versions enable trigger catalogue_versions_enforce_immutability`; await sql`alter table public.catalogue_source_documents enable trigger catalogue_source_documents_reject_mutation`; @@ -180,6 +182,7 @@ beforeAll(async () => { sourceId = await ensureAnuSourceId(sql); await createRecord(CONFLICT_CODE, "Conflict Record"); await createRecord(CHANGE_CODE, "Change Record"); + await createRecord(FIRST_READ_CODE, "First Read Record"); }); afterAll(async () => { @@ -401,3 +404,70 @@ test("using ANU writes one path, keeps unrelated edits and moves only its proven assert.equal(reclassified.conflicts[0].isStale, true); assert.equal(reclassified.conflicts[0].localValue, "Rewritten locally."); }); + +test("a first reading is rated for review and holds publishing until approved", async () => { + const recordId = records.get(FIRST_READ_CODE); + const content = sourceContent( + FIRST_READ_CODE, + "First Read Record", + "Read from a page that barely says it.", + ); + content.evidence[0].confidence = 0.4; + content.contentHash = contentHashForCatalogueContent(content); + + const first = await observeSource(FIRST_READ_CODE, content); + assert.equal(first.populatedDraft, true); + + const review = await currentReview(FIRST_READ_CODE); + const description = review.firstRead.find( + (change) => change.fieldPath === "course.details.description", + ); + assert.equal(description.band, "needs_review"); + assert.equal(description.confidence, 0.4); + assert.equal(review.firstRead[0].id, description.id, "least certain first"); + assert.equal( + review.firstRead.find( + (change) => change.fieldPath === "course.details.title", + ).band, + "check", + "a value with no evidence is worth a look", + ); + + const [draft] = await sql` + select revision from public.catalogue_drafts where record_id = ${recordId} + `; + await assert.rejects( + publishCatalogueDraft({ + recordId, + expectedRevision: Number(draft.revision), + userId: ADMIN_ID, + editingSessionId: "11111111-1111-4111-8111-111111111111", + sql, + }), + (error) => error.code === "FIRST_READ_REVIEW", + ); + + await resolveSourceChange({ + recordId, + changeId: description.id, + decision: "use_source", + userId: ADMIN_ID, + sql, + }); + const [unchanged] = await sql` + select revision from public.catalogue_drafts where record_id = ${recordId} + `; + assert.equal( + Number(unchanged.revision), + Number(draft.revision), + "approving a first reading keeps the draft as it is", + ); + const versionId = await publishCatalogueDraft({ + recordId, + expectedRevision: Number(draft.revision), + userId: ADMIN_ID, + editingSessionId: "11111111-1111-4111-8111-111111111111", + sql, + }); + assert.ok(versionId); +}); diff --git a/apps/web/tests/first-read.test.ts b/apps/web/tests/first-read.test.ts new file mode 100644 index 00000000..22975805 --- /dev/null +++ b/apps/web/tests/first-read.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from "vitest"; +import { + type CatalogueContent, + emptyCatalogueContent, +} from "@/lib/catalogue/content"; +import { classifyFirstRead } from "@/lib/catalogue/first-read"; + +function course(): CatalogueContent { + const content = emptyCatalogueContent({ + kind: "course", + code: "COMP2710", + academicYear: 2026, + title: "Special Topics in Computer Science", + }); + content.course!.details.description = "Advanced topics."; + content.course!.details.convenerText = "Dr Example"; + content.evidence = [ + { + fieldPath: "title", + method: "model", + confidence: 0.98, + sourceLocator: null, + sourceExcerpt: null, + }, + { + fieldPath: "description", + method: "model", + confidence: 0.82, + sourceLocator: null, + sourceExcerpt: null, + }, + ]; + return content; +} + +function band(content: CatalogueContent, fieldPath: string) { + return classifyFirstRead(content).find( + (item) => item.fieldPath === fieldPath, + ); +} + +test("confidence sorts plain readings from ones worth a look", () => { + const content = course(); + expect(band(content, "course.details.title")).toMatchObject({ + band: "accepted", + confidence: 0.98, + }); + expect(band(content, "course.details.description")).toMatchObject({ + band: "check", + confidence: 0.82, + }); + // With no confidence to judge by, the reading is taken as read. + expect(band(content, "course.details.convenerText")).toMatchObject({ + band: "accepted", + confidence: null, + }); + // Empty parts of the reading have nothing to review. + expect(band(content, "course.details.workloadText")).toBeUndefined(); +}); + +test("a model error puts a part up for review whatever its confidence", () => { + const content = course(); + content.flags = [ + { + fieldPath: "title", + severity: "error", + code: "conflict", + message: "The page gives two titles.", + sourceExcerpt: null, + }, + ]; + expect(band(content, "course.details.title")).toMatchObject({ + band: "needs_review", + reason: "The page gives two titles.", + }); +}); + +test("a rule split from one sentence needs review, as COMP2710's permission did", () => { + const content = course(); + const sentence = + "You will need to contact the School of Computing to request a permission code."; + content.requirements.rules.push({ + key: "prerequisite", + hardness: "hard", + sourceText: sentence, + sourceLocator: null, + reviewState: "automatic", + confidence: 0.95, + position: 1, + }); + for (const [position, kind] of ( + ["permission", "permission"] as const + ).entries()) { + content.requirements.conditions.push({ + key: `condition-${position}`, + ruleKey: "prerequisite", + groupKey: "root", + position, + kind, + itemCode: null, + itemKind: null, + structureKind: null, + requirementMode: null, + minimumMark: null, + minimumUnits: null, + maximumUnits: null, + minimumCount: null, + subjectCode: null, + minimumLevel: null, + maximumLevel: null, + minimumYear: null, + minimumGpa: null, + minimumWam: null, + tag: null, + freeText: sentence, + hardness: "hard", + sourceText: sentence, + sourceLocator: null, + reviewState: "automatic", + confidence: 0.95, + }); + } + expect(band(content, "requirements.prerequisite")).toMatchObject({ + band: "needs_review", + reason: "One sentence was split into several conditions", + }); +}); + +test("a course's unit value evidence rates its unit kind and count", () => { + const content = course(); + content.evidence.push({ + fieldPath: "unitValue", + method: "model", + confidence: 0.95, + sourceLocator: null, + sourceExcerpt: null, + }); + expect(band(content, "course.details.unitValueKind")?.confidence).toBe(0.95); + expect(band(content, "course.details.units")?.confidence).toBe(0.95); +}); diff --git a/apps/web/types/database.ts b/apps/web/types/database.ts index 2bb8a6c0..30894154 100644 --- a/apps/web/types/database.ts +++ b/apps/web/types/database.ts @@ -1784,6 +1784,7 @@ export type Database = { Row: { base_source_value: Json | null classification: string + confidence: number | null created_at: string decision: string | null field_path: string @@ -1796,6 +1797,8 @@ export type Database = { resolution_note: string | null resolved_at: string | null resolved_by: string | null + review_band: string | null + review_reason: string | null review_unit_kind: string superseded_at: string | null sync_id: string @@ -1803,6 +1806,7 @@ export type Database = { Insert: { base_source_value?: Json | null classification: string + confidence?: number | null created_at?: string decision?: string | null field_path: string @@ -1815,6 +1819,8 @@ export type Database = { resolution_note?: string | null resolved_at?: string | null resolved_by?: string | null + review_band?: string | null + review_reason?: string | null review_unit_kind: string superseded_at?: string | null sync_id: string @@ -1822,6 +1828,7 @@ export type Database = { Update: { base_source_value?: Json | null classification?: string + confidence?: number | null created_at?: string decision?: string | null field_path?: string @@ -1834,6 +1841,8 @@ export type Database = { resolution_note?: string | null resolved_at?: string | null resolved_by?: string | null + review_band?: string | null + review_reason?: string | null review_unit_kind?: string superseded_at?: string | null sync_id?: string diff --git a/supabase/migrations/016_first_read_review.sql b/supabase/migrations/016_first_read_review.sql new file mode 100644 index 00000000..3c55dd15 --- /dev/null +++ b/supabase/migrations/016_first_read_review.sql @@ -0,0 +1,39 @@ +-- Review a record's first reading from ANU. +-- +-- A record's first sync fills its draft straight from the model's reading, +-- which used to leave nothing to review: the model's word was taken as read. +-- The sync now also records every filled part of that reading as a +-- `first_read` change, with the weakest confidence behind it, a band saying +-- how much it needs a person, and the reason. Publishing waits on every +-- `needs_review` change until an administrator approves or corrects it. + +alter table public.catalogue_sync_changes + drop constraint catalogue_sync_changes_classification_check; + +alter table public.catalogue_sync_changes + add constraint catalogue_sync_changes_classification_check check ((classification = any (array['source_change'::text, 'local_override'::text, 'conflict'::text, 'converged'::text, 'first_read'::text]))); + +alter table public.catalogue_sync_changes + add column confidence numeric(5,4), + add column review_band text, + add column review_reason text; + +alter table public.catalogue_sync_changes + add constraint catalogue_sync_changes_confidence_check check (((confidence is null) or ((confidence >= (0)::numeric) and (confidence <= (1)::numeric)))); + +alter table public.catalogue_sync_changes + add constraint catalogue_sync_changes_review_band_check check (((review_band is null) or (review_band = any (array['needs_review'::text, 'check'::text, 'accepted'::text])))); + +-- Every first reading is rated; later syncs compare against a previous +-- reading instead and carry no band. +alter table public.catalogue_sync_changes + add constraint catalogue_sync_changes_first_read_band_check check (((classification = 'first_read'::text) = (review_band is not null))); + +-- Publishing checks for open first readings that still need a person. +create index catalogue_sync_changes_open_review_idx + on public.catalogue_sync_changes using btree (record_id) + where ((superseded_at is null) and (decision is null) and (review_band = 'needs_review'::text)); + +comment on column public.catalogue_sync_changes.confidence is 'The weakest model confidence behind a first reading, or null when none was given.'; +comment on column public.catalogue_sync_changes.review_band is 'How much a first reading needs a person: needs_review blocks publishing, check is worth a look, accepted was stated plainly.'; +comment on column public.catalogue_sync_changes.review_reason is 'Why a first reading landed in its band.'; From f543fd167ba055d46b18713ec381c1288ac22099 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 08:14:09 +0000 Subject: [PATCH 2/6] feat: show ANU changes as a diff with the model's notes --- .../lib/catalogue/requirement-expression.ts | 197 +++++++++++ apps/web/lib/catalogue/review-diff.ts | 126 +++++++ apps/web/lib/catalogue/review-notes.ts | 24 ++ apps/web/lib/catalogue/review-units.ts | 18 + .../lib/coursemap/admin-catalogue-actions.ts | 70 +++- .../tests/catalogue-changes-panel.test.tsx | 120 +++++-- apps/web/tests/requirement-expression.test.ts | 93 ++++++ apps/web/tests/review-diff.test.ts | 23 ++ .../ui/admin/catalogue/changes/all-fields.tsx | 182 +++++++++++ .../admin/catalogue/changes/changes-panel.tsx | 144 ++++++-- .../catalogue/changes/first-read-review.tsx | 297 +++++++++++++++++ .../admin/catalogue/changes/model-notes.tsx | 128 +++----- .../admin/catalogue/changes/review-diff.tsx | 213 ++++++++++++ .../admin/catalogue/changes/review-value.tsx | 309 ++++++++++++++---- .../catalogue/changes/source-change-card.tsx | 112 +++++-- 15 files changed, 1815 insertions(+), 241 deletions(-) create mode 100644 apps/web/lib/catalogue/requirement-expression.ts create mode 100644 apps/web/lib/catalogue/review-diff.ts create mode 100644 apps/web/tests/requirement-expression.test.ts create mode 100644 apps/web/tests/review-diff.test.ts create mode 100644 apps/web/ui/admin/catalogue/changes/all-fields.tsx create mode 100644 apps/web/ui/admin/catalogue/changes/first-read-review.tsx create mode 100644 apps/web/ui/admin/catalogue/changes/review-diff.tsx diff --git a/apps/web/lib/catalogue/requirement-expression.ts b/apps/web/lib/catalogue/requirement-expression.ts new file mode 100644 index 00000000..4ad4c96e --- /dev/null +++ b/apps/web/lib/catalogue/requirement-expression.ts @@ -0,0 +1,197 @@ +import type { CourseRuleExpression } from "../coursemap/course-types.ts"; +import type { RequirementWrite } from "./content.ts"; + +/** One rule's share of a record's requirements, as review units carry it. */ +export type RequirementRuleSlice = { + rule?: RequirementWrite["rules"][number] | null; + groups?: RequirementWrite["groups"]; + conditions?: RequirementWrite["conditions"]; + options?: RequirementWrite["options"]; +}; + +type Condition = RequirementWrite["conditions"][number]; + +function conditionExpression( + condition: Condition, + optionCodes: string[], +): CourseRuleExpression { + const base = { + confidence: condition.confidence, + hardness: condition.hardness, + reviewState: condition.reviewState, + sourceText: condition.sourceText ?? "", + }; + const text = condition.freeText ?? condition.sourceText ?? ""; + const units = condition.minimumUnits; + switch (condition.kind) { + case "course": + if (condition.itemCode) { + return { + ...base, + kind: "course", + code: condition.itemCode.toUpperCase(), + minimumMark: condition.minimumMark, + requirementMode: condition.requirementMode ?? "completed", + }; + } + break; + case "incompatible": + if (condition.itemCode) { + return { + ...base, + kind: "incompatible", + code: condition.itemCode.toUpperCase(), + }; + } + break; + case "units_total": + case "subject_units": + if (units !== null) { + return { + ...base, + kind: condition.kind, + subject: condition.subjectCode, + units, + }; + } + break; + case "level_units": + if (units !== null && condition.minimumLevel !== null) { + return { + ...base, + kind: "level_units", + maximumLevel: condition.maximumLevel, + minimumLevel: condition.minimumLevel, + subject: condition.subjectCode, + units, + }; + } + break; + case "course_set_units": + if (units !== null && optionCodes.length) { + return { + ...base, + kind: "course_set_units", + courseCodes: optionCodes, + units, + }; + } + break; + case "tagged_units": + if (units !== null && condition.tag) { + return { ...base, kind: "tagged_units", tag: condition.tag, units }; + } + break; + case "elective_units": + if (units !== null) return { ...base, kind: "elective_units", units }; + break; + case "year_standing": + if (condition.minimumYear !== null) { + return { + ...base, + kind: "year_standing", + minimumYear: condition.minimumYear, + }; + } + break; + case "structure": + return { + ...base, + kind: "structure", + structureCode: condition.itemCode, + text: condition.freeText, + }; + case "structure_set": + return { + ...base, + kind: "structure_set", + minimumCount: condition.minimumCount, + structureCodes: optionCodes, + structureKind: condition.structureKind, + }; + case "gpa": + if (condition.minimumGpa !== null) { + return { ...base, kind: "gpa", minimumGpa: condition.minimumGpa }; + } + break; + case "wam": + if (condition.minimumWam !== null) { + return { ...base, kind: "wam", minimumWam: condition.minimumWam }; + } + break; + case "permission": + return { ...base, kind: "permission", text }; + } + return { ...base, kind: "other", text }; +} + +/** + * A rule's groups and conditions as the expression tree the course page draws, + * so a review shows a reading the way a student will see it. Returns null when + * the rule has no root group to start from. + */ +export function requirementSliceExpression( + slice: RequirementRuleSlice, +): CourseRuleExpression | null { + const groups = slice.groups ?? []; + const conditions = slice.conditions ?? []; + const optionCodes = new Map(); + for (const option of [...(slice.options ?? [])].sort( + (left, right) => left.position - right.position, + )) { + const list = optionCodes.get(option.conditionKey) ?? []; + list.push(option.code.toUpperCase()); + optionCodes.set(option.conditionKey, list); + } + + const visit = ( + key: string, + ancestors: ReadonlySet, + ): CourseRuleExpression | null => { + const group = groups.find((candidate) => candidate.key === key); + if (!group || ancestors.has(key)) return null; + const next = new Set(ancestors).add(key); + const children = [ + ...groups + .filter((candidate) => candidate.parentKey === key) + .map((child) => ({ + position: child.position, + expression: visit(child.key, next), + })), + ...conditions + .filter((candidate) => candidate.groupKey === key) + .map((condition) => ({ + position: condition.position, + expression: conditionExpression( + condition, + optionCodes.get(condition.key) ?? [], + ), + })), + ] + .sort((left, right) => left.position - right.position) + .flatMap(({ expression }) => (expression ? [expression] : [])); + if (children.length === 0) return null; + return { + kind: "group", + operator: group.operator, + minimumCount: group.minimumCount, + conditions: children, + }; + }; + + const roots = groups + .filter((group) => group.parentKey === null) + .sort((left, right) => left.position - right.position) + .flatMap((group) => { + const expression = visit(group.key, new Set()); + return expression ? [expression] : []; + }); + if (roots.length === 0) return null; + if (roots.length === 1) return roots[0]!; + return { + kind: "group", + operator: "all_of", + minimumCount: null, + conditions: roots, + }; +} diff --git a/apps/web/lib/catalogue/review-diff.ts b/apps/web/lib/catalogue/review-diff.ts new file mode 100644 index 00000000..a089656a --- /dev/null +++ b/apps/web/lib/catalogue/review-diff.ts @@ -0,0 +1,126 @@ +export type DiffPart = { text: string; changed: boolean }; + +export type DiffLine = { + kind: "same" | "removed" | "added"; + text: string; + /** For a removed or added line paired with its counterpart, what changed. */ + parts: DiffPart[] | null; +}; + +type Op = { kind: DiffLine["kind"]; value: T }; + +/** The shortest edit from one sequence to another, by longest common subsequence. */ +function editScript(before: readonly T[], after: readonly T[]): Op[] { + const rows = before.length; + const columns = after.length; + const lengths = Array.from({ length: rows + 1 }, () => + new Array(columns + 1).fill(0), + ); + for (let i = rows - 1; i >= 0; i -= 1) { + for (let j = columns - 1; j >= 0; j -= 1) { + lengths[i]![j] = + before[i] === after[j] + ? lengths[i + 1]![j + 1]! + 1 + : Math.max(lengths[i + 1]![j]!, lengths[i]![j + 1]!); + } + } + const ops: Op[] = []; + let i = 0; + let j = 0; + while (i < rows && j < columns) { + if (before[i] === after[j]) { + ops.push({ kind: "same", value: before[i]! }); + i += 1; + j += 1; + } else if (lengths[i + 1]![j]! >= lengths[i]![j + 1]!) { + ops.push({ kind: "removed", value: before[i]! }); + i += 1; + } else { + ops.push({ kind: "added", value: after[j]! }); + j += 1; + } + } + for (; i < rows; i += 1) ops.push({ kind: "removed", value: before[i]! }); + for (; j < columns; j += 1) ops.push({ kind: "added", value: after[j]! }); + return ops; +} + +/** Words and the space between them, so a rejoined line reads as it was. */ +function tokens(text: string) { + return text.match(/\s+|[^\s]+/g) ?? []; +} + +function parts(ops: Op[], side: "removed" | "added"): DiffPart[] { + const merged: DiffPart[] = []; + for (const op of ops) { + if (op.kind !== "same" && op.kind !== side) continue; + const changed = op.kind === side; + const last = merged.at(-1); + if (last && last.changed === changed) last.text += op.value; + else merged.push({ text: op.value, changed }); + } + // A lone space kept between two changes reads as one change, not two. + return merged.reduce((joined, part, at) => { + const last = joined.at(-1); + const next = merged[at + 1]; + const bridge = + !part.changed && + part.text.trim() === "" && + last?.changed && + next?.changed; + if (last && (bridge || (last.changed && part.changed))) { + last.text += part.text; + last.changed = true; + } else { + joined.push({ ...part }); + } + return joined; + }, []); +} + +/** + * A line diff in the manner of git: unchanged lines as context, removals + * before additions, and where a removed line is replaced one for one, the + * words that changed within it. + */ +export function diffLines( + before: readonly string[], + after: readonly string[], +): DiffLine[] { + const ops = editScript(before, after); + const lines: DiffLine[] = []; + let index = 0; + while (index < ops.length) { + if (ops[index]!.kind === "same") { + lines.push({ kind: "same", text: ops[index]!.value, parts: null }); + index += 1; + continue; + } + const removed: string[] = []; + const added: string[] = []; + while (index < ops.length && ops[index]!.kind !== "same") { + const op = ops[index]!; + (op.kind === "removed" ? removed : added).push(op.value); + index += 1; + } + const paired = removed.length === added.length; + const words = paired + ? removed.map((line, at) => editScript(tokens(line), tokens(added[at]!))) + : []; + removed.forEach((text, at) => + lines.push({ + kind: "removed", + text, + parts: paired ? parts(words[at]!, "removed") : null, + }), + ); + added.forEach((text, at) => + lines.push({ + kind: "added", + text, + parts: paired ? parts(words[at]!, "added") : null, + }), + ); + } + return lines; +} diff --git a/apps/web/lib/catalogue/review-notes.ts b/apps/web/lib/catalogue/review-notes.ts index 85ba991e..745e7173 100644 --- a/apps/web/lib/catalogue/review-notes.ts +++ b/apps/web/lib/catalogue/review-notes.ts @@ -1,3 +1,5 @@ +import { evidenceBelongsToReviewUnit } from "./review-units.ts"; + /** One note the model left on a source version. */ export type VersionFlag = { fieldPath: string | null; @@ -142,3 +144,25 @@ export function summariseReviewNotes({ ), }; } + +// The course model names requisites by their wording or rule, where review +// units name the requirement rule itself. +const REQUISITE_RULES: Record = { + prerequisite: "prerequisite", + corequisite: "corequisite", + incompatibility: "incompatibility", + softIncompatibility: "incompatibility", +}; + +/** Whether a note from the model is about the given review unit. */ +export function noteBelongsToReviewUnit( + fieldPath: string, + notePath: string | null, +) { + if (evidenceBelongsToReviewUnit(fieldPath, notePath)) return true; + const requisite = notePath?.match( + /^(?:requisites\.)?([a-zA-Z]+?)(?:Rule|Text|CourseCodes)$/, + )?.[1]; + const rule = requisite ? REQUISITE_RULES[requisite] : undefined; + return rule !== undefined && fieldPath === `requirements.${rule}`; +} diff --git a/apps/web/lib/catalogue/review-units.ts b/apps/web/lib/catalogue/review-units.ts index b8423e0a..532bc180 100644 --- a/apps/web/lib/catalogue/review-units.ts +++ b/apps/web/lib/catalogue/review-units.ts @@ -147,11 +147,29 @@ export function catalogueReviewUnitMap(content: CatalogueContent | null) { * that match nothing stay with the content they were already on, because an * accepted field must never relabel a field nobody decided on. */ +/** + * Model fields that land in review units under other names: a course's unit + * value becomes its unit kind, counts and options, and its offerings become + * the offering and its sessions. + */ +const MODEL_FIELD_UNITS: Record = { + unitValue: [ + "course.details.unitValueKind", + "course.details.units", + "course.details.minimumUnits", + "course.details.maximumUnits", + "course.unitOptions", + ], + offerings: ["course.offering", "course.sessions"], +}; + export function evidenceBelongsToReviewUnit( fieldPath: string, evidencePath: string | null, ) { if (!evidencePath) return false; + const root = evidencePath.split(/[.[]/)[0] ?? evidencePath; + if (MODEL_FIELD_UNITS[root]?.includes(fieldPath)) return true; const leaf = fieldPath.split(".").pop() ?? fieldPath; return ( evidencePath === fieldPath || diff --git a/apps/web/lib/coursemap/admin-catalogue-actions.ts b/apps/web/lib/coursemap/admin-catalogue-actions.ts index fbacdd53..c74315fb 100644 --- a/apps/web/lib/coursemap/admin-catalogue-actions.ts +++ b/apps/web/lib/coursemap/admin-catalogue-actions.ts @@ -13,7 +13,10 @@ import { saveCatalogueDraft, unpublishCatalogueRecord, } from "@/lib/catalogue/drafts"; -import { resolveSourceChange } from "@/lib/catalogue/source-review-decisions"; +import { + markFieldForReview, + resolveSourceChange, +} from "@/lib/catalogue/source-review-decisions"; import type { CatalogueKind } from "@/lib/coursemap/catalogue-kinds"; import { revalidatePublishedRecord } from "@/lib/coursemap/published-cache"; import type { SourceReviewDecision } from "@/lib/catalogue/source-review-store"; @@ -304,3 +307,68 @@ export async function resolveSourceChangeAction({ return draftFailure(error, "The ANU change could not be resolved."); } } + +/** + * Approves parts of a record's first ANU reading as read. Each keeps the value + * the draft already holds, so approving changes no content; it only clears + * the item, and with it any hold on publishing. + */ +export async function approveFirstReadAction({ + recordId, + changeIds, + path, +}: { + recordId: number; + changeIds: number[]; + path: string; +}): Promise { + if (!(await canWriteCatalogue())) + return { ok: false, error: "Catalogue write permission is required." }; + const viewer = await getAuthViewer(); + if (!viewer) return { ok: false, error: "Authentication is required." }; + let revision: number | undefined; + try { + for (const changeId of changeIds) { + const resolved = await resolveSourceChange({ + recordId, + changeId, + decision: "use_source", + userId: viewer.id, + }); + revision = resolved.revision; + } + } catch (error) { + revalidateRecord(path); + return draftFailure(error, "The ANU reading could not be approved."); + } + revalidateRecord(path); + return { + ok: true, + revision, + message: + changeIds.length === 1 + ? "Approved." + : `${changeIds.length} parts approved.`, + }; +} + +/** Puts one field back on the Changes tab for a person to look at. */ +export async function markFieldForReviewAction({ + recordId, + fieldPath, + path, +}: { + recordId: number; + fieldPath: string; + path: string; +}): Promise { + if (!(await canWriteCatalogue())) + return { ok: false, error: "Catalogue write permission is required." }; + try { + const marked = await markFieldForReview({ recordId, fieldPath }); + revalidateRecord(path); + return { ok: true, message: `${marked.label} is back up for review.` }; + } catch (error) { + return draftFailure(error, "The field could not be marked for review."); + } +} diff --git a/apps/web/tests/catalogue-changes-panel.test.tsx b/apps/web/tests/catalogue-changes-panel.test.tsx index 2eb49132..70a008c6 100644 --- a/apps/web/tests/catalogue-changes-panel.test.tsx +++ b/apps/web/tests/catalogue-changes-panel.test.tsx @@ -12,6 +12,7 @@ const actions = vi.hoisted(() => ({ resolve: vi.fn() })); vi.mock("@/lib/coursemap/admin-catalogue-actions", () => ({ resolveSourceChangeAction: actions.resolve, + approveFirstReadAction: vi.fn(), })); vi.mock("next/navigation", () => ({ @@ -34,6 +35,9 @@ function change(overrides: Partial = {}) { isStale: false, decision: null, resolvedAt: null, + confidence: null, + band: null, + reason: null, ...overrides, } satisfies SourceReviewChange; } @@ -46,6 +50,7 @@ function review(overrides: Partial = {}): SourceReview { conflicts: [], incoming: [], overrides: [], + firstRead: [], resolved: [], ...overrides, }; @@ -104,7 +109,7 @@ test("a matching record offers nothing to review", () => { ).toBeTruthy(); }); -test("a conflict shows all three values and both decisions", () => { +test("a conflict diffs the draft against ANU and offers both decisions", () => { renderPanel({ review: review({ conflicts: [ @@ -116,10 +121,15 @@ test("a conflict shows all three values and both decisions", () => { }), }); expect(screen.getByText("Conflicts")).toBeTruthy(); - expect(screen.getByText("Previous ANU")).toBeTruthy(); expect(screen.getByText("Current")).toBeTruthy(); - expect(screen.getByText("New ANU")).toBeTruthy(); - expect(screen.getByText("Manually changed")).toBeTruthy(); + expect(screen.getAllByText("New ANU").length).toBeGreaterThan(0); + expect(document.body.textContent).toContain("Locally authored"); + expect( + screen.getByText("What ANU changed since the last check"), + ).toBeTruthy(); + expect( + screen.getByText("Changed by hand since the last check."), + ).toBeTruthy(); expect(screen.getByRole("button", { name: "Keep current" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Use ANU" })).toBeTruthy(); }); @@ -159,52 +169,102 @@ test("kept values stay available without nagging", () => { expect(screen.queryByRole("button", { name: "Keep current" })).toBeNull(); }); -test("what the model flagged leads the tab, least certain field first", () => { +test("the model's notes sit on the change they are about", () => { renderPanel({ - review: review({ incoming: [change()] }), + review: review({ + incoming: [change({ fieldPath: "course.fees", label: "Fees" })], + }), notes: summariseReviewNotes({ flags: [ + { + fieldPath: "fees", + severity: "warning", + code: "EVIDENCE_MISSING", + message: "The ANU page does not contain this wording: $5520", + }, { fieldPath: "requisites.prerequisiteRule", severity: "error", code: "INVALID", message: "The rule named a course code ANU does not use.", }, - { - fieldPath: "requirements.rule.children.3", - severity: "warning", - code: "AMBIGUOUS", - message: "Kept as the page's wording.", - }, - ], - evidence: [ - { fieldPath: "fees", confidence: 0.9, excerpt: "$5520" }, - { fieldPath: "offerings", confidence: 0.55, excerpt: "First Semester" }, - { fieldPath: "college", confidence: 0.7, excerpt: "ANU College" }, ], + evidence: [{ fieldPath: "offerings", confidence: 0.55, excerpt: null }], }), }); expect( - screen.getByRole("heading", { name: "What to check" }), + screen.queryByRole("heading", { name: "What to check" }), + ).not.toBeInTheDocument(); + expect( + screen.getByText("The ANU page does not contain this wording: $5520"), ).toBeInTheDocument(); + // No open change carries the prerequisite rule, so it is listed on its own. expect( screen.getByText("1 part could not be read and was left empty"), ).toBeInTheDocument(); - expect(screen.getByText("Prerequisite rule:")).toBeInTheDocument(); - expect(screen.getByText("Requirements, branch 4:")).toBeInTheDocument(); - const uncertain = screen - .getAllByText(/% sure$/u) - .map((node) => node.textContent); - // Fees are sure enough not to be listed. - expect(uncertain).toEqual(["55% sure", "70% sure"]); + expect(screen.queryByText("55% sure")).not.toBeInTheDocument(); }); -test("nothing flagged means no notes section", () => { +test("a first reading leads with what needs review and folds what was read plainly", () => { renderPanel({ - review: review({ incoming: [change()] }), - notes: summariseReviewNotes({ flags: [], evidence: [] }), + review: review({ + firstRead: [ + change({ + id: 21, + classification: "first_read", + label: "Prerequisite rule", + fieldPath: "requirements.prerequisite", + unitKind: "requirement_rule", + confidence: 0.42, + band: "needs_review", + reason: "One sentence was split into several conditions", + }), + change({ + id: 22, + classification: "first_read", + confidence: 0.8, + band: "check", + reason: "Probably right, worth a look", + }), + change({ + id: 23, + classification: "first_read", + label: "Title", + confidence: 0.97, + band: "accepted", + reason: "Stated plainly on the page", + }), + ], + }), }); + expect(screen.getByText("First reading from ANU")).toBeTruthy(); expect( - screen.queryByRole("heading", { name: "What to check" }), - ).not.toBeInTheDocument(); + screen.getByText(/1 part needs review before this can be published/u), + ).toBeTruthy(); + expect(screen.getByText("42% sure")).toBeTruthy(); + expect( + screen.getByText("One sentence was split into several conditions"), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Approve all 1" })).toBeTruthy(); + expect(screen.getByText("Stated plainly")).toBeTruthy(); + expect(screen.queryByText("No changes to review")).toBeNull(); +}); + +test("leaves out first readings taken word for word from the page", () => { + renderPanel({ + review: review({ + firstRead: [ + change({ + id: 31, + classification: "first_read", + label: "Title", + confidence: 1, + band: "accepted", + reason: "Stated plainly on the page", + }), + ], + }), + }); + expect(screen.queryByText("First reading from ANU")).toBeNull(); + expect(screen.getByText("No changes to review")).toBeTruthy(); }); diff --git a/apps/web/tests/requirement-expression.test.ts b/apps/web/tests/requirement-expression.test.ts new file mode 100644 index 00000000..cfcdcac6 --- /dev/null +++ b/apps/web/tests/requirement-expression.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "vitest"; +import { requirementSliceExpression } from "@/lib/catalogue/requirement-expression"; +import type { RequirementWrite } from "@/lib/catalogue/content"; + +type Group = RequirementWrite["groups"][number]; +type Condition = RequirementWrite["conditions"][number]; + +function group(overrides: Partial): Group { + return { + key: "root", + ruleKey: "prerequisite", + parentKey: null, + label: null, + description: null, + operator: "all_of", + minimumCount: null, + minimumUnits: null, + maximumUnits: null, + sourceText: null, + sourceLocator: null, + position: 0, + ...overrides, + }; +} + +function course(key: string, groupKey: string, position: number, code: string) { + return { + key, + ruleKey: "prerequisite", + groupKey, + position, + kind: "course", + itemCode: code, + itemKind: "course", + structureKind: null, + requirementMode: "completed", + minimumMark: null, + minimumUnits: null, + maximumUnits: null, + minimumCount: null, + subjectCode: null, + minimumLevel: null, + maximumLevel: null, + minimumYear: null, + minimumGpa: null, + minimumWam: null, + tag: null, + freeText: null, + hardness: "hard", + sourceText: "FINM2001; and, FINM2003 or FINM3011.", + sourceLocator: null, + reviewState: "automatic", + confidence: 1, + } satisfies Condition; +} + +test("builds a rule's groups and conditions into the course page's tree", () => { + const expression = requirementSliceExpression({ + groups: [ + group({ key: "root" }), + group({ + key: "either", + parentKey: "root", + operator: "any_of", + position: 1, + }), + ], + conditions: [ + course("a", "root", 0, "finm2001"), + course("b", "either", 0, "FINM2003"), + course("c", "either", 1, "FINM3011"), + ], + }); + expect(expression).toMatchObject({ + kind: "group", + operator: "all_of", + conditions: [ + { kind: "course", code: "FINM2001" }, + { + kind: "group", + operator: "any_of", + conditions: [ + { kind: "course", code: "FINM2003" }, + { kind: "course", code: "FINM3011" }, + ], + }, + ], + }); +}); + +test("has no tree for a rule without groups", () => { + expect(requirementSliceExpression({ conditions: [] })).toBeNull(); +}); diff --git a/apps/web/tests/review-diff.test.ts b/apps/web/tests/review-diff.test.ts new file mode 100644 index 00000000..d201b050 --- /dev/null +++ b/apps/web/tests/review-diff.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "vitest"; +import { diffLines } from "@/lib/catalogue/review-diff"; + +test("keeps shared lines as context and lists removals before additions", () => { + expect( + diffLines(["a", "b", "c"], ["a", "x", "c", "d"]).map( + (line) => `${line.kind}:${line.text}`, + ), + ).toEqual(["same:a", "removed:b", "added:x", "same:c", "added:d"]); +}); + +test("marks the words that changed within a replaced line", () => { + const [removed, added] = diffLines( + ["course · 5520 · domestic"], + ["course · 5720 · domestic"], + ); + expect(removed!.parts).toEqual([ + { text: "course · ", changed: false }, + { text: "5520", changed: true }, + { text: " · domestic", changed: false }, + ]); + expect(added!.parts?.find((part) => part.changed)?.text).toBe("5720"); +}); diff --git a/apps/web/ui/admin/catalogue/changes/all-fields.tsx b/apps/web/ui/admin/catalogue/changes/all-fields.tsx new file mode 100644 index 00000000..b57d73da --- /dev/null +++ b/apps/web/ui/admin/catalogue/changes/all-fields.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { Button } from "@coursemap/ui/primitives/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@coursemap/ui/primitives/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@coursemap/ui/primitives/table"; +import type { FirstReadItem } from "@/lib/catalogue/first-read"; +import { fieldLabel } from "@/lib/coursemap/catalogue-kinds"; +import { + approveFirstReadAction, + markFieldForReviewAction, +} from "@/lib/coursemap/admin-catalogue-actions"; +import { Pencil } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; +import { plainText } from "./review-value"; +import { showToast } from "@/ui/common/toast"; + +function summary(item: FirstReadItem) { + if (item.unitKind === "requirement_rule") { + const rule = (item.value as { rule?: { sourceText?: string } } | null) + ?.rule; + return rule?.sourceText?.trim() ?? ""; + } + if (Array.isArray(item.value)) { + return `${item.value.length} item${item.value.length === 1 ? "" : "s"}`; + } + const text = plainText(item.value); + return text.length > 140 ? `${text.slice(0, 137)}…` : text; +} + +/** An open change on a field, and whether approving it settles the field. */ +export type OpenField = { changeId: number; approvable: boolean }; + +function StatusSelect({ + item, + open, + recordId, + path, + canWrite, +}: { + item: FirstReadItem; + open: OpenField | undefined; + recordId: number; + path: string; + canWrite: boolean; +}) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const status = open ? "review" : "settled"; + const label = fieldLabel(item.fieldPath); + const change = (next: string) => { + if (next === status) return; + startTransition(async () => { + const result = + next === "review" + ? await markFieldForReviewAction({ + recordId, + fieldPath: item.fieldPath, + path, + }) + : await approveFirstReadAction({ + recordId, + changeIds: [open!.changeId], + path, + }); + if (!result.ok) { + showToast(result.error, "error"); + return; + } + if (result.message) showToast(result.message); + router.refresh(); + }); + }; + // Settling a conflict or an ANU change needs its choice on the card, so + // only a first reading can be settled from here. + const locked = + !canWrite || isPending || (open !== undefined && !open.approvable); + return ( + + ); +} + +/** + * Every filled field of the record as ANU was read, with how sure the reading + * was, including those taken as read and never put up for review. A field can + * be sent back for review or settled here, and edited on the Content tab. + */ +export function AllFields({ + items, + open, + recordId, + path, + canWrite, +}: { + items: readonly FirstReadItem[]; + /** Fields with a change still waiting on a decision, by field path. */ + open: Readonly>; + recordId: number; + path: string; + canWrite: boolean; +}) { + return ( +
+ + + + Field + Value + Confidence + Status + {canWrite ? ( + + Edit + + ) : null} + + + + {items.map((item) => ( + + + {fieldLabel(item.fieldPath)} + + + {summary(item)} + + + {item.confidence === null + ? "None given" + : `${Math.round(item.confidence * 100)}%`} + + + + + {canWrite ? ( + + {/* Fields are edited where they live, in the Content tab. */} + + + ) : null} + + ))} + +
+
+ ); +} diff --git a/apps/web/ui/admin/catalogue/changes/changes-panel.tsx b/apps/web/ui/admin/catalogue/changes/changes-panel.tsx index 545b5e02..0526bb45 100644 --- a/apps/web/ui/admin/catalogue/changes/changes-panel.tsx +++ b/apps/web/ui/admin/catalogue/changes/changes-panel.tsx @@ -1,11 +1,25 @@ -import Link from "next/link"; - +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@coursemap/ui/primitives/tabs"; import { cn } from "@/lib/cn"; import type { SnapshotChange } from "@/lib/catalogue-import/changes"; -import type { summariseReviewNotes } from "@/lib/catalogue/review-notes"; +import { + type FirstReadItem, + isCertainFirstRead, +} from "@/lib/catalogue/first-read"; +import { + noteBelongsToReviewUnit, + type summariseReviewNotes, +} from "@/lib/catalogue/review-notes"; import type { SourceReview } from "@/lib/catalogue/source-review-store"; import { CatalogueEmpty } from "@/ui/admin/catalogue-table/catalogue-empty"; -import { ModelNotes } from "./model-notes"; +import { AllFields } from "./all-fields"; +import { FirstReadReview } from "./first-read-review"; +import { UnreadParts } from "./model-notes"; +import type { ReviewSubject } from "./review-value"; import { SourceChangeCard } from "./source-change-card"; import { UnpublishedChanges } from "./unpublished-changes"; @@ -74,8 +88,9 @@ export function CatalogueChangesPanel({ hasEverSynced, isPublished, kindLabel, - latestSync = null, notes = null, + subject = null, + allFields = [], }: { review: SourceReview | null; unpublished: SnapshotChange[]; @@ -85,14 +100,33 @@ export function CatalogueChangesPanel({ hasEverSynced: boolean; isPublished: boolean; kindLabel: string; - /** The check these changes came out of, for readers allowed to open it. */ - latestSync?: { id: string; completedAt: string | null } | null; /** What the model flagged on the latest ANU version. */ notes?: ReturnType | null; + /** The course or structure itself, for drawing requirement rules. */ + subject?: ReviewSubject | null; + /** Every filled field as the draft holds it, rated like a first reading. */ + allFields?: readonly FirstReadItem[]; }) { const conflicts = review?.conflicts ?? []; + const firstRead = (review?.firstRead ?? []).filter( + (change) => !isCertainFirstRead(change), + ); const incoming = review?.incoming ?? []; const overrides = review?.overrides ?? []; + // The model's notes ride on the change they are about and clear with it; + // uncertainty already shows as each card's confidence. + const flagged = notes ? [...notes.errors, ...notes.warnings] : []; + const open = [...firstRead, ...conflicts, ...incoming, ...overrides]; + const notesFor = (fieldPath: string) => + flagged.filter((note) => + noteBelongsToReviewUnit(fieldPath, note.fieldPath), + ); + const unreadParts = (notes?.errors ?? []).filter( + (note) => + !open.some((change) => + noteBelongsToReviewUnit(change.fieldPath, note.fieldPath), + ), + ); const unpublishedCount = isPublished ? unpublished.length : 0; const empty = reviewEmptyState({ hasEverSynced, @@ -100,33 +134,29 @@ export function CatalogueChangesPanel({ kindLabel, }); const showUnpublished = unpublishedCount > 0; - const isEmpty = conflicts.length === 0 && incoming.length === 0; + const isEmpty = + conflicts.length === 0 && incoming.length === 0 && firstRead.length === 0; // The empty state reaches the page floor only when nothing follows it. const fillsPage = isEmpty && overrides.length === 0 && !showUnpublished; - return ( + const toReview = (
- {/* - Everything on this tab is the output of a sync, so the sync that - produced it is named here rather than left to be found in Activity. - */} - {latestSync ? ( -

- {latestSync.completedAt - ? `Last checked against ANU on ${new Intl.DateTimeFormat("en-AU", { - dateStyle: "long", - timeStyle: "short", - }).format(new Date(latestSync.completedAt))}. ` - : "A check against ANU is under way. "} - - Sync diagnostics - -

+ + {firstRead.length ? ( + [ + change.fieldPath, + notesFor(change.fieldPath), + ]), + )} + path={path} + recordId={recordId} + subject={subject} + /> ) : null} - {notes ? : null} {isEmpty ? ( ) : null} @@ -138,8 +168,10 @@ export function CatalogueChangesPanel({ canWrite={canWrite} change={change} key={change.id} + notes={notesFor(change.fieldPath)} path={path} recordId={recordId} + subject={subject} /> ))}
@@ -153,8 +185,10 @@ export function CatalogueChangesPanel({ canWrite={canWrite} change={change} key={change.id} + notes={notesFor(change.fieldPath)} path={path} recordId={recordId} + subject={subject} /> ))} @@ -174,8 +208,10 @@ export function CatalogueChangesPanel({ canWrite={canWrite} change={change} key={change.id} + notes={notesFor(change.fieldPath)} path={path} recordId={recordId} + subject={subject} /> ))} @@ -188,4 +224,54 @@ export function CatalogueChangesPanel({ ) : null} ); + if (allFields.length === 0) return toReview; + // Every open row, including first readings taken as read, which are open + // but not listed for review. + const openFields = Object.fromEntries( + [ + ...(review?.firstRead ?? []).filter( + (change) => !isCertainFirstRead(change), + ), + ...conflicts, + ...incoming, + ].map((change) => [ + change.fieldPath, + { + changeId: change.id, + approvable: change.classification === "first_read" && !change.isStale, + }, + ]), + ); + return ( + + + + To review + + {conflicts.length + + incoming.length + + firstRead.filter((change) => change.band !== "accepted").length} + + + + All fields + + {allFields.length} + + + + + {toReview} + + + + + + ); } diff --git a/apps/web/ui/admin/catalogue/changes/first-read-review.tsx b/apps/web/ui/admin/catalogue/changes/first-read-review.tsx new file mode 100644 index 00000000..24ab805c --- /dev/null +++ b/apps/web/ui/admin/catalogue/changes/first-read-review.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { Badge } from "@coursemap/ui/components/badge"; +import { Button } from "@coursemap/ui/primitives/button"; +import { ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; +import type { ReviewNote } from "@/lib/catalogue/review-notes"; +import type { SourceReviewChange } from "@/lib/catalogue/source-review-store"; +import { + approveFirstReadAction, + resolveSourceChangeAction, +} from "@/lib/coursemap/admin-catalogue-actions"; +import { CardNotes } from "./model-notes"; +import { ReviewDiff } from "./review-diff"; +import { type ReviewSubject, ReviewValue } from "./review-value"; +import { showToast } from "@/ui/common/toast"; + +const BAND_BADGE = { + needs_review: { label: "Needs review", variant: "destructive-light" }, + check: { label: "Check", variant: "warning-light" }, + accepted: { label: "Accepted", variant: "success-light" }, +} as const; + +export function confidenceLabel(confidence: number | null) { + return confidence === null + ? "No % given" + : `${Math.round(confidence * 100)}% sure`; +} + +function useResolve(recordId: number, path: string) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const run = (work: () => ReturnType) => + startTransition(async () => { + const result = await work(); + if (!result.ok) { + showToast(result.error, "error"); + return; + } + if (result.message) showToast(result.message); + router.refresh(); + }); + return { + isPending, + approve: (changeIds: number[]) => + run(() => approveFirstReadAction({ recordId, changeIds, path })), + keepEdit: (changeId: number) => + run(() => + resolveSourceChangeAction({ + recordId, + changeId, + decision: "keep_local", + path, + }), + ), + }; +} + +function FirstReadCard({ + change, + recordId, + path, + canWrite, + subject, + notes, +}: { + change: SourceReviewChange; + recordId: number; + path: string; + canWrite: boolean; + subject: ReviewSubject | null; + notes: readonly ReviewNote[]; +}) { + const { isPending, approve, keepEdit } = useResolve(recordId, path); + const band = BAND_BADGE[change.band ?? "check"]; + return ( +
+
+

{change.label}

+
+ + {confidenceLabel(change.confidence)} + + {band.label} +
+
+ {change.reason ? ( +

{change.reason}

+ ) : null} + {/* The reason may already be one of the notes. */} + note.message !== change.reason)} + /> +
+ {change.isStale ? ( + <> +

+ You corrected this after the reading. +

+ + + ) : ( + + )} +
+ {canWrite ? ( +
+ {change.isStale ? ( + <> + + + + ) : ( + <> + + {/* Correcting happens in the editor, where the field lives. */} + + + )} +
+ ) : null} +
+ ); +} + +function BulkApprove({ + changes, + recordId, + path, + label, +}: { + changes: SourceReviewChange[]; + recordId: number; + path: string; + label: string; +}) { + const { isPending, approve } = useResolve(recordId, path); + const approvable = changes.filter((change) => !change.isStale); + if (approvable.length === 0) return null; + return ( + + ); +} + +/** + * A record's first reading from ANU, rated part by part. What the model was + * unsure of leads and holds publishing; what it read plainly is folded away, + * one click from being approved or reopened. + */ +export function FirstReadReview({ + changes, + recordId, + path, + canWrite, + subject = null, + notes = {}, +}: { + changes: SourceReviewChange[]; + recordId: number; + path: string; + canWrite: boolean; + subject?: ReviewSubject | null; + /** The model's notes on each change, by field path. */ + notes?: Readonly>; +}) { + const needsReview = changes.filter( + (change) => change.band === "needs_review", + ); + const check = changes.filter((change) => change.band === "check"); + const accepted = changes.filter((change) => change.band === "accepted"); + const card = (change: SourceReviewChange) => ( + + ); + + return ( +
+
+

+ First reading from ANU +

+

+ {needsReview.length + ? `${needsReview.length} ${needsReview.length === 1 ? "part needs" : "parts need"} review before this can be published.` + : "Nothing is holding publication."}{" "} + {check.length ? `${check.length} worth a look.` : ""} +

+
+ + {needsReview.length ? ( +
{needsReview.map(card)}
+ ) : null} + + {check.length ? ( +
+
+

Worth a look

+ {canWrite ? ( + + ) : null} +
+ {check.map(card)} +
+ ) : null} + + {accepted.length ? ( +
+ +

+

+
+
+ {canWrite ? ( +
+ +
+ ) : null} + {accepted.map(card)} +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/ui/admin/catalogue/changes/model-notes.tsx b/apps/web/ui/admin/catalogue/changes/model-notes.tsx index 17cc30ac..d6cdc2f3 100644 --- a/apps/web/ui/admin/catalogue/changes/model-notes.tsx +++ b/apps/web/ui/admin/catalogue/changes/model-notes.tsx @@ -3,105 +3,57 @@ import { AlertDescription, AlertTitle, } from "@coursemap/ui/components/alert"; -import { Badge } from "@coursemap/ui/components/badge"; -import { CircleAlert, Gauge, TriangleAlert } from "lucide-react"; -import type { ReviewNote, UncertainField } from "@/lib/catalogue/review-notes"; +import { CircleAlert, TriangleAlert } from "lucide-react"; +import type { ReviewNote } from "@/lib/catalogue/review-notes"; -function NoteList({ notes }: { notes: readonly ReviewNote[] }) { +/** + * What the model said about one change, shown on its card so it is cleared + * with the decision rather than left standing at the top of the tab. + */ +export function CardNotes({ notes }: { notes: readonly ReviewNote[] }) { + if (!notes.length) return null; return ( -
    +
      {notes.map((note, index) => ( -
    • - {note.label}:{" "} - {note.message} +
    • +
    • ))}
    ); } -function plural(count: number, singular: string, pluralForm: string) { - return `${count} ${count === 1 ? singular : pluralForm}`; -} - /** - * What the model flagged on the latest ANU check, and the fields it was least - * sure of. Review is the only check on a model-owned sync, so this leads the - * Changes tab: it says where to look before any change is accepted. Nothing - * is shown when the model flagged nothing. + * Parts of the latest ANU check the model could not read, which have no + * change of their own to carry the note. They clear on the next check. */ -export function ModelNotes({ - errors, - warnings, - uncertain, -}: { - errors: readonly ReviewNote[]; - warnings: readonly ReviewNote[]; - uncertain: readonly UncertainField[]; -}) { - if (!errors.length && !warnings.length && !uncertain.length) return null; +export function UnreadParts({ errors }: { errors: readonly ReviewNote[] }) { + if (!errors.length) return null; return ( -
    -

    - What to check -

    - {errors.length ? ( - - - ) : null} - {warnings.length ? ( - - - ) : null} - {uncertain.length ? ( - - - ) : null} -
    + + ); } diff --git a/apps/web/ui/admin/catalogue/changes/review-diff.tsx b/apps/web/ui/admin/catalogue/changes/review-diff.tsx new file mode 100644 index 00000000..587c99d2 --- /dev/null +++ b/apps/web/ui/admin/catalogue/changes/review-diff.tsx @@ -0,0 +1,213 @@ +import { cn } from "@/lib/cn"; +import { type DiffLine, diffLines } from "@/lib/catalogue/review-diff"; +import { + type RequirementRuleSlice, + requirementSliceExpression, +} from "@/lib/catalogue/requirement-expression"; +import type { CatalogueReviewUnitKind } from "@/lib/catalogue/review-units"; +import { + HIDDEN_COLUMNS, + heading, + isBlank, + isRecord, + plainText, + ruleRows, +} from "./review-value"; + +// Unchanged runs longer than this fold to their first and last lines. +const CONTEXT = 2; + +function asRows(value: unknown): unknown[] { + if (isBlank(value)) return []; + return Array.isArray(value) ? value : [value]; +} + +/** The columns either side fills, in first-seen order. */ +function columnsOf(values: unknown[]) { + const records = values.flatMap(asRows).filter(isRecord); + return [...new Set(records.flatMap((record) => Object.keys(record)))].filter( + (key) => + !HIDDEN_COLUMNS.has(key) && + records.some((record) => !isBlank(record[key])), + ); +} + +function ruleLines(value: unknown) { + if (isBlank(value)) return []; + const slice = value as RequirementRuleSlice; + const wording = slice.rule?.sourceText?.trim(); + const expression = requirementSliceExpression(slice); + return [ + ...(wording ? [`“${wording}”`] : []), + ...(expression + ? ruleRows(expression).map( + (row) => + `${" ".repeat(row.depth)}${row.text}${ + row.confidence === null + ? "" + : ` (${Math.round(row.confidence * 100)}%)` + }`, + ) + : []), + ]; +} + +/** + * Each side of a change as lines to compare: text by line, a collection one + * row per line with its columns in a fixed order, and a rule as its wording + * then each step it asks for. + */ +function linesOf( + value: unknown, + unitKind: CatalogueReviewUnitKind, + columns: string[], +) { + if (isBlank(value)) return []; + if (unitKind === "requirement_rule") return ruleLines(value); + if (unitKind === "scalar") return plainText(value).split("\n"); + return asRows(value).map((row) => + isRecord(row) + ? columns.map((column) => plainText(row[column]) || "—").join(" · ") + : plainText(row), + ); +} + +type Shown = DiffLine | { kind: "fold"; count: number }; + +function fold(lines: DiffLine[]): Shown[] { + const shown: Shown[] = []; + let index = 0; + while (index < lines.length) { + if (lines[index]!.kind !== "same") { + shown.push(lines[index]!); + index += 1; + continue; + } + let end = index; + while (end < lines.length && lines[end]!.kind === "same") end += 1; + const run = lines.slice(index, end); + const keepStart = index === 0 ? 0 : CONTEXT; + const keepEnd = end === lines.length ? 0 : CONTEXT; + if (run.length > keepStart + keepEnd + 1) { + shown.push(...run.slice(0, keepStart)); + shown.push({ kind: "fold", count: run.length - keepStart - keepEnd }); + shown.push(...run.slice(run.length - keepEnd)); + } else { + shown.push(...run); + } + index = end; + } + return shown; +} + +const SIGN = { same: " ", removed: "−", added: "+" } as const; + +/** + * Two values as a unified diff, the way git shows a change: removed lines in + * red, added lines in green, the words that changed within a line marked, and + * long unchanged stretches folded. + */ +export function ReviewDiff({ + before, + after, + beforeLabel, + afterLabel, + unitKind, +}: { + before: unknown; + after: unknown; + beforeLabel: string; + afterLabel: string; + unitKind: CatalogueReviewUnitKind; +}) { + const columns = unitKind === "collection" ? columnsOf([before, after]) : []; + const lines = diffLines( + linesOf(before, unitKind, columns), + linesOf(after, unitKind, columns), + ); + const shown = fold(lines); + return ( +
    +
    + + − {beforeLabel} + {isBlank(before) ? " (not set)" : ""} + + + + {afterLabel} + {isBlank(after) ? " (not set)" : ""} + + {columns.length ? ( + + {columns.map((column) => heading(column)).join(" · ")} + + ) : null} +
    +
    + {shown.length === 0 ? ( +

    No difference

    + ) : ( + shown.map((line, index) => + line.kind === "fold" ? ( +

    + ⋯ {line.count} unchanged line{line.count === 1 ? "" : "s"} +

    + ) : ( +

    + + + {line.kind === "removed" + ? "Removed: " + : line.kind === "added" + ? "Added: " + : ""} + + + {line.parts + ? line.parts.map((part, at) => + part.changed ? ( + + {part.text} + + ) : ( + part.text + ), + ) + : line.text || " "} + +

    + ), + ) + )} +
    +
    + ); +} diff --git a/apps/web/ui/admin/catalogue/changes/review-value.tsx b/apps/web/ui/admin/catalogue/changes/review-value.tsx index 65b6644d..432c37c3 100644 --- a/apps/web/ui/admin/catalogue/changes/review-value.tsx +++ b/apps/web/ui/admin/catalogue/changes/review-value.tsx @@ -1,52 +1,109 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@coursemap/ui/primitives/table"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@coursemap/ui/primitives/tabs"; +import type { CourseRuleExpression } from "@/lib/coursemap/course-types"; +import { + type RequirementRuleSlice, + requirementSliceExpression, +} from "@/lib/catalogue/requirement-expression"; import type { CatalogueReviewUnitKind } from "@/lib/catalogue/review-units"; import { JsonCode } from "@/ui/common/json-code"; +import { EnrolmentSteps } from "@/ui/courses/enrolment-steps"; +import { RequisiteDiagram } from "@/ui/courses/requisite-diagram"; +import { + groupSentence, + requisiteSentence, +} from "@/ui/courses/requisite-wording"; -type RequirementSlice = { - rule?: { sourceText?: string | null } | null; - conditions?: unknown[]; -}; +/** The record under review, for drawing a rule the way its course page does. */ +export type ReviewSubject = { code: string; academicYear: number }; + +// Bookkeeping every row carries that says nothing to a reviewer. +export const HIDDEN_COLUMNS = new Set(["position", "key", "id"]); +// Rules drawn as a chain into the course; the rest read better as a list. +const GRAPHED_RULES = new Set(["prerequisite", "corequisite"]); -function itemCount(value: unknown[]) { - return `${value.length} item${value.length === 1 ? "" : "s"}`; +export function isBlank(value: unknown) { + return ( + value === null || + value === undefined || + value === "" || + (Array.isArray(value) && value.length === 0) + ); } -function scalarText(value: unknown) { +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function heading(key: string) { + const words = key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_.]/g, " ") + .toLowerCase(); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +export function plainText(value: unknown): string { + if (isBlank(value)) return ""; if (typeof value === "boolean") return value ? "Yes" : "No"; + if (Array.isArray(value)) return value.map(plainText).join(", "); + if (isRecord(value)) { + return Object.entries(value) + .filter(([key, entry]) => !HIDDEN_COLUMNS.has(key) && !isBlank(entry)) + .map(([key, entry]) => `${heading(key)}: ${plainText(entry)}`) + .join("; "); + } return String(value); } /** - * One side of a comparison. Scalars read as themselves, a requirement rule - * leads with the wording ANU published, and a collection states its size with - * the full value behind a disclosure, because no administrator reads twelve - * assessment rows as prose. + * One side of a comparison. Scalars read as themselves, a collection reads as + * a table, and a requirement rule leads with ANU's wording and can be viewed + * as the course page's graph, as a table of its conditions or as stored. */ export function ReviewValue({ label, value, unitKind, note, + subject = null, }: { label: string; value: unknown; unitKind: CatalogueReviewUnitKind; note?: string; + subject?: ReviewSubject | null; }) { - const empty = value === null || value === undefined || value === ""; return (

    {label}

    - {empty ? ( + {isBlank(value) ? ( Not set ) : unitKind === "scalar" ? ( - {scalarText(value)} + {plainText(value)} ) : unitKind === "requirement_rule" ? ( - + ) : ( - + )}
    {note ? ( @@ -56,64 +113,198 @@ export function ReviewValue({ ); } -function RequirementValue({ +function CollectionTable({ label, value }: { label: string; value: unknown }) { + const rows = Array.isArray(value) ? value : [value]; + if (rows.every((row) => !isRecord(row))) { + return

    {rows.map(plainText).join(", ")}

    ; + } + if (!Array.isArray(value) && isRecord(value)) { + const entries = Object.entries(value).filter( + ([key, entry]) => !HIDDEN_COLUMNS.has(key) && !isBlank(entry), + ); + return ( + + + + {entries.map(([key, entry]) => ( + + {heading(key)} + + {plainText(entry)} + + + ))} + +
    + + ); + } + const records = rows.filter(isRecord); + // Columns in first-seen order, leaving out any no row fills. + const columns = [ + ...new Set(records.flatMap((record) => Object.keys(record))), + ].filter( + (key) => + !HIDDEN_COLUMNS.has(key) && + records.some((record) => !isBlank(record[key])), + ); + return ( + + + + + {columns.map((column) => ( + {heading(column)} + ))} + + + + {records.map((record, index) => ( + + {columns.map((column) => ( + + {plainText(record[column])} + + ))} + + ))} + +
    + + ); +} + +function Frame({ children }: { children: React.ReactNode }) { + return ( +
    + {children} +
    + ); +} + +export function RequirementValue({ label, + subject, value, }: { label: string; - value: RequirementSlice; + subject: ReviewSubject | null; + value: RequirementRuleSlice; }) { const wording = value.rule?.sourceText?.trim(); - const conditions = value.conditions?.length ?? 0; + const expression = requirementSliceExpression(value); + const graphed = + subject !== null && + expression !== null && + GRAPHED_RULES.has(value.rule?.key ?? ""); + const views = [ + ...(graphed ? ["graph"] : []), + ...(expression ? ["table"] : []), + "json", + ]; return ( - <> +
    {wording ? (

    {wording}

    ) : (

    No published wording

    )} - - + + + {graphed ? Graph : null} + {expression ? Table : null} + JSON + + {graphed && subject ? ( + + + + ) : null} + {expression ? ( + + {/* The list a student sees on the course page. */} +
    + +
    +
    + ) : null} + + + + + +
    +
    ); } -function CollectionValue({ label, value }: { label: string; value: unknown }) { - return ( - - ); +function courseCodes(expression: CourseRuleExpression | null): string[] { + if (!expression) return []; + if (expression.kind === "group") { + return expression.conditions.flatMap(courseCodes); + } + return expression.kind === "course" ? [expression.code] : []; } -function ValueDetails({ - label, - summary, - value, - open = false, - alwaysShow = false, -}: { - label: string; - summary: string; - value: unknown; - open?: boolean; - alwaysShow?: boolean; -}) { - return ( -
    - - {summary} - -
    - -
    -
    - ); +type RequirementRow = { + depth: number; + text: string; + detail: string; + confidence: number | null; +}; + +function requirementRows( + node: CourseRuleExpression, + depth = 0, +): RequirementRow[] { + if (node.kind === "group") { + return [ + { depth, text: groupSentence(node), detail: "", confidence: null }, + ...node.conditions.flatMap((child) => requirementRows(child, depth + 1)), + ]; + } + return [ + { + depth, + text: requisiteSentence(node), + detail: [ + node.hardness === "advisory" ? "Advisory" : null, + node.reviewState === "review" ? "Marked for review" : null, + ] + .filter(Boolean) + .join(" · "), + confidence: node.confidence, + }, + ]; +} + +/** A rule's steps, dropping the all-of wrapper that says nothing the rows don't. */ +export function ruleRows(expression: CourseRuleExpression) { + return expression.kind === "group" && expression.operator === "all_of" + ? expression.conditions.flatMap((child) => requirementRows(child)) + : requirementRows(expression); } diff --git a/apps/web/ui/admin/catalogue/changes/source-change-card.tsx b/apps/web/ui/admin/catalogue/changes/source-change-card.tsx index cc53e59c..9804c741 100644 --- a/apps/web/ui/admin/catalogue/changes/source-change-card.tsx +++ b/apps/web/ui/admin/catalogue/changes/source-change-card.tsx @@ -8,10 +8,31 @@ import type { SourceReviewChange, SourceReviewDecision, } from "@/lib/catalogue/source-review-store"; +import type { ReviewNote } from "@/lib/catalogue/review-notes"; import { resolveSourceChangeAction } from "@/lib/coursemap/admin-catalogue-actions"; -import { ReviewValue } from "./review-value"; +import { confidenceLabel } from "./first-read-review"; +import { CardNotes } from "./model-notes"; +import { ReviewDiff } from "./review-diff"; +import { type ReviewSubject, ReviewValue } from "./review-value"; import { showToast } from "@/ui/common/toast"; +function Fold({ + summary, + children, +}: { + summary: string; + children: React.ReactNode; +}) { + return ( +
    + + {summary} + +
    {children}
    +
    + ); +} + // The section heading already says a row was kept or has converged, so only // the two actionable classifications carry a badge of their own. const CLASSIFICATION_LABELS: Partial< @@ -31,11 +52,15 @@ export function SourceChangeCard({ recordId, path, canWrite, + subject = null, + notes = [], }: { change: SourceReviewChange; recordId: number; path: string; canWrite: boolean; + subject?: ReviewSubject | null; + notes?: readonly ReviewNote[]; }) { const router = useRouter(); const [isPending, startTransition] = useTransition(); @@ -62,43 +87,62 @@ export function SourceChangeCard({

    {change.label}

    - {CLASSIFICATION_LABELS[change.classification] ? ( - - {CLASSIFICATION_LABELS[change.classification]} +
    + + {confidenceLabel(change.confidence)} - ) : null} + {CLASSIFICATION_LABELS[change.classification] ? ( + + {CLASSIFICATION_LABELS[change.classification]} + + ) : null} +
    -
    - {change.classification === "conflict" && change.hasBaseSource ? ( - - ) : null} - - + {change.isStale || change.classification === "conflict" ? ( +

    + {change.isStale + ? "You changed this after the review was created." + : "Changed by hand since the last check."} +

    + ) : null} +
    + {/* What choosing ANU would do to the draft as it stands. */} + + {change.classification === "conflict" && change.hasBaseSource ? ( + + + + ) : null} + {change.unitKind === "requirement_rule" && + change.incomingSourceValue !== null ? ( + + + + ) : null}
    {canWrite ? (
    From d689882697f5e619e90a6a803a81ca8d21440459 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 08:14:09 +0000 Subject: [PATCH 3/6] feat: move record actions into a menu and let a stuck sync be stopped --- .../tests/catalogue-content-editor.test.tsx | 59 +++-- apps/web/tests/catalogue-sync-button.test.tsx | 39 +++ .../catalogue/catalogue-editor-toolbar.tsx | 205 ---------------- .../web/ui/admin/catalogue/record-actions.tsx | 232 ++++++++++++++++++ apps/web/ui/admin/catalogue/record-header.tsx | 170 ++++++------- apps/web/ui/admin/catalogue/record-page.tsx | 50 ++-- apps/web/ui/admin/catalogue/record-sync.tsx | 125 ++++++++++ apps/web/ui/admin/catalogue/sync-button.tsx | 76 ++++-- 8 files changed, 590 insertions(+), 366 deletions(-) delete mode 100644 apps/web/ui/admin/catalogue/catalogue-editor-toolbar.tsx create mode 100644 apps/web/ui/admin/catalogue/record-actions.tsx create mode 100644 apps/web/ui/admin/catalogue/record-sync.tsx diff --git a/apps/web/tests/catalogue-content-editor.test.tsx b/apps/web/tests/catalogue-content-editor.test.tsx index 4351d035..76ca3bf9 100644 --- a/apps/web/tests/catalogue-content-editor.test.tsx +++ b/apps/web/tests/catalogue-content-editor.test.tsx @@ -12,7 +12,7 @@ import { TooltipProvider } from "@coursemap/ui/primitives/tooltip"; import { emptyCatalogueContent } from "@/lib/catalogue/content"; import { CatalogueEditorProvider } from "@/ui/admin/catalogue/catalogue-editor-context"; -import { CatalogueEditorToolbar } from "@/ui/admin/catalogue/catalogue-editor-toolbar"; +import { RecordActions } from "@/ui/admin/catalogue/record-actions"; import { CatalogueContentEditor } from "@/ui/admin/catalogue/content-editor"; const actions = vi.hoisted(() => ({ @@ -60,7 +60,7 @@ function renderEditor({ hasDraft = true } = {}) { initialHasUnpublishedChanges={hasDraft} path="/admin/courses/2026/comp1000" > - + , @@ -154,21 +154,29 @@ test("editing opens the draft actions, with nothing yet to publish", async () => actions.save.mockResolvedValue({ ok: true, revision: 1, unchanged: false }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderEditor({ hasDraft: false }); - await user.click(screen.getByRole("button", { name: "Edit" })); + await user.click(screen.getByRole("button", { name: "Record actions" })); + await user.click(screen.getByRole("menuitem", { name: "Edit" })); - expect(screen.getByRole("status")).toHaveTextContent("Draft"); + await user.click(screen.getByRole("button", { name: "Record actions" })); expect( - screen.getByRole("button", { name: "Discard draft" }), + screen.getByRole("menuitem", { name: "Discard draft" }), ).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Publish" })).toBeDisabled(); + expect(screen.getByRole("menuitem", { name: "Publish" })).toHaveAttribute( + "aria-disabled", + "true", + ); + await user.keyboard("{Escape}"); fireEvent.change(screen.getByLabelText("Description"), { target: { value: "Worth keeping" }, }); await act(async () => vi.advanceTimersByTime(1_000)); + await user.click(screen.getByRole("button", { name: "Record actions" })); await waitFor(() => - expect(screen.getByRole("button", { name: "Publish" })).toBeEnabled(), + expect( + screen.getByRole("menuitem", { name: "Publish" }), + ).not.toHaveAttribute("aria-disabled"), ); }); @@ -177,33 +185,29 @@ test("discarding a draft leaves the record with nothing to discard", async () => renderEditor(); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - await user.click(screen.getByRole("button", { name: "Discard draft" })); - const confirm = await screen.findByRole("button", { - name: "Discard draft", - // The trigger is behind the open dialog, so only the confirmation - // inside it is still reachable. - hidden: false, - }); - await user.click(confirm); - await waitFor(() => expect(actions.discard).toHaveBeenCalled()); - await waitFor(() => - expect( - screen.queryByRole("button", { name: "Discard draft" }), - ).not.toBeInTheDocument(), + await user.click(screen.getByRole("button", { name: "Record actions" })); + await user.click(screen.getByRole("menuitem", { name: "Discard draft" })); + await user.click( + await screen.findByRole("button", { name: "Discard draft" }), ); + await waitFor(() => expect(actions.discard).toHaveBeenCalled()); + await user.click(screen.getByRole("button", { name: "Record actions" })); + expect( + screen.queryByRole("menuitem", { name: "Discard draft" }), + ).not.toBeInTheDocument(); }); test("a record without a draft is read until editing is asked for", async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderEditor({ hasDraft: false }); - expect(screen.getByRole("status")).toHaveTextContent("Published"); expect(screen.queryByLabelText("Title")).not.toBeInTheDocument(); // The values are still there to read, just not to change. expect(screen.getByText("Test course")).toBeInTheDocument(); expect(actions.begin).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: "Edit" })); + await user.click(screen.getByRole("button", { name: "Record actions" })); + await user.click(screen.getByRole("menuitem", { name: "Edit" })); expect(screen.getByLabelText("Title")).toHaveValue("Test course"); // Asking to edit is what opens the draft, so the record is still a draft // when whoever opened it comes back to the page later. @@ -217,21 +221,24 @@ test("backing out of an opened draft discards it, keeping no checkpoint", async actions.discard.mockResolvedValue({ ok: true, message: "Draft discarded." }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); renderEditor({ hasDraft: false }); - await user.click(screen.getByRole("button", { name: "Edit" })); + await user.click(screen.getByRole("button", { name: "Record actions" })); + await user.click(screen.getByRole("menuitem", { name: "Edit" })); - await user.click(screen.getByRole("button", { name: "Discard draft" })); + await user.click(screen.getByRole("button", { name: "Record actions" })); + await user.click(screen.getByRole("menuitem", { name: "Discard draft" })); expect( screen.getByText( "The editor goes back to the published version. Nothing has been changed in it, so nothing is kept.", ), ).toBeInTheDocument(); await user.click( - await screen.findByRole("button", { name: "Discard draft", hidden: false }), + await screen.findByRole("button", { name: "Discard draft" }), ); await waitFor(() => expect(actions.discard).toHaveBeenCalled()); + await user.click(screen.getByRole("button", { name: "Record actions" })); await waitFor(() => - expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument(), + expect(screen.getByRole("menuitem", { name: "Edit" })).toBeInTheDocument(), ); expect(screen.queryByLabelText("Description")).not.toBeInTheDocument(); }); diff --git a/apps/web/tests/catalogue-sync-button.test.tsx b/apps/web/tests/catalogue-sync-button.test.tsx index 68b50e5d..1cc7c5e4 100644 --- a/apps/web/tests/catalogue-sync-button.test.tsx +++ b/apps/web/tests/catalogue-sync-button.test.tsx @@ -1,5 +1,9 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, expect, test, vi } from "vitest"; +import { + FailedSyncAlert, + RecordSyncProvider, +} from "@/ui/admin/catalogue/record-sync"; import { CatalogueSyncButton } from "@/ui/admin/catalogue/sync-button"; const { refresh, progress, success, info, failure } = vi.hoisted(() => ({ @@ -154,3 +158,38 @@ test("offers a retry after a failed sync", () => { ); expect(screen.getByRole("button", { name: "Retry sync" })).toBeEnabled(); }); + +test("a failed sync keeps its error behind Details and can be retried", async () => { + const request = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ syncId: "sync-2", mode: "inline" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + render( + + + , + ); + + expect(screen.getByText("The last ANU sync failed")).toBeInTheDocument(); + expect(screen.queryByText("duplicate key value")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Details" })); + expect(screen.getByText("duplicate key value")).toBeInTheDocument(); + expect(screen.getByText("23505")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Retry sync" })); + await waitFor(() => expect(request).toHaveBeenCalledTimes(1)); +}); diff --git a/apps/web/ui/admin/catalogue/catalogue-editor-toolbar.tsx b/apps/web/ui/admin/catalogue/catalogue-editor-toolbar.tsx deleted file mode 100644 index 1d7e1a04..00000000 --- a/apps/web/ui/admin/catalogue/catalogue-editor-toolbar.tsx +++ /dev/null @@ -1,205 +0,0 @@ -"use client"; - -import { Button } from "@coursemap/ui/primitives/button"; -import { - Check, - EyeOff, - LoaderCircle, - Pencil, - RefreshCw, - Send, - Trash2, - TriangleAlert, -} from "lucide-react"; - -import { ConfirmDialog } from "@/ui/common/confirm-dialog"; -import { useCatalogueEditor } from "./catalogue-editor-context"; - -/** - * What state this record's content is in, and what can be done about it. - * - * It sits above the record's title rather than above the fields, because what - * it reports - read-only, unsaved, published - is true of the whole record and - * not of one tab. The actions follow the state, so nothing is offered that - * would fail if it were chosen: a record being read offers only Edit, and - * discarding and publishing appear for as long as the editor is open. - */ -export function CatalogueEditorToolbar() { - const { - beginEditing, - cancelEditing, - dirty, - discard, - editing, - hasDraft, - hasUnpublishedChanges, - isPublished, - publish, - saveError, - saveState, - unpublish, - } = useCatalogueEditor(); - const busy = dirty || saveState === "saving"; - // Opening the editor is itself the start of a draft: the record is being - // worked on whether or not a change has been saved against it yet, so the - // state and the actions that follow it do not wait for the first keystroke. - const drafting = hasDraft || editing; - // What the record is right now, in the same shorthand as the header badge - // beside the code. - const resting = drafting - ? { dot: "bg-violet-500", label: "Draft" } - : isPublished - ? { dot: "bg-emerald-500", label: "Published" } - : { dot: "bg-muted-foreground/40", label: "Not published" }; - - return ( -
    -
    -
    -
    - {isPublished ? ( - -
    -
    - ); -} diff --git a/apps/web/ui/admin/catalogue/record-actions.tsx b/apps/web/ui/admin/catalogue/record-actions.tsx new file mode 100644 index 00000000..fba26a12 --- /dev/null +++ b/apps/web/ui/admin/catalogue/record-actions.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { Button } from "@coursemap/ui/primitives/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@coursemap/ui/primitives/dropdown-menu"; +import { + Check, + CircleStop, + Ellipsis, + EyeOff, + LoaderCircle, + Pencil, + RefreshCw, + Send, + Trash2, + TriangleAlert, +} from "lucide-react"; +import { useState } from "react"; +import { ConfirmDialog } from "@/ui/common/confirm-dialog"; +import { useCatalogueEditor } from "./catalogue-editor-context"; +import { type RecordSync, useRecordSync } from "./record-sync"; + +type Confirming = "publish" | "discard" | "unpublish" | null; + +/** + * Everything that can be done to the record, behind one menu beside its + * title. The badge beside the code already says + * whether it is published or drafted, so only whether the edits are saved is + * reported here. + */ +export function RecordActions({ canWrite }: { canWrite: boolean }) { + const sync = useRecordSync(); + return canWrite ? ( + + ) : sync ? ( + + ) : null; +} + +/** + * The sync is followed above the menu, not by the item, because the item + * unmounts whenever the menu closes and would stop watching the sync. + */ +function SyncItem({ sync }: { sync: RecordSync }) { + const { start, cancel, busy, isActive, label } = sync; + if (isActive) { + return ( + <> + + + + + + ); + } + return ( + + + ); +} + +function MenuTrigger() { + return ( + + + + ); +} + +function SyncOnlyActions({ sync }: { sync: RecordSync }) { + return ( + + + + + + + ); +} + +function EditableRecordActions({ sync }: { sync: RecordSync | null }) { + const { + beginEditing, + cancelEditing, + dirty, + discard, + editing, + hasDraft, + hasUnpublishedChanges, + isPublished, + publish, + saveError, + saveState, + unpublish, + } = useCatalogueEditor(); + const [confirming, setConfirming] = useState(null); + const busy = dirty || saveState === "saving"; + const failed = saveState === "error" || saveState === "conflict"; + const drafting = hasDraft || editing; + const close = (open: boolean) => { + if (!open) setConfirming(null); + }; + + return ( +
    + + {saveState === "saving" ? ( + + + ) : failed ? ( + + + ) : editing ? ( + + + ) : null} + + {saveState === "conflict" ? ( + + ) : null} + + + + {!editing ? ( + + + ) : null} + {drafting ? ( + setConfirming("publish")} + > + + ) : null} + {sync ? : null} + {drafting || isPublished ? : null} + {drafting ? ( + // A draft not yet opened on the server holds nothing, so backing + // out of it is only leaving the editor and asks nothing. + + hasDraft ? setConfirming("discard") : cancelEditing() + } + > + + ) : null} + {isPublished ? ( + setConfirming("unpublish")} + > + + ) : null} + + + + + +
    + ); +} diff --git a/apps/web/ui/admin/catalogue/record-header.tsx b/apps/web/ui/admin/catalogue/record-header.tsx index 0dc3ade8..d3bb81dc 100644 --- a/apps/web/ui/admin/catalogue/record-header.tsx +++ b/apps/web/ui/admin/catalogue/record-header.tsx @@ -2,12 +2,10 @@ import { Badge } from "@coursemap/ui/components/badge"; import { ExternalLink, TriangleAlert } from "lucide-react"; import Link from "next/link"; import type { CatalogueRecord } from "@/lib/coursemap/admin-catalogue-record"; -import { - CATALOGUE_KIND_LABELS, - adminCatalogueRecordPath, -} from "@/lib/coursemap/catalogue-kinds"; +import { CATALOGUE_KIND_LABELS } from "@/lib/coursemap/catalogue-kinds"; import { anuSourceUrl } from "./anu-source"; -import { CatalogueSyncButton } from "./sync-button"; +import { RecordActions } from "./record-actions"; +import { FailedSyncAlert, RecordSyncProvider } from "./record-sync"; function formatDate(value: string | null) { if (!value) return null; @@ -21,15 +19,13 @@ export function RecordHeader({ hasDraft, hasUnpublishedChanges, canSync, - openChangeCount, - conflictCount, + canWrite, }: { record: CatalogueRecord; hasDraft: boolean; hasUnpublishedChanges: boolean; canSync: boolean; - openChangeCount: number; - conflictCount: number; + canWrite: boolean; }) { const labels = CATALOGUE_KIND_LABELS[record.kind]; const publicationLabel = record.publishedVersionId @@ -39,98 +35,86 @@ export function RecordHeader({ : hasDraft ? "Not published · Draft" : "Not published"; - return ( -
    -
    -
    -

    - {record.code} -

    - {record.academicYear} - - {publicationLabel} - -
    - {/* + const failedSync = + record.syncs[0]?.status === "failed" ? record.syncs[0] : null; + const content = ( +
    +
    +
    +
    +

    + {record.code} +

    + {record.academicYear} + + {publicationLabel} + +
    + {/* 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 title itself opens the ANU page. */} - - {record.title} -
    - {canSync ? ( - +
    + +
    + {failedSync ? ( + ) : null} - +
    + ); + return canSync ? ( + + {content} + + ) : ( + content ); } diff --git a/apps/web/ui/admin/catalogue/record-page.tsx b/apps/web/ui/admin/catalogue/record-page.tsx index 9280baa6..273ab9d0 100644 --- a/apps/web/ui/admin/catalogue/record-page.tsx +++ b/apps/web/ui/admin/catalogue/record-page.tsx @@ -16,6 +16,10 @@ import { loadVersionReviewNotes, loadVersionWrite, } from "@/lib/coursemap/admin-catalogue-record"; +import { + classifyFirstRead, + isCertainFirstRead, +} from "@/lib/catalogue/first-read"; import { summariseReviewNotes } from "@/lib/catalogue/review-notes"; import { courseDetailsFromWrite } from "@/lib/coursemap/course-version-view"; import { @@ -32,7 +36,6 @@ import { RecordHeader } from "./record-header"; import { StudentViewPanel } from "./student-view-panel"; import { RecordTabList, RecordTabs, type RecordSection } from "./record-tabs"; import { CatalogueEditorProvider } from "./catalogue-editor-context"; -import { CatalogueEditorToolbar } from "./catalogue-editor-toolbar"; import { CatalogueContentEditor } from "./content-editor"; function FoundationEmpty({ @@ -128,8 +131,14 @@ export async function CatalogueRecordPage({ .sort((left, right) => left.id - right.id) .map((version, index) => [version.id, index + 1]), ); + // A first reading counts while it is unsure; what was read plainly waits + // folded away and does not ask for attention. const openChanges = - (review?.conflicts.length ?? 0) + (review?.incoming.length ?? 0); + (review?.conflicts.length ?? 0) + + (review?.incoming.length ?? 0) + + (review?.firstRead.filter( + (change) => change.band !== "accepted" && !isCertainFirstRead(change), + ).length ?? 0); return ( @@ -153,22 +162,17 @@ export async function CatalogueRecordPage({ path={path} >
    - {/* - The toolbar reports the record's state, so it leads the page - rather than the fields. It appears only where it can act: the - other tabs read the record and do not change it. - */} - {canWrite && section === "content" ? ( - + {/* The record's summary belongs with its content; the other + tabs lead with what they are for. */} + {section === "content" ? ( + ) : null} - {canWrite ? ( @@ -193,17 +197,13 @@ export async function CatalogueRecordPage({ isPublished={record.publishedVersionId !== null} kindLabel={labels.singular.toLowerCase()} notes={notes} - latestSync={ - canManageImports && record.syncs[0] - ? { - id: record.syncs[0].id, - completedAt: record.syncs[0].completedAt, - } - : null - } path={path} recordId={record.recordId} review={review} + allFields={hasDraft ? classifyFirstRead(draft.content) : []} + subject={ + kind === "course" ? { code: record.code, academicYear } : null + } unpublished={unpublished} /> diff --git a/apps/web/ui/admin/catalogue/record-sync.tsx b/apps/web/ui/admin/catalogue/record-sync.tsx new file mode 100644 index 00000000..10cc5271 --- /dev/null +++ b/apps/web/ui/admin/catalogue/record-sync.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { + Alert, + AlertAction, + AlertDescription, + AlertTitle, +} from "@coursemap/ui/components/alert"; +import { Button } from "@coursemap/ui/primitives/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@coursemap/ui/primitives/collapsible"; +import { ChevronDown, CircleAlert, RefreshCw } from "lucide-react"; +import { createContext, type ReactNode, useContext } from "react"; +import { type CatalogueSyncTarget, useCatalogueSync } from "./sync-button"; + +export type RecordSync = ReturnType; + +const RecordSyncContext = createContext(null); + +/** + * Follows the record's sync once for everything in the header, so the menu and + * the failure notice start and watch the same sync rather than one each. + */ +export function RecordSyncProvider({ + target, + children, +}: { + target: CatalogueSyncTarget; + children: ReactNode; +}) { + const sync = useCatalogueSync(target); + return ( + + {children} + + ); +} + +/** The record's sync, or null where this person cannot sync it. */ +export function useRecordSync() { + return useContext(RecordSyncContext); +} + +function formatTime(value: string) { + return new Intl.DateTimeFormat("en-AU", { + dateStyle: "long", + timeStyle: "short", + }).format(new Date(value)); +} + +/** + * Says the last sync failed and offers to run it again. What went wrong is + * kept behind Details, since the database's own wording helps whoever is + * investigating and nobody else. + */ +export function FailedSyncAlert({ + errorCode, + errorMessage, + failedAt, +}: { + errorCode: string | null; + errorMessage: string | null; + failedAt: string | null; +}) { + const sync = useRecordSync(); + if (sync?.isActive) return null; + return ( + + + + + ); +} diff --git a/apps/web/ui/admin/catalogue/sync-button.tsx b/apps/web/ui/admin/catalogue/sync-button.tsx index 856cd499..ae7728fe 100644 --- a/apps/web/ui/admin/catalogue/sync-button.tsx +++ b/apps/web/ui/admin/catalogue/sync-button.tsx @@ -36,20 +36,26 @@ function syncOutcome(code: string, status: string) { return { title: `${code} updated from ANU` }; } -export function CatalogueSyncButton({ - recordId, - code, - kind, - latestSync, - hasSynced, -}: { +export type CatalogueSyncTarget = { recordId: number; code: string; kind: CatalogueKind; latestSync: CatalogueSync | null; /** Whether ANU has ever been read for this record, which names the action. */ hasSynced: boolean; -}) { +}; + +/** + * Starts a record's ANU sync and follows it to the end with a toast. Whatever + * control starts it shows `busy` while it runs and names itself `label`. + */ +export function useCatalogueSync({ + recordId, + code, + kind, + latestSync, + hasSynced, +}: CatalogueSyncTarget) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const [startedSyncId, setStartedSyncId] = useState(null); @@ -150,24 +156,60 @@ export function CatalogueSyncButton({ task.current = null; }, [code, latestSync, retrySync, startedSyncId]); + // A sync whose worker went away, such as a dev server that restarted + // mid-read, never finishes on its own, so an active one can be stopped. + const cancel = useCallback(() => { + const syncId = latestSync?.id ?? startedSyncId; + if (!syncId) return; + startTransition(async () => { + const response = await fetch("/api/admin/catalogue-syncs", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ syncId }), + }); + if (!response.ok) { + const result = (await response.json()) as { error?: string }; + task.current?.fail({ + title: `Couldn't stop the ${code} sync`, + detail: result.error ?? "The sync could not be stopped.", + }); + task.current = null; + } + setStartedSyncId(null); + router.refresh(); + }); + }, [code, latestSync?.id, router, startedSyncId]); + + return { + start: startSync, + cancel, + busy: isPending || isActive, + isActive, + label: + latestSync?.status === "failed" && !isActive + ? "Retry sync" + : hasSynced + ? "Resync" + : "Sync", + }; +} + +export function CatalogueSyncButton(target: CatalogueSyncTarget) { + const sync = useCatalogueSync(target); return ( ); } From 8c76345745caaf0a5c1cf0a1333efad65e2c6e60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 08:14:09 +0000 Subject: [PATCH 4/6] feat: retry a failed sync and simplify the sync run page --- apps/web/tests/operations-sync-views.test.tsx | 4 +- .../ui/admin/operations/sync-detail-tabs.tsx | 41 +++++--- apps/web/ui/admin/operations/sync-detail.tsx | 98 +++++++++++-------- .../ui/admin/operations/sync-retry-button.tsx | 61 ++++++++++++ 4 files changed, 147 insertions(+), 57 deletions(-) create mode 100644 apps/web/ui/admin/operations/sync-retry-button.tsx diff --git a/apps/web/tests/operations-sync-views.test.tsx b/apps/web/tests/operations-sync-views.test.tsx index e01e2919..3204a5cb 100644 --- a/apps/web/tests/operations-sync-views.test.tsx +++ b/apps/web/tests/operations-sync-views.test.tsx @@ -208,7 +208,7 @@ test("an empty list says what fills it rather than showing an empty table", () = test("the sync detail shows the failure, the lease and the attempt that failed", async () => { const user = userEvent.setup(); renderSyncDetail(syncDetail()); - // The failure and the lease are true of the sync, so they lead every tab. + // The failure is true of the sync, so it leads every tab. expect(screen.getByText("OPENROUTER_HTTP_500")).toBeTruthy(); expect(screen.getByText("OpenRouter returned 500.")).toBeTruthy(); expect(screen.getByText("99999999-9999-4999-8999-999999999999")).toBeTruthy(); @@ -219,7 +219,7 @@ test("the sync detail shows the failure, the lease and the attempt that failed", expect(within(stages!).getByText("3")).toBeTruthy(); expect(within(stages!).getByText("OpenRouter returned 500.")).toBeTruthy(); - await user.click(screen.getByRole("tab", { name: /Extractions/ })); + // Extractions share the stages tab, since both say where the run stopped. expect(screen.getByText("openai/gpt-5-2026")).toBeTruthy(); expect(screen.getByText("2 errors")).toBeTruthy(); diff --git a/apps/web/ui/admin/operations/sync-detail-tabs.tsx b/apps/web/ui/admin/operations/sync-detail-tabs.tsx index 8b9b0928..951a2e14 100644 --- a/apps/web/ui/admin/operations/sync-detail-tabs.tsx +++ b/apps/web/ui/admin/operations/sync-detail-tabs.tsx @@ -1,16 +1,17 @@ "use client"; import { Tabs } from "@coursemap/ui/primitives/tabs"; -import { FileCode2, Info, ListChecks, Sparkles } from "lucide-react"; -import { useState, type ReactNode } from "react"; +import { FileCode2, Info, ListChecks } from "lucide-react"; +import { createContext, useContext, useState, type ReactNode } from "react"; import { SectionTabs } from "@/ui/common/section-tabs"; -export type SyncDetailSection = - "overview" | "stages" | "extractions" | "artefacts"; +export type SyncDetailSection = "overview" | "stages" | "artefacts"; + +const SyncDetailSectionContext = createContext("overview"); /** * One sync's diagnostics, split by the question being asked of it: what it - * was, where it stopped, what the model cost, and what it captured. The whole + * was, where it stopped and what the model cost, and what it captured. The whole * record used to be a single scroll, so the failing stage sat below several * screens of contract versions and the artefact viewer never had the page to * itself. @@ -26,11 +27,31 @@ export function SyncDetailTabs({ children }: { children: ReactNode }) { value={section} onValueChange={(next) => setSection(next as SyncDetailSection)} > - {children} + + {children} + ); } +/** + * Content that belongs to one section but sits outside that section's tab + * panel, such as the sync's heading, which only the overview carries. + */ +export function SyncDetailSectionOnly({ + section, + children, +}: { + section: SyncDetailSection | readonly SyncDetailSection[]; + children: ReactNode; +}) { + const current = useContext(SyncDetailSectionContext); + const shown = Array.isArray(section) + ? section.includes(current) + : section === current; + return shown ? children : null; +} + export function SyncDetailTabList({ stageCount, extractionCount, @@ -54,13 +75,7 @@ export function SyncDetailTabList({ label: "Stages", icon: ListChecks, count: failedStageCount, - disabled: stageCount === 0, - }, - { - value: "extractions", - label: "Extractions", - icon: Sparkles, - disabled: extractionCount === 0, + disabled: stageCount === 0 && extractionCount === 0, }, { value: "artefacts", diff --git a/apps/web/ui/admin/operations/sync-detail.tsx b/apps/web/ui/admin/operations/sync-detail.tsx index 31863c46..5a941382 100644 --- a/apps/web/ui/admin/operations/sync-detail.tsx +++ b/apps/web/ui/admin/operations/sync-detail.tsx @@ -1,11 +1,9 @@ -import { - ADMIN_CATALOGUE_OPERATIONS_PATH, - adminCatalogueRecordPath, -} from "@/lib/coursemap/catalogue-kinds"; -import { ArrowLeft, ExternalLink } from "lucide-react"; +import { adminCatalogueRecordPath } from "@/lib/coursemap/catalogue-kinds"; +import { CircleAlert, ExternalLink } from "lucide-react"; import Link from "next/link"; import { Alert, + AlertAction, AlertDescription, AlertTitle, } from "@coursemap/ui/components/alert"; @@ -24,6 +22,8 @@ import { badgeVariantForTone } from "@/lib/ui"; import type { SyncDetail } from "@/lib/coursemap/admin-operations"; import { DataTableShell } from "@/ui/common/data-table"; import { ArtefactViewer } from "./artefact-viewer"; +import { SyncDetailSectionOnly } from "./sync-detail-tabs"; +import { SyncRetryButton } from "./sync-retry-button"; import { Facts, Measure, @@ -59,42 +59,60 @@ export function SyncDetailView({ sync }: { sync: SyncDetail }) { ); return (
    - -