fix(global Search): duped indexes

This commit is contained in:
2026-06-27 15:38:53 +09:30
parent bdb1911e74
commit 6518999ac5
6 changed files with 253 additions and 25 deletions
@@ -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;