diff --git a/src/plugins/built-in/globalSearch/src/indexing/actions.ts b/src/plugins/built-in/globalSearch/src/indexing/actions.ts index b485b3a5..0b282ee2 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/actions.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/actions.ts @@ -353,9 +353,28 @@ export const actionMap: Record> = { const forumId = num("forumId") ?? num("forum"); const year = num("year"); const assessmentId = - num("assessmentId") ?? num("assessmentID") ?? num("id"); + num("assessmentId") ?? + num("assessmentID") ?? + num("entityId") ?? + num("id"); const messageId = num("messageId"); + const navigateToAssessment = (): void => { + if (programme !== undefined && metaclass !== undefined) { + const itemSuffix = + assessmentId !== undefined ? `&item=${assessmentId}` : ""; + navigateToHashRoute( + `/assessments/${programme}:${metaclass}${itemSuffix}`, + ); + return; + } + if (assessmentId !== undefined) { + navigateToHashRoute(`/assessments/upcoming&item=${assessmentId}`); + return; + } + navigateToHashRoute("/assessments/upcoming"); + }; + if (sourcePage === "/messages") { navigateInCurrentSeqtaApp("/messages"); return; @@ -369,19 +388,8 @@ export const actionMap: Record> = { } break; case "assessments": - if (programme !== undefined && metaclass !== undefined) { - const itemSuffix = - assessmentId !== undefined ? `&item=${assessmentId}` : ""; - navigateToHashRoute( - `/assessments/${programme}:${metaclass}${itemSuffix}`, - ); - return; - } - if (assessmentId !== undefined) { - navigateToHashRoute(`/assessments/upcoming&item=${assessmentId}`); - return; - } - navigateToHashRoute("/assessments/upcoming"); + case "assessment": + navigateToAssessment(); return; case "forums": case "forum": diff --git a/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts b/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts index e0310a11..4d77082d 100644 --- a/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts +++ b/src/plugins/built-in/globalSearch/src/indexing/passiveObserver.ts @@ -11,6 +11,7 @@ import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api"; import { mergeDynamicItems } from "../utils/dynamicItems"; import { decorateIndexItems } from "./renderComponents"; import { isIndexingPaused } from "./indexingPause"; +import { isAssessmentListRoute } from "./routeFilters"; /** * Passive network observer. @@ -298,6 +299,8 @@ function synthesizeItems( ctx: CapturedContext, payload: unknown, ): IndexItem[] { + if (isAssessmentListRoute(ctx.route)) return []; + const entities = entitiesFromPayload(payload); if (entities.length === 0) return []; diff --git a/src/plugins/built-in/globalSearch/src/indexing/routeFilters.test.ts b/src/plugins/built-in/globalSearch/src/indexing/routeFilters.test.ts new file mode 100644 index 00000000..c1c7e069 --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/indexing/routeFilters.test.ts @@ -0,0 +1,18 @@ +import { isAssessmentListRoute } from "./routeFilters"; + +describe("isAssessmentListRoute", () => { + it("matches past and upcoming assessment list routes", () => { + expect(isAssessmentListRoute("/seqta/student/assessment/list/past?")).toBe( + true, + ); + expect( + isAssessmentListRoute("/seqta/student/assessment/list/upcoming?"), + ).toBe(true); + }); + + it("does not match unrelated routes", () => { + expect(isAssessmentListRoute("/seqta/student/load/courses")).toBe(false); + expect(isAssessmentListRoute("/seqta/student/load/messages")).toBe(false); + expect(isAssessmentListRoute("/seqta/student/assessment/save")).toBe(false); + }); +}); diff --git a/src/plugins/built-in/globalSearch/src/indexing/routeFilters.ts b/src/plugins/built-in/globalSearch/src/indexing/routeFilters.ts new file mode 100644 index 00000000..b5bd4ab8 --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/indexing/routeFilters.ts @@ -0,0 +1,8 @@ +/** Routes already indexed by the assignments job — passive capture would duplicate them. */ +export function isAssessmentListRoute(route: string): boolean { + const normalized = route.toLowerCase(); + return ( + normalized.includes("/assessment/list/past") || + normalized.includes("/assessment/list/upcoming") + ); +} diff --git a/src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.test.ts b/src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.test.ts new file mode 100644 index 00000000..889b2999 --- /dev/null +++ b/src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.test.ts @@ -0,0 +1,123 @@ +import { + assessmentDestinationKey, + dedupeCombinedResultsByCourseNav, + dedupeIndexItemsForSearch, +} from "./dedupeIndexItems"; +import type { IndexItem } from "../indexing/types"; + +function makeItem(overrides: Partial & Pick): IndexItem { + return { + text: "SAT 1: Differential Calculus", + category: "assignments", + content: "Subject: Mathematical Methods", + dateAdded: 1, + metadata: {}, + actionId: "assessment", + renderComponentId: "assessment", + ...overrides, + }; +} + +describe("assessmentDestinationKey", () => { + it("keys curated assignment items by assessment id", () => { + const item = makeItem({ + id: "assignment-19748", + metadata: { assessmentId: 19748 }, + }); + expect(assessmentDestinationKey(item)).toBe("assessment:19748"); + }); + + it("keys passive past items by entity id", () => { + const item = makeItem({ + id: "passive-past-19748", + category: "past", + actionId: "passive", + renderComponentId: "passive", + metadata: { + entityId: 19748, + route: "/seqta/student/assessment/list/past", + source: "passive", + }, + }); + expect(assessmentDestinationKey(item)).toBe("assessment:19748"); + }); +}); + +describe("dedupeIndexItemsForSearch assessments", () => { + it("keeps curated assignment over passive past duplicate", () => { + const passive = makeItem({ + id: "passive-past-19748", + category: "past", + actionId: "passive", + renderComponentId: "passive", + dateAdded: 2, + metadata: { + entityId: 19748, + route: "/seqta/student/assessment/list/past", + source: "passive", + }, + }); + const curated = makeItem({ + id: "assignment-19748", + category: "assignments", + actionId: "assessment", + renderComponentId: "assessment", + dateAdded: 1, + metadata: { + assessmentId: 19748, + programmeId: 3705, + metaclassId: 10337, + }, + }); + + const result = dedupeIndexItemsForSearch([passive, curated]); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("assignment-19748"); + }); + + it("preserves unrelated items", () => { + const course = makeItem({ + id: "course-1", + category: "courses", + actionId: "course", + renderComponentId: "course", + metadata: { programmeId: 1, metaclassId: 2 }, + }); + const assignment = makeItem({ + id: "assignment-99", + metadata: { assessmentId: 99 }, + }); + + const result = dedupeIndexItemsForSearch([course, assignment]); + expect(result).toHaveLength(2); + }); +}); + +describe("dedupeCombinedResultsByCourseNav assessments", () => { + it("collapses hybrid results for the same assessment id", () => { + const passive = makeItem({ + id: "passive-past-19748", + category: "past", + actionId: "passive", + renderComponentId: "passive", + metadata: { + entityId: 19748, + route: "/seqta/student/assessment/list/past", + source: "passive", + }, + }); + const curated = makeItem({ + id: "assignment-19748", + metadata: { assessmentId: 19748, programmeId: 3705, metaclassId: 10337 }, + }); + + const results = dedupeCombinedResultsByCourseNav([ + { type: "dynamic", id: passive.id, score: 0.9, item: passive }, + { type: "dynamic", id: curated.id, score: 0.8, item: curated }, + ]); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe("assignment-19748"); + expect(results[0].score).toBe(0.9); + }); +}); diff --git a/src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.ts b/src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.ts index 970216c6..20004724 100644 --- a/src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.ts +++ b/src/plugins/built-in/globalSearch/src/search/dedupeIndexItems.ts @@ -42,12 +42,49 @@ export function courseDestinationKey(item: IndexItem): string | undefined { return `course:${programme}:${metaclass}`; } +function shouldDedupeAsSameAssessmentSPA(item: IndexItem): boolean { + if (item.actionId === "assessment") return true; + if (item.actionId !== "passive") return false; + + const md = item.metadata ?? {}; + const route = typeof md.route === "string" ? md.route.toLowerCase() : ""; + if (route.includes("/assessment/list/")) return true; + + const cat = item.category?.toLowerCase(); + return cat === "past" || cat === "upcoming"; +} + +export function assessmentDestinationKey(item: IndexItem): string | undefined { + if (!shouldDedupeAsSameAssessmentSPA(item)) return undefined; + const md = item.metadata ?? {}; + const assessmentId = toFiniteNumber( + md.assessmentId ?? md.assessmentID ?? md.entityId, + ); + if (assessmentId === undefined) return undefined; + return `assessment:${assessmentId}`; +} + +function searchDedupeKey(item: IndexItem): string | undefined { + return courseDestinationKey(item) ?? assessmentDestinationKey(item); +} + function isPassiveLike(item: IndexItem): boolean { return ( item.actionId === "passive" || item.metadata?.source === "passive" ); } +function hasProgrammeMetaclass(item: IndexItem): boolean { + const md = item.metadata ?? {}; + const programme = toFiniteNumber( + md.programme ?? md.programmeId ?? md.programmeID, + ); + const metaclass = toFiniteNumber( + md.metaclass ?? md.metaclassId ?? md.metaclassID ?? md.subjectId, + ); + return programme !== undefined && metaclass !== undefined; +} + function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem { const aP = isPassiveLike(a); const bP = isPassiveLike(b); @@ -65,20 +102,51 @@ function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem { return ad >= bd ? a : b; } +function pickBetterAssessmentDuplicate(a: IndexItem, b: IndexItem): IndexItem { + const aP = isPassiveLike(a); + const bP = isPassiveLike(b); + if (aP && !bP) return b; + if (!aP && bP) return a; + + if (a.category === "assignments" && b.category !== "assignments") return a; + if (b.category === "assignments" && a.category !== "assignments") return b; + + const aPm = hasProgrammeMetaclass(a); + const bPm = hasProgrammeMetaclass(b); + if (aPm && !bPm) return a; + if (!aPm && bPm) return b; + + const ad = typeof a.dateAdded === "number" ? a.dateAdded : 0; + const bd = typeof b.dateAdded === "number" ? b.dateAdded : 0; + return ad >= bd ? a : b; +} + +function pickBetterSearchDuplicate( + a: IndexItem, + b: IndexItem, + key: string, +): IndexItem { + if (key.startsWith("assessment:")) { + return pickBetterAssessmentDuplicate(a, b); + } + return pickBetterCourseNavDuplicate(a, b); +} + /** - * Collapses multiple index rows that open the same course hash route - * (e.g. `course` job + passive `/load/courses` capture) so search shows one hit. + * Collapses multiple index rows that open the same course or assessment hash + * route (e.g. `course` job + passive `/load/courses`, or assignments job + + * passive `/assessment/list/past`) so search shows one hit. */ export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] { const winners = new Map(); for (const item of items) { - const key = courseDestinationKey(item); + const key = searchDedupeKey(item); if (!key) continue; const prev = winners.get(key); winners.set( key, - prev ? pickBetterCourseNavDuplicate(prev, item) : item, + prev ? pickBetterSearchDuplicate(prev, item, key) : item, ); } @@ -86,7 +154,7 @@ export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] { const out: IndexItem[] = []; for (const item of items) { - const key = courseDestinationKey(item); + const key = searchDedupeKey(item); if (!key) { out.push(item); continue; @@ -99,14 +167,14 @@ export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] { return out; } -function dynamicCourseKey(row: CombinedResult): string | undefined { +function dynamicSearchKey(row: CombinedResult): string | undefined { if (row.type !== "dynamic") return undefined; - return courseDestinationKey(row.item as IndexItem); + return searchDedupeKey(row.item as IndexItem); } /** * Final pass after hybrid expansion: vector-only recall can still surface a - * second row for the same `/courses/P:M` SPA route using a stale passive id. + * second row for the same SPA route using a stale passive id. */ export function dedupeCombinedResultsByCourseNav( results: CombinedResult[], @@ -114,7 +182,7 @@ export function dedupeCombinedResultsByCourseNav( const best = new Map(); for (const r of results) { - const key = dynamicCourseKey(r); + const key = dynamicSearchKey(r); if (!key) continue; const prev = best.get(key); if (!prev) { @@ -123,7 +191,7 @@ export function dedupeCombinedResultsByCourseNav( } const aItem = prev.item as IndexItem; const bItem = r.item as IndexItem; - const winnerItem = pickBetterCourseNavDuplicate(aItem, bItem); + const winnerItem = pickBetterSearchDuplicate(aItem, bItem, key); const envelope = winnerItem.id === aItem.id ? prev : r; best.set(key, { ...envelope, @@ -137,7 +205,7 @@ export function dedupeCombinedResultsByCourseNav( const out: CombinedResult[] = []; for (const r of results) { - const key = dynamicCourseKey(r); + const key = dynamicSearchKey(r); if (!key) { out.push(r); continue;