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 forumId = num("forumId") ?? num("forum");
|
||||||
const year = num("year");
|
const year = num("year");
|
||||||
const assessmentId =
|
const assessmentId =
|
||||||
num("assessmentId") ?? num("assessmentID") ?? num("id");
|
num("assessmentId") ??
|
||||||
|
num("assessmentID") ??
|
||||||
|
num("entityId") ??
|
||||||
|
num("id");
|
||||||
const messageId = num("messageId");
|
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") {
|
if (sourcePage === "/messages") {
|
||||||
navigateInCurrentSeqtaApp("/messages");
|
navigateInCurrentSeqtaApp("/messages");
|
||||||
return;
|
return;
|
||||||
@@ -369,19 +388,8 @@ export const actionMap: Record<string, ActionHandler<any>> = {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "assessments":
|
case "assessments":
|
||||||
if (programme !== undefined && metaclass !== undefined) {
|
case "assessment":
|
||||||
const itemSuffix =
|
navigateToAssessment();
|
||||||
assessmentId !== undefined ? `&item=${assessmentId}` : "";
|
|
||||||
navigateToHashRoute(
|
|
||||||
`/assessments/${programme}:${metaclass}${itemSuffix}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (assessmentId !== undefined) {
|
|
||||||
navigateToHashRoute(`/assessments/upcoming&item=${assessmentId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigateToHashRoute("/assessments/upcoming");
|
|
||||||
return;
|
return;
|
||||||
case "forums":
|
case "forums":
|
||||||
case "forum":
|
case "forum":
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
|
|||||||
import { mergeDynamicItems } from "../utils/dynamicItems";
|
import { mergeDynamicItems } from "../utils/dynamicItems";
|
||||||
import { decorateIndexItems } from "./renderComponents";
|
import { decorateIndexItems } from "./renderComponents";
|
||||||
import { isIndexingPaused } from "./indexingPause";
|
import { isIndexingPaused } from "./indexingPause";
|
||||||
|
import { isAssessmentListRoute } from "./routeFilters";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Passive network observer.
|
* Passive network observer.
|
||||||
@@ -298,6 +299,8 @@ function synthesizeItems(
|
|||||||
ctx: CapturedContext,
|
ctx: CapturedContext,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
): IndexItem[] {
|
): IndexItem[] {
|
||||||
|
if (isAssessmentListRoute(ctx.route)) return [];
|
||||||
|
|
||||||
const entities = entitiesFromPayload(payload);
|
const entities = entitiesFromPayload(payload);
|
||||||
if (entities.length === 0) return [];
|
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}`;
|
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 {
|
function isPassiveLike(item: IndexItem): boolean {
|
||||||
return (
|
return (
|
||||||
item.actionId === "passive" || item.metadata?.source === "passive"
|
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 {
|
function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem {
|
||||||
const aP = isPassiveLike(a);
|
const aP = isPassiveLike(a);
|
||||||
const bP = isPassiveLike(b);
|
const bP = isPassiveLike(b);
|
||||||
@@ -65,20 +102,51 @@ function pickBetterCourseNavDuplicate(a: IndexItem, b: IndexItem): IndexItem {
|
|||||||
return ad >= bd ? a : b;
|
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
|
* Collapses multiple index rows that open the same course or assessment hash
|
||||||
* (e.g. `course` job + passive `/load/courses` capture) so search shows one hit.
|
* 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[] {
|
export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
|
||||||
const winners = new Map<string, IndexItem>();
|
const winners = new Map<string, IndexItem>();
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const key = courseDestinationKey(item);
|
const key = searchDedupeKey(item);
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
const prev = winners.get(key);
|
const prev = winners.get(key);
|
||||||
winners.set(
|
winners.set(
|
||||||
key,
|
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[] = [];
|
const out: IndexItem[] = [];
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const key = courseDestinationKey(item);
|
const key = searchDedupeKey(item);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
out.push(item);
|
out.push(item);
|
||||||
continue;
|
continue;
|
||||||
@@ -99,14 +167,14 @@ export function dedupeIndexItemsForSearch(items: IndexItem[]): IndexItem[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dynamicCourseKey(row: CombinedResult): string | undefined {
|
function dynamicSearchKey(row: CombinedResult): string | undefined {
|
||||||
if (row.type !== "dynamic") return 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
|
* 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(
|
export function dedupeCombinedResultsByCourseNav(
|
||||||
results: CombinedResult[],
|
results: CombinedResult[],
|
||||||
@@ -114,7 +182,7 @@ export function dedupeCombinedResultsByCourseNav(
|
|||||||
const best = new Map<string, CombinedResult>();
|
const best = new Map<string, CombinedResult>();
|
||||||
|
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
const key = dynamicCourseKey(r);
|
const key = dynamicSearchKey(r);
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
const prev = best.get(key);
|
const prev = best.get(key);
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
@@ -123,7 +191,7 @@ export function dedupeCombinedResultsByCourseNav(
|
|||||||
}
|
}
|
||||||
const aItem = prev.item as IndexItem;
|
const aItem = prev.item as IndexItem;
|
||||||
const bItem = r.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;
|
const envelope = winnerItem.id === aItem.id ? prev : r;
|
||||||
best.set(key, {
|
best.set(key, {
|
||||||
...envelope,
|
...envelope,
|
||||||
@@ -137,7 +205,7 @@ export function dedupeCombinedResultsByCourseNav(
|
|||||||
const out: CombinedResult[] = [];
|
const out: CombinedResult[] = [];
|
||||||
|
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
const key = dynamicCourseKey(r);
|
const key = dynamicSearchKey(r);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
out.push(r);
|
out.push(r);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
Reference in New Issue
Block a user