Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ca34f27
fix: make catalogue admin actions and tabs take effect
HarryRandall Sep 20, 2026
97baa36
refactor: show import runs, records and stages as tables
HarryRandall Sep 20, 2026
188c231
docs: record the component reuse list and the work left after A7
HarryRandall Sep 20, 2026
76264ae
refactor: diff a catalogue change field by field
HarryRandall Sep 20, 2026
be74860
refactor: rebuild the import tables from the components they replaced
HarryRandall Sep 20, 2026
f2df671
docs: correct the table guidance and record which shell to use
HarryRandall Sep 20, 2026
0e7de0e
feat: restore the import artefact viewer
HarryRandall Sep 20, 2026
38992b1
docs: record the restored artefact viewer
HarryRandall Sep 20, 2026
0e3e3fb
refactor: make the whole directory row a target
HarryRandall Sep 20, 2026
3ea1488
fix: draw the skeleton the page it precedes will draw
HarryRandall Sep 20, 2026
45257ec
fix: state why a draft cannot be published
HarryRandall Sep 20, 2026
8fc2b68
feat: give record pages an error boundary
HarryRandall Sep 20, 2026
5d6a6b2
refactor: make review a decision table
HarryRandall Sep 20, 2026
a059633
feat: search, sort and page the import runs
HarryRandall Sep 20, 2026
28c47b2
fix: close four ways the catalogue pipeline lost work quietly
HarryRandall Sep 20, 2026
eef8009
fix: keep the run list vocabulary out of the server-only module
HarryRandall Sep 20, 2026
d7cd8d8
docs: record what landed across the three workstreams
HarryRandall Sep 20, 2026
e223e12
fix: put the record tabs in the shell and stop contradicting the reader
HarryRandall Sep 20, 2026
a39f1fd
feat: lead the catalogue review with a verdict and one next action
HarryRandall Sep 20, 2026
1a677b4
fix: let a flag row breathe on a phone and name the view control
HarryRandall Sep 20, 2026
4a9d97b
refactor: give the record tabs icons instead of a count
HarryRandall Sep 20, 2026
59bb254
refactor: flatten the imports page into one table of records
HarryRandall Sep 20, 2026
24ce7b2
docs: record that the imports page is now one flat table
HarryRandall Sep 20, 2026
3b8723c
refactor: stop wrapping a collapsed review in a second disclosure
HarryRandall Sep 20, 2026
713994c
fix: read the whole directory rather than its first thousand codes
HarryRandall Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/app/admin/courses/[code]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueRecordError as default } from "@/ui/admin/catalogue-table/catalogue-error";
2 changes: 2 additions & 0 deletions apps/web/app/admin/courses/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error";
2 changes: 2 additions & 0 deletions apps/web/app/admin/courses/imports/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error";
4 changes: 2 additions & 2 deletions apps/web/app/admin/courses/imports/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";
import { ImportRecordsLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return <CatalogueLoading noun="import runs" imports />;
return <ImportRecordsLoading noun="imports" />;
}
7 changes: 4 additions & 3 deletions apps/web/app/admin/courses/imports/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { CatalogueImportRunsPage } from "@/ui/admin/catalogue/catalogue-pages";
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueImportsPage } from "@/ui/admin/catalogue/catalogue-pages";

export const dynamic = "force-dynamic";

export default function Page() {
return <CatalogueImportRunsPage kind="course" />;
export default function Page({ searchParams }: { searchParams: SearchParams }) {
return <CatalogueImportsPage kind="course" searchParams={searchParams} />;
}
2 changes: 1 addition & 1 deletion apps/web/app/admin/courses/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return <CatalogueLoading noun="courses" />;
return <CatalogueLoading noun="courses" layout="directory" />;
}
2 changes: 2 additions & 0 deletions apps/web/app/admin/majors/[code]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueRecordError as default } from "@/ui/admin/catalogue-table/catalogue-error";
2 changes: 2 additions & 0 deletions apps/web/app/admin/majors/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error";
2 changes: 2 additions & 0 deletions apps/web/app/admin/majors/imports/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error";
4 changes: 2 additions & 2 deletions apps/web/app/admin/majors/imports/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";
import { ImportRecordsLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return <CatalogueLoading noun="import runs" imports />;
return <ImportRecordsLoading noun="imports" />;
}
7 changes: 4 additions & 3 deletions apps/web/app/admin/majors/imports/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { CatalogueImportRunsPage } from "@/ui/admin/catalogue/catalogue-pages";
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueImportsPage } from "@/ui/admin/catalogue/catalogue-pages";

export const dynamic = "force-dynamic";

export default function Page() {
return <CatalogueImportRunsPage kind="major" />;
export default function Page({ searchParams }: { searchParams: SearchParams }) {
return <CatalogueImportsPage kind="major" searchParams={searchParams} />;
}
2 changes: 1 addition & 1 deletion apps/web/app/admin/majors/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return <CatalogueLoading noun="majors" />;
return <CatalogueLoading noun="majors" layout="directory" />;
}
2 changes: 2 additions & 0 deletions apps/web/app/admin/minors/[code]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueRecordError as default } from "@/ui/admin/catalogue-table/catalogue-error";
2 changes: 2 additions & 0 deletions apps/web/app/admin/minors/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error";
2 changes: 2 additions & 0 deletions apps/web/app/admin/minors/imports/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error";
4 changes: 2 additions & 2 deletions apps/web/app/admin/minors/imports/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";
import { ImportRecordsLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return <CatalogueLoading noun="import runs" imports />;
return <ImportRecordsLoading noun="imports" />;
}
7 changes: 4 additions & 3 deletions apps/web/app/admin/minors/imports/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { CatalogueImportRunsPage } from "@/ui/admin/catalogue/catalogue-pages";
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueImportsPage } from "@/ui/admin/catalogue/catalogue-pages";

export const dynamic = "force-dynamic";

export default function Page() {
return <CatalogueImportRunsPage kind="minor" />;
export default function Page({ searchParams }: { searchParams: SearchParams }) {
return <CatalogueImportsPage kind="minor" searchParams={searchParams} />;
}
2 changes: 1 addition & 1 deletion apps/web/app/admin/minors/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return <CatalogueLoading noun="minors" />;
return <CatalogueLoading noun="minors" layout="directory" />;
}
2 changes: 2 additions & 0 deletions apps/web/app/admin/programmes/[code]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueRecordError as default } from "@/ui/admin/catalogue-table/catalogue-error";
2 changes: 2 additions & 0 deletions apps/web/app/admin/programmes/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error";
2 changes: 2 additions & 0 deletions apps/web/app/admin/programmes/imports/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error";
4 changes: 2 additions & 2 deletions apps/web/app/admin/programmes/imports/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";
import { ImportRecordsLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return <CatalogueLoading noun="import runs" imports />;
return <ImportRecordsLoading noun="imports" />;
}
7 changes: 4 additions & 3 deletions apps/web/app/admin/programmes/imports/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { CatalogueImportRunsPage } from "@/ui/admin/catalogue/catalogue-pages";
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueImportsPage } from "@/ui/admin/catalogue/catalogue-pages";

export const dynamic = "force-dynamic";

export default function Page() {
return <CatalogueImportRunsPage kind="programme" />;
export default function Page({ searchParams }: { searchParams: SearchParams }) {
return <CatalogueImportsPage kind="programme" searchParams={searchParams} />;
}
2 changes: 1 addition & 1 deletion apps/web/app/admin/programmes/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return <CatalogueLoading noun="programmes" />;
return <CatalogueLoading noun="programmes" layout="directory" />;
}
2 changes: 2 additions & 0 deletions apps/web/app/admin/specialisations/[code]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueRecordError as default } from "@/ui/admin/catalogue-table/catalogue-error";
2 changes: 2 additions & 0 deletions apps/web/app/admin/specialisations/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error";
2 changes: 2 additions & 0 deletions apps/web/app/admin/specialisations/imports/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use client";
export { CatalogueError as default } from "@/ui/admin/catalogue-table/catalogue-error";
4 changes: 2 additions & 2 deletions apps/web/app/admin/specialisations/imports/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";
import { ImportRecordsLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return <CatalogueLoading noun="import runs" imports />;
return <ImportRecordsLoading noun="imports" />;
}
9 changes: 6 additions & 3 deletions apps/web/app/admin/specialisations/imports/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { CatalogueImportRunsPage } from "@/ui/admin/catalogue/catalogue-pages";
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueImportsPage } from "@/ui/admin/catalogue/catalogue-pages";

export const dynamic = "force-dynamic";

export default function Page() {
return <CatalogueImportRunsPage kind="specialisation" />;
export default function Page({ searchParams }: { searchParams: SearchParams }) {
return (
<CatalogueImportsPage kind="specialisation" searchParams={searchParams} />
);
}
2 changes: 1 addition & 1 deletion apps/web/app/admin/specialisations/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CatalogueLoading } from "@/ui/admin/catalogue-table/catalogue-loading";

export default function Loading() {
return <CatalogueLoading noun="specialisations" />;
return <CatalogueLoading noun="specialisations" layout="directory" />;
}
1 change: 1 addition & 0 deletions apps/web/lib/catalogue-import/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const COURSE_COLLECTIONS = [
] as const;

const STRUCTURE_COLLECTIONS = [
"summaryFields",
"sections",
"learningOutcomes",
"fees",
Expand Down
10 changes: 10 additions & 0 deletions apps/web/lib/catalogue-import/kind-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ export type MergeOutcome<Extraction> = {
warningCount: number;
errorCount: number;
report: unknown;
/**
* Set when the model output was discarded. The processor records it on the
* target so a snapshot built from deterministic parsing alone says so,
* rather than finishing `ready` with no error at all.
*/
errorCode?: string | null;
/** The reason, for `catalogue_extractions.error_summary`. */
errorSummary?: string | null;
};

/**
Expand Down Expand Up @@ -75,6 +83,8 @@ export type CatalogueKindAdapter<Extraction = unknown> = {
modelValid: boolean;
modelInput: string;
responseError: string | null;
/** The provider's stop reason; `length` means the response was truncated. */
finishReason: string | null;
}): MergeOutcome<Extraction>;
project(extraction: Extraction): CatalogueSnapshotWrite;
};
4 changes: 4 additions & 0 deletions apps/web/lib/catalogue-import/kinds/course/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ export const courseKindAdapter: CatalogueKindAdapter<CourseExtraction> = {
modelValid,
warningCount,
errorCount,
errorCode: modelValid ? null : "MODEL_OUTPUT_REJECTED",
errorSummary: modelValid
? null
: "The model response failed the strict course extraction contract; only deterministic parsing reached this snapshot.",
report: {
schemaValid: modelValid,
modelValidationIssues: result.modelValidationIssues,
Expand Down
78 changes: 65 additions & 13 deletions apps/web/lib/catalogue-import/kinds/structure/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ import {
convertAcademicStructureHtmlToMarkdown,
} from "./markdown.ts";
import {
ACADEMIC_STRUCTURE_MODEL_FIELDS,
academicStructureModelEvidenceIssues,
academicStructureModelFieldRoot,
mergeAcademicStructureExtractions,
normaliseAcademicStructureModelExtraction,
} from "./merge.ts";
import { academicStructureModelResponseError } from "./model-response-error.ts";
import { projectAcademicStructureSnapshot } from "./project.ts";
import {
ACADEMIC_STRUCTURE_IMPORT_MAX_OUTPUT_TOKENS,
Expand Down Expand Up @@ -103,6 +106,7 @@ export const structureKindAdapter: CatalogueKindAdapter<AcademicStructureExtract
modelValid,
modelInput,
responseError,
finishReason,
}) {
const normalised = normaliseAcademicStructureModelExtraction(model);
const validation = validateAcademicStructureExtraction(normalised.value, {
Expand All @@ -111,20 +115,57 @@ export const structureKindAdapter: CatalogueKindAdapter<AcademicStructureExtract
expectedYear: claim.academicYear,
evidenceMethod: "model",
});
// A truncated response reads as a cause rather than a bare finish
// reason, and travels to catalogue_extractions.error_summary.
const responseCause = academicStructureModelResponseError({
finishReason,
responseError,
});
const evidenceIssues = validation.success
? academicStructureModelEvidenceIssues(validation.data, modelInput)
: [];
const usable =
modelValid &&
validation.success &&
!responseError &&
evidenceIssues.length === 0;
const extraction = usable
? mergeAcademicStructureExtractions({
deterministic,
model: validation.data,
})
: deterministic;
// A response that did not finish, or that failed the contract, carries
// no field worth trusting. Anything else is judged field by field: one
// unsupported fee no longer costs the requirement tree, which matters
// because the deterministic fallback models that tree as a single
// free-text condition.
const discarded = !modelValid || !validation.success || !!responseCause;
const modelFields = ACADEMIC_STRUCTURE_MODEL_FIELDS as readonly string[];
const rejectedFields = new Set<string>(
discarded
? modelFields
: evidenceIssues
.map(({ fieldKey }) => academicStructureModelFieldRoot(fieldKey))
.filter((field) => modelFields.includes(field)),
);
const extraction =
discarded || !validation.success
? structuredClone(deterministic)
: mergeAcademicStructureExtractions({
deterministic,
model: validation.data,
rejectedFields,
});

if (discarded) {
extraction.reviewItems.push({
fieldKey: "modelExtraction",
kind: "invalid",
severity: "error",
message:
responseCause ??
"The model response failed the strict academic structure extraction contract; only deterministic parsing reached this snapshot.",
});
} else {
for (const field of rejectedFields) {
extraction.reviewItems.push({
fieldKey: field,
kind: "evidence_missing",
severity: "warning",
message: `The model supplied ${field} without wording from the selected-year source; the deterministic value was kept.`,
});
}
}
const warningCount = extraction.reviewItems.filter(
({ severity }) => severity === "warning",
).length;
Expand All @@ -133,17 +174,28 @@ export const structureKindAdapter: CatalogueKindAdapter<AcademicStructureExtract
).length;
return {
extraction,
modelValid: usable,
modelValid: !discarded,
warningCount,
errorCount,
errorCode: discarded ? "MODEL_OUTPUT_REJECTED" : null,
errorSummary: discarded
? (responseCause ??
"The model response failed strict extraction validation; deterministic data was retained.")
: null,
report: {
responseError,
responseCause,
finishReason,
schemaValid: validation.success,
schemaIssues: validation.success ? [] : validation.issues,
evidenceValid: evidenceIssues.length === 0,
evidenceIssues,
providerNormalisations: normalised.normalisations,
modelUsed: usable,
modelUsed: !discarded,
modelRejectedFields: [...rejectedFields].sort(),
modelAcceptedFields: discarded
? []
: modelFields.filter((field) => !rejectedFields.has(field)),
},
};
},
Expand Down
Binary file modified apps/web/lib/catalogue-import/kinds/structure/merge.ts
Binary file not shown.
20 changes: 20 additions & 0 deletions apps/web/lib/catalogue-import/persist-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,16 @@ async function insertStructureContent(
${details.contactText}
)
`;
for (const field of content.summaryFields) {
await tx`
insert into public.structure_snapshot_summary_fields (
snapshot_id, position, value_position, field_key, label, field_value, source_text
) values (
${snapshotId}, ${field.position}, ${field.valuePosition}, ${field.fieldKey},
${field.label}, ${field.fieldValue}, ${field.sourceText}
)
`;
}
for (const section of content.sections) {
await tx`
insert into public.academic_structure_snapshot_sections (
Expand Down Expand Up @@ -634,6 +644,16 @@ export async function persistSnapshotCandidate(
set draft_snapshot_id = ${snapshotId}
where id = ${claim.itemYearId}
`;
// A first import has nothing to compare against, so its changes are
// recorded as already accepted and the candidate becomes the draft
// without anyone pressing Apply. Recording that here keeps the target
// honest: it was applied, and leaving applied_snapshot_id null made a
// published record still read "Ready for review".
await tx`
update public.catalogue_import_targets
set applied_snapshot_id = ${snapshotId}, applied_at = now()
where id = ${claim.targetId}::uuid
`;
}
const changeKind: SnapshotChangeKind = becameDraft ? "new" : "changed";
return {
Expand Down
Loading
Loading