mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 17:21:05 +00:00
fix(global Search): duped indexes
This commit is contained in:
@@ -353,9 +353,28 @@ export const actionMap: Record<string, ActionHandler<any>> = {
|
||||
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<string, ActionHandler<any>> = {
|
||||
}
|
||||
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":
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
assessmentDestinationKey,
|
||||
dedupeCombinedResultsByCourseNav,
|
||||
dedupeIndexItemsForSearch,
|
||||
} from "./dedupeIndexItems";
|
||||
import type { IndexItem } from "../indexing/types";
|
||||
|
||||
function makeItem(overrides: Partial<IndexItem> & Pick<IndexItem, "id">): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, IndexItem>();
|
||||
|
||||
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<string, CombinedResult>();
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user