From 798256dc63a863a29f8328e7185fc9df54a0738d Mon Sep 17 00:00:00 2001 From: Pedro Pupo Sa da Costa Date: Tue, 6 Feb 2024 01:30:42 +0000 Subject: [PATCH] refactor: apply DRY using abstract classes :O --- .../core/search/src/entities/search-params.ts | 2 + libs/core/search/src/index.ts | 1 + .../question-bank-search-provider.ts | 151 ++++++++++++ .../search/src/question-bank/annex-search.ts | 163 ++++-------- .../search/src/question-bank/doc-search.ts | 173 ++++--------- .../learning-objective-search.ts | 199 ++++++--------- .../src/question-bank/question-search.ts | 231 ++++++------------ .../types/question-bank-search-provider.ts | 25 -- .../src/routers/common/search-router.ts | 36 ++- .../containers/learning-objectives-router.ts | 6 +- 10 files changed, 427 insertions(+), 560 deletions(-) create mode 100644 libs/providers/search/src/abstract-providers/question-bank-search-provider.ts delete mode 100644 libs/providers/search/src/types/question-bank-search-provider.ts diff --git a/libs/core/search/src/entities/search-params.ts b/libs/core/search/src/entities/search-params.ts index 93bd78cbd..8ea9d851c 100644 --- a/libs/core/search/src/entities/search-params.ts +++ b/libs/core/search/src/entities/search-params.ts @@ -5,3 +5,5 @@ export const searchParams = z.object({ limit: z.number().min(1).max(50), cursor: z.number().default(0), }); + +export type SearchParams = z.infer; diff --git a/libs/core/search/src/index.ts b/libs/core/search/src/index.ts index f9788b601..9eaed3cba 100644 --- a/libs/core/search/src/index.ts +++ b/libs/core/search/src/index.ts @@ -1,3 +1,4 @@ +export * from "./entities/search-params"; export * from "./entities/annex-search"; export * from "./entities/doc-search"; export * from "./entities/learning-objective-search"; diff --git a/libs/providers/search/src/abstract-providers/question-bank-search-provider.ts b/libs/providers/search/src/abstract-providers/question-bank-search-provider.ts new file mode 100644 index 000000000..3e44328af --- /dev/null +++ b/libs/providers/search/src/abstract-providers/question-bank-search-provider.ts @@ -0,0 +1,151 @@ +import { default as MiniSearch } from "minisearch"; +import type { SearchParams as ISearchParams } from "@chair-flight/core/search"; +import type { QuestionBank } from "@chair-flight/providers/question-bank"; +import type { SearchOptions } from "minisearch"; + +type Filter = Array<{ id: string; text: string }>; + +export abstract class QuestionBankSearchProvider< + SearchDocument extends { id: string; [k: string]: string }, + SearchResult extends { id: string }, + SearchFilters extends { searchField: string }, + SearchParams extends ISearchParams & { filters: SearchFilters }, +> { + private searchIndex: MiniSearch; + private searchResults = new Map(); + private initializationWork = new Map>(); + private idSearchFields: (keyof SearchDocument)[]; + + constructor({ + idSearchFields, + searchFields, + storeFields, + }: { + idSearchFields: (keyof SearchDocument)[]; + searchFields: (keyof SearchDocument)[]; + storeFields: (keyof SearchDocument)[]; + }) { + this.idSearchFields = idSearchFields; + this.searchIndex = new MiniSearch({ + fields: searchFields as string[], + storeFields: storeFields as string[], + tokenize: (text, fieldName) => { + if (idSearchFields.includes(fieldName as keyof SearchDocument)) { + return text.split(", "); + } + return MiniSearch.getDefault("tokenize")(text); + }, + }); + } + + private async initializeSearchResults(bank: QuestionBank) { + const resultItems = await this.getResultItems(bank); + const firstId = resultItems[0]?.id; + if (!firstId || this.searchResults.get(firstId)) return; + resultItems.forEach((item) => this.searchResults.set(item.id, item)); + } + + private async initializeSearchIndex(bank: QuestionBank) { + const thisWork = this.initializationWork.get(bank); + if (thisWork) return await thisWork; + + const newWork = (async () => { + const searchDocuments = await this.getSearchDocuments(bank); + const firstId = searchDocuments[0]?.id; + if (!firstId || this.searchIndex.has(firstId)) return; + await this.searchIndex.addAll(searchDocuments); + })(); + this.initializationWork.set(bank, newWork); + await newWork; + } + + public async search( + bank: QuestionBank, + params: SearchParams, + ): Promise<{ + items: SearchResult[]; + totalResults: number; + nextCursor: number; + }> { + const results = await (async () => { + if (!params.q) { + // Kickstart the indexing processing but dont await it + void this.initializeSearchIndex(bank); + await this.initializeSearchResults(bank); + return Array.from(this.searchResults.values()); + } else { + await this.initializeSearchIndex(bank); + await this.initializeSearchResults(bank); + + const searchField = params.filters.searchField; + const isFuzzy = this.idSearchFields.includes( + searchField as keyof SearchDocument, + ); + + const opts: SearchOptions = { + fuzzy: isFuzzy ? false : 0.2, + prefix: !isFuzzy, + fields: searchField === "all" ? undefined : [searchField as string], + tokenize: (text) => { + if ( + this.idSearchFields.includes(searchField as keyof SearchDocument) + ) { + return text.split(", "); + } + return MiniSearch.getDefault("tokenize")(text); + }, + }; + + return this.searchIndex + .search(params.q, opts) + .map(({ id }) => this.searchResults.get(id)); + } + })(); + + const processedResults = results.filter(this.getSearchResultFilter(params)); + const finalItems = processedResults.slice( + params.cursor, + params.cursor + params.limit, + ); + + return { + items: finalItems, + totalResults: processedResults.length, + nextCursor: params.cursor + finalItems.length, + }; + } + + public async retrieve( + bank: QuestionBank, + ids: string[], + ): Promise<{ + items: SearchResult[]; + }> { + await this.initializeSearchResults(bank); + + const items = ids.map((id) => this.searchResults.get(id)).filter(Boolean); + + return { items }; + } + + public async initialize(bank: QuestionBank) { + await this.initializeSearchIndex(bank); + await this.initializeSearchResults(bank); + } + + public abstract getFilters(bank: QuestionBank): Promise<{ + filters: Record; + }>; + + protected abstract getResultItems( + bank: QuestionBank, + ): Promise; + + protected abstract getSearchDocuments( + bank: QuestionBank, + ): Promise; + + protected abstract getSearchResultFilter( + params: SearchParams, + ): (r: SearchResult | undefined) => r is SearchResult; +} diff --git a/libs/providers/search/src/question-bank/annex-search.ts b/libs/providers/search/src/question-bank/annex-search.ts index ca305922d..7a8ca9646 100644 --- a/libs/providers/search/src/question-bank/annex-search.ts +++ b/libs/providers/search/src/question-bank/annex-search.ts @@ -1,96 +1,29 @@ -import { default as MiniSearch } from "minisearch"; -import type { QuestionBankSearchProvider } from "../types/question-bank-search-provider"; +import { QuestionBankSearchProvider } from "../abstract-providers/question-bank-search-provider"; import type { AnnexSearchResult, AnnexSearchParams, AnnexSearchFilters, } from "@chair-flight/core/search"; import type { QuestionBank } from "@chair-flight/providers/question-bank"; -import type { SearchOptions } from "minisearch"; type AnnexSearchField = "id" | "description"; type AnnexSearchDocument = Record; -export class AnnexSearch - implements - QuestionBankSearchProvider< - AnnexSearchResult, - AnnexSearchParams, - AnnexSearchFilters - > -{ - private static initializationWork: Promise | undefined; - private static searchResults = new Map(); - private static idSearchFields: AnnexSearchField[] = ["id"]; - - private static searchIndex = new MiniSearch({ - fields: ["id", "description"] satisfies AnnexSearchField[], - storeFields: ["id"] satisfies AnnexSearchField[], - }); - - async search(params: AnnexSearchParams) { - if (AnnexSearch.initializationWork) await AnnexSearch.initializationWork; - - const isFuzzy = AnnexSearch.idSearchFields.includes( - params.filters.searchField, - ); - - const opts: SearchOptions = { - fuzzy: isFuzzy ? false : 0.2, - prefix: !isFuzzy, - fields: - params.filters.searchField === "all" - ? undefined - : [params.filters.searchField], - }; - - const results = params.q - ? AnnexSearch.searchIndex - .search(params.q, opts) - .map(({ id }) => AnnexSearch.searchResults.get(id)) - : Array.from(AnnexSearch.searchResults.values()); - - const processedResults = results.filter( - (result): result is AnnexSearchResult => { - if (!result) { - return false; - } - - if (result.questionBank !== params.questionBank) { - return false; - } - - if (params.filters.subject !== "all") { - if (!result.subjects.includes(params.filters.subject)) return false; - } - - return true; - }, - ); - - const finalItems = processedResults.slice( - params.cursor, - params.cursor + params.limit, - ); - - return { - items: finalItems, - totalResults: processedResults.length, - nextCursor: params.cursor + finalItems.length, - }; - } - - async retrieve(ids: string[]) { - if (AnnexSearch.initializationWork) await AnnexSearch.initializationWork; - - const items = ids - .map((id) => AnnexSearch.searchResults.get(id)) - .filter((r): r is AnnexSearchResult => !!r); - - return { items }; +export class AnnexSearch extends QuestionBankSearchProvider< + AnnexSearchDocument, + AnnexSearchResult, + AnnexSearchFilters, + AnnexSearchParams +> { + constructor() { + super({ + idSearchFields: ["id"], + searchFields: ["description"], + storeFields: ["id"], + }); } - async getFilters(bank: QuestionBank) { + public override async getFilters(bank: QuestionBank) { const rawSubjects = await bank.getAll("subjects"); const subject = [ @@ -112,40 +45,48 @@ export class AnnexSearch }; } - async initialize(bank: QuestionBank) { - if (AnnexSearch.initializationWork) await AnnexSearch.initializationWork; + protected override async getResultItems(bank: QuestionBank) { + const annexes = await bank.getAll("annexes"); + return annexes.map((annex) => ({ + id: annex.id, + href: annex.href, + description: annex.description, + subjects: annex.subjects, + questionBank: bank.getName(), + questions: annex.questions.map((id) => ({ + id, + href: `/modules/${bank.getName()}/questions/${id}`, + })), + learningObjectives: annex.learningObjectives.map((id) => ({ + id, + href: `/modules/${bank.getName()}/learning-objectives/${id}`, + })), + })); + } - AnnexSearch.initializationWork = (async () => { - const annexes = await bank.getAll("annexes"); - const hasAnnexes = await bank.has("annexes"); - const firstId = annexes.at(0)?.id; + protected override async getSearchDocuments(bank: QuestionBank) { + const annexes = await bank.getAll("annexes"); + return annexes.map((annex) => ({ + id: annex.id, + description: annex.description, + })); + } - if (!hasAnnexes) return; - if (AnnexSearch.searchIndex.has(firstId)) return; + protected override getSearchResultFilter(params: AnnexSearchParams) { + return (r: AnnexSearchResult | undefined): r is AnnexSearchResult => { + if (!r) { + return false; + } - const searchItems: AnnexSearchDocument[] = annexes.map((annex) => ({ - id: annex.id, - description: annex.description, - })); + if (r.questionBank !== params.questionBank) { + return false; + } - const resultItems: AnnexSearchResult[] = annexes.map((annex) => ({ - id: annex.id, - href: annex.href, - description: annex.description, - subjects: annex.subjects, - questionBank: bank.getName(), - questions: annex.questions.map((id) => ({ - id, - href: `/modules/${bank.getName()}/questions/${id}`, - })), - learningObjectives: annex.learningObjectives.map((id) => ({ - id, - href: `/modules/${bank.getName()}/learning-objectives/${id}`, - })), - })); + if (params.filters.subject !== "all") { + if (!r.subjects.includes(params.filters.subject)) return false; + } - await AnnexSearch.searchIndex.addAllAsync(searchItems); - resultItems.forEach((r) => AnnexSearch.searchResults.set(r.id, r)); - })(); + return true; + }; } } diff --git a/libs/providers/search/src/question-bank/doc-search.ts b/libs/providers/search/src/question-bank/doc-search.ts index 9cb7b4c23..427fbc15c 100644 --- a/libs/providers/search/src/question-bank/doc-search.ts +++ b/libs/providers/search/src/question-bank/doc-search.ts @@ -1,104 +1,29 @@ -import { default as MiniSearch } from "minisearch"; -import type { QuestionBankSearchProvider } from "../types/question-bank-search-provider"; +import { QuestionBankSearchProvider } from "../abstract-providers/question-bank-search-provider"; import type { DocSearchFilters, DocSearchParams, DocSearchResult, } from "@chair-flight/core/search"; import type { QuestionBank } from "@chair-flight/providers/question-bank"; -import type { SearchOptions } from "minisearch"; -export type DocSearchField = "id" | "learningObjectiveId" | "content" | "title"; -export type DocSearchDocument = Record; +type DocSearchField = "id" | "learningObjectiveId" | "content" | "title"; +type DocSearchDocument = Record; -export class DocSearch - implements - QuestionBankSearchProvider< - DocSearchResult, - DocSearchParams, - DocSearchFilters - > -{ - private static initializationWork: Promise | undefined; - private static searchResults: Map = new Map(); - private static idSearchFields: DocSearchField[] = [ - "id", - "learningObjectiveId", - ]; - - private static searchIndex = new MiniSearch({ - fields: [ - "id", - "learningObjectiveId", - "content", - "title", - ] satisfies DocSearchField[], - storeFields: ["id"] satisfies DocSearchField[], - }); - - async search(params: DocSearchParams) { - if (DocSearch.initializationWork) await DocSearch.initializationWork; - - const isFuzzy = DocSearch.idSearchFields.includes( - params.filters.searchField, - ); - - const opts: SearchOptions = { - fuzzy: isFuzzy ? false : 0.2, - prefix: !isFuzzy, - fields: - params.filters.searchField === "all" - ? undefined - : [params.filters.searchField], - }; - - const results = params.q - ? DocSearch.searchIndex - .search(params.q, opts) - .map(({ id }) => DocSearch.searchResults.get(id)) - : Array.from(DocSearch.searchResults.values()); - - const processedResults = results.filter( - (result): result is DocSearchResult => { - if (!result) { - return false; - } - - if (result.questionBank !== params.questionBank) { - return false; - } - - if (params.filters.subject !== "all") { - if (result.subject !== params.filters.subject) return false; - } - - return true; - }, - ); - - const finalItems = processedResults.slice( - params.cursor, - params.cursor + params.limit, - ); - - return { - items: finalItems, - totalResults: processedResults.length, - nextCursor: params.cursor + finalItems.length, - }; - } - - async retrieve(ids: string[]) { - if (DocSearch.initializationWork) await DocSearch.initializationWork; - - const items = ids - .map((id) => DocSearch.searchResults.get(id)) - .filter((r): r is DocSearchResult => !!r); - - return { items }; +export class DocSearch extends QuestionBankSearchProvider< + DocSearchDocument, + DocSearchResult, + DocSearchFilters, + DocSearchParams +> { + constructor() { + super({ + idSearchFields: ["id", "learningObjectiveId"], + searchFields: ["id", "learningObjectiveId", "content", "title"], + storeFields: ["id"], + }); } - async getFilters(bank: QuestionBank) { + public override async getFilters(bank: QuestionBank) { const rawSubjects = await bank.getAll("subjects"); const subject = [ @@ -121,39 +46,47 @@ export class DocSearch }; } - async initialize(bank: QuestionBank) { - if (DocSearch.initializationWork) await DocSearch.initializationWork; + protected override async getResultItems(bank: QuestionBank) { + const docs = await bank.getAll("docs"); + return docs.map((doc) => ({ + id: doc.id, + questionBank: bank.getName(), + title: doc.title, + empty: doc.empty, + subject: doc.subjectId, + href: `/modules/${bank.getName()}/docs/${doc.id}`, + learningObjective: { + id: doc.learningObjectiveId, + href: `/modules/${bank.getName()}/learning-objectives/${doc.learningObjectiveId}`, + }, + })); + } - DocSearch.initializationWork = (async () => { - const docs = await bank.getAll("docs"); - const hasDocs = await bank.has("docs"); - const firstId = docs.at(0)?.id; + protected override async getSearchDocuments(bank: QuestionBank) { + const docs = await bank.getAll("docs"); + return docs.map((doc) => ({ + id: doc.id, + learningObjectiveId: doc.learningObjectiveId, + content: doc.content, + title: doc.title, + })); + } - if (!hasDocs) return; - if (DocSearch.searchIndex.has(firstId)) return; + protected override getSearchResultFilter(params: DocSearchParams) { + return (r: DocSearchResult | undefined): r is DocSearchResult => { + if (!r) { + return false; + } - const searchItems: DocSearchDocument[] = docs.flatMap((doc) => ({ - id: doc.id, - learningObjectiveId: doc.learningObjectiveId, - content: doc.content, - title: doc.title, - })); + if (r.questionBank !== params.questionBank) { + return false; + } - const resultItems: DocSearchResult[] = docs.flatMap((doc) => ({ - id: doc.id, - questionBank: bank.getName(), - title: doc.title, - empty: doc.empty, - subject: doc.subjectId, - href: `/modules/${bank.getName()}/docs/${doc.id}`, - learningObjective: { - id: doc.learningObjectiveId, - href: `/modules/${bank.getName()}/learning-objectives/${doc.learningObjectiveId}`, - }, - })); + if (params.filters.subject !== "all") { + if (r.subject !== params.filters.subject) return false; + } - await DocSearch.searchIndex.addAllAsync(searchItems); - resultItems.forEach((r) => DocSearch.searchResults.set(r.id, r)); - })(); + return true; + }; } } diff --git a/libs/providers/search/src/question-bank/learning-objective-search.ts b/libs/providers/search/src/question-bank/learning-objective-search.ts index 53b2674fb..22daf8adc 100644 --- a/libs/providers/search/src/question-bank/learning-objective-search.ts +++ b/libs/providers/search/src/question-bank/learning-objective-search.ts @@ -1,110 +1,33 @@ -import { default as MiniSearch } from "minisearch"; import { makeMap } from "@chair-flight/base/utils"; -import type { QuestionBankSearchProvider } from "../types/question-bank-search-provider"; +import { QuestionBankSearchProvider } from "../abstract-providers/question-bank-search-provider"; import type { LearningObjectiveSearchFilters, LearningObjectiveSearchParams, LearningObjectiveSearchResult, } from "@chair-flight/core/search"; import type { QuestionBank } from "@chair-flight/providers/question-bank"; -import type { SearchOptions } from "minisearch"; -type LoSearchField = "id" | "text"; -type LoSearchDocument = Record; +type LearningObjectiveSearchField = "id" | "text"; +type LearningObjectiveSearchDocument = Record< + LearningObjectiveSearchField, + string +>; -export class LearningObjectiveSearch - implements - QuestionBankSearchProvider< - LearningObjectiveSearchResult, - LearningObjectiveSearchParams, - LearningObjectiveSearchFilters - > -{ - private static initializationWork: Promise | undefined; - private static searchResults = new Map< - string, - LearningObjectiveSearchResult - >(); - private static idSearchFields: LoSearchField[] = ["id"]; - - private static searchIndex = new MiniSearch({ - fields: ["id", "text"] as LoSearchField[], - storeFields: ["id"] as LoSearchField[], - }); - - async search(params: LearningObjectiveSearchParams) { - if (LearningObjectiveSearch.initializationWork) - await LearningObjectiveSearch.initializationWork; - - const isFuzzy = LearningObjectiveSearch.idSearchFields.includes( - params.filters.searchField, - ); - - const opts: SearchOptions = { - fuzzy: isFuzzy ? false : 0.2, - prefix: !isFuzzy, - fields: - params.filters.searchField === "all" - ? undefined - : [params.filters.searchField], - }; - - const results = params.q - ? LearningObjectiveSearch.searchIndex - .search(params.q, opts) - .map(({ id }) => LearningObjectiveSearch.searchResults.get(id)) - : Array.from(LearningObjectiveSearch.searchResults.values()); - - const processedResults = results.filter( - (result): result is LearningObjectiveSearchResult => { - if (!result) { - return false; - } - - if (result.questionBank !== params.questionBank) { - return false; - } - - if (params.filters.subject !== "all") { - if (result.subject !== params.filters.subject) { - return false; - } - } - - if (params.filters.course !== "all") { - if (result.courses.every((c) => c.id !== params.filters.course)) { - return false; - } - } - - return true; - }, - ); - - const finalItems = processedResults.slice( - params.cursor, - params.cursor + params.limit, - ); - - return { - items: finalItems, - totalResults: processedResults.length, - nextCursor: params.cursor + finalItems.length, - }; - } - - async retrieve(ids: string[]) { - if (LearningObjectiveSearch.initializationWork) - await LearningObjectiveSearch.initializationWork; - - const items = ids - .map((id) => LearningObjectiveSearch.searchResults.get(id)) - .filter((r): r is LearningObjectiveSearchResult => !!r); - - return { items }; +export class LearningObjectiveSearch extends QuestionBankSearchProvider< + LearningObjectiveSearchDocument, + LearningObjectiveSearchResult, + LearningObjectiveSearchFilters, + LearningObjectiveSearchParams +> { + constructor() { + super({ + idSearchFields: ["id"], + searchFields: ["id", "text"], + storeFields: ["id"], + }); } - async getFilters(bank: QuestionBank) { + public override async getFilters(bank: QuestionBank) { const rawSubjects = await bank.getAll("subjects"); const rawCourses = await bank.getAll("courses"); @@ -136,43 +59,59 @@ export class LearningObjectiveSearch }; } - async initialize(bank: QuestionBank) { - if (LearningObjectiveSearch.initializationWork) - await LearningObjectiveSearch.initializationWork; - - LearningObjectiveSearch.initializationWork = (async () => { - const allCourses = await bank.getAll("courses"); - const learningObjectives = await bank.getAll("learningObjectives"); - const hasLearningObjectives = await bank.has("learningObjectives"); - const firstId = learningObjectives.at(0)?.id; - - if (!hasLearningObjectives) return; - if (LearningObjectiveSearch.searchIndex.has(firstId)) return; + protected override async getResultItems(bank: QuestionBank) { + const allCourses = await bank.getAll("courses"); + const learningObjectives = await bank.getAll("learningObjectives"); + const coursesMap = makeMap(allCourses, (c) => c.id); + + return learningObjectives.map((lo) => ({ + id: lo.id, + href: `/modules/${bank.getName()}/learning-objectives/${lo.id}`, + parentId: lo.parentId, + courses: lo.courses.map((c) => coursesMap[c]), + text: lo.text, + source: lo.source, + questionBank: bank.getName(), + subject: lo.id.split(".")[0], + numberOfQuestions: lo.nestedQuestions.length, + })); + } - const coursesMap = makeMap(allCourses, (c) => c.id); + protected override async getSearchDocuments(bank: QuestionBank) { + const learningObjectives = await bank.getAll("learningObjectives"); + return learningObjectives.map((lo) => ({ + id: lo.id, + text: lo.text, + })); + } - const searchItems: LoSearchDocument[] = learningObjectives.map((lo) => ({ - id: lo.id, - text: lo.text, - })); + protected override getSearchResultFilter( + params: LearningObjectiveSearchParams, + ) { + return ( + r: LearningObjectiveSearchResult | undefined, + ): r is LearningObjectiveSearchResult => { + if (!r) { + return false; + } + + if (r.questionBank !== params.questionBank) { + return false; + } + + if (params.filters.subject !== "all") { + if (r.subject !== params.filters.subject) { + return false; + } + } - const resultItems: LearningObjectiveSearchResult[] = - learningObjectives.map((lo) => ({ - id: lo.id, - href: `/modules/${bank.getName()}/learning-objectives/${lo.id}`, - parentId: lo.parentId, - courses: lo.courses.map((c) => coursesMap[c]), - text: lo.text, - source: lo.source, - questionBank: bank.getName(), - subject: lo.id.split(".")[0], - numberOfQuestions: lo.nestedQuestions.length, - })); + if (params.filters.course !== "all") { + if (r.courses.every((c) => c.id !== params.filters.course)) { + return false; + } + } - await LearningObjectiveSearch.searchIndex.addAllAsync(searchItems); - resultItems.forEach((item) => - LearningObjectiveSearch.searchResults.set(item.id, item), - ); - })(); + return true; + }; } } diff --git a/libs/providers/search/src/question-bank/question-search.ts b/libs/providers/search/src/question-bank/question-search.ts index 49f3baf08..5273f0c08 100644 --- a/libs/providers/search/src/question-bank/question-search.ts +++ b/libs/providers/search/src/question-bank/question-search.ts @@ -1,13 +1,11 @@ -import MiniSearch from "minisearch"; import { getQuestionPreview } from "@chair-flight/core/question-bank"; -import type { QuestionBankSearchProvider } from "../types/question-bank-search-provider"; +import { QuestionBankSearchProvider } from "../abstract-providers/question-bank-search-provider"; import type { QuestionSearchFilters, QuestionSearchParams, QuestionSearchResult, } from "@chair-flight/core/search"; import type { QuestionBank } from "@chair-flight/providers/question-bank"; -import type { SearchOptions } from "minisearch"; export type QuestionSearchField = | "id" @@ -20,107 +18,29 @@ export type QuestionSearchField = export type QuestionSearchDocument = Record; -export class QuestionSearch - implements - QuestionBankSearchProvider< - QuestionSearchResult, - QuestionSearchParams, - QuestionSearchFilters - > -{ - private static initializationWork: Promise | undefined; - private static searchResults = new Map(); - private static idSearchFields: QuestionSearchField[] = [ - "id", - "questionId", - "learningObjectives", - ]; - - private static searchIndex = new MiniSearch({ - fields: [ - "id", - "questionId", - "questionBank", - "subjects", - "learningObjectives", - "externalIds", - "text", - ], - storeFields: ["id"], - }); - - async search(params: QuestionSearchParams) { - if (QuestionSearch.initializationWork) - await QuestionSearch.initializationWork; - - const isFuzzy = QuestionSearch.idSearchFields.includes( - params.filters.searchField, - ); - - const opts: SearchOptions = { - fuzzy: isFuzzy ? false : 0.2, - prefix: !isFuzzy, - fields: - params.filters.searchField === "all" - ? undefined - : [params.filters.searchField], - tokenize: (s) => { - if (params.filters.searchField === "learningObjectives") - return s.split(", "); - return MiniSearch.getDefault("tokenize")(s); - }, - }; - - const results = params.q - ? QuestionSearch.searchIndex - .search(params.q, opts) - .map(({ id }) => QuestionSearch.searchResults.get(id)) - : Array.from(QuestionSearch.searchResults.values()); - - const processedResults = results.filter( - (result): result is QuestionSearchResult => { - if (!result) { - return false; - } - - if (result.questionBank !== params.questionBank) { - return false; - } - - if (params.filters.subject !== "all") { - if (!result.subjects.includes(params.filters.subject)) { - return false; - } - } - - return true; - }, - ); - - const finalItems = processedResults.slice( - params.cursor, - params.cursor + params.limit, - ); - - return { - items: finalItems, - totalResults: processedResults.length, - nextCursor: params.cursor + finalItems.length, - }; - } - - async retrieve(ids: string[]) { - if (QuestionSearch.initializationWork) - await QuestionSearch.initializationWork; - - const items = ids - .map((id) => QuestionSearch.searchResults.get(id)) - .filter((r): r is QuestionSearchResult => !!r); - - return { items }; +export class QuestionSearch extends QuestionBankSearchProvider< + QuestionSearchDocument, + QuestionSearchResult, + QuestionSearchFilters, + QuestionSearchParams +> { + constructor() { + super({ + idSearchFields: ["id", "questionId", "learningObjectives"], + searchFields: [ + "id", + "questionId", + "questionBank", + "subjects", + "learningObjectives", + "externalIds", + "text", + ], + storeFields: ["id"], + }); } - async getFilters(bank: QuestionBank) { + public override async getFilters(bank: QuestionBank) { const rawSubjects = await bank.getAll("subjects"); const subject = [ @@ -144,58 +64,69 @@ export class QuestionSearch }; } - async initialize(bank: QuestionBank) { - if (QuestionSearch.initializationWork) - await QuestionSearch.initializationWork; - - QuestionSearch.initializationWork = (async () => { - const questions = await bank.getAll("questions"); - const hasQuestions = await bank.has("questions"); - const firstId = questions.at(0)?.id; - - if (!hasQuestions) return; - if (QuestionSearch.searchIndex.has(firstId)) return; - - const searchItems: QuestionSearchDocument[] = questions.flatMap( - (question) => { - const subjects = question.learningObjectives.map( - (l) => l.split(".")[0], - ); - const los = question.learningObjectives.join(", "); - const uniqueSubjects = [...new Set(subjects)]; - return Object.values(question.variants).map((variant) => ({ - id: variant.id, - questionId: question.id, - questionBank: bank.getName(), - subjects: uniqueSubjects.join(", "), - learningObjectives: los, - externalIds: variant.externalIds.join(", "), - text: getQuestionPreview(question, variant.id), - })); - }, - ); + protected override async getResultItems(bank: QuestionBank) { + const questions = await bank.getAll("questions"); + const resultItems: QuestionSearchResult[] = questions.flatMap((q) => { + const subjects = q.learningObjectives.map((l) => l.split(".")[0]); + const uniqueSubjects = [...new Set(subjects)]; + return Object.values(q.variants).map((v) => ({ + questionBank: bank.getName(), + id: v.id, + questionId: q.id, + variantId: v.id, + text: getQuestionPreview(q, v.id), + subjects: uniqueSubjects, + learningObjectives: q.learningObjectives.map((name) => ({ + name, + href: `/modules/${bank.getName()}/learning-objectives/${name}`, + })), + externalIds: v.externalIds, + href: `/modules/${bank.getName()}/questions/${q.id}?variantId=${v.id}`, + })); + }); + + return resultItems; + } - const resultItems: QuestionSearchResult[] = questions.flatMap((q) => { - const subjects = q.learningObjectives.map((l) => l.split(".")[0]); + protected override async getSearchDocuments(bank: QuestionBank) { + const questions = await bank.getAll("questions"); + const searchItems: QuestionSearchDocument[] = questions.flatMap( + (question) => { + const subjects = question.learningObjectives.map( + (l) => l.split(".")[0], + ); + const los = question.learningObjectives.join(", "); const uniqueSubjects = [...new Set(subjects)]; - return Object.values(q.variants).map((v) => ({ + return Object.values(question.variants).map((variant) => ({ + id: variant.id, + questionId: question.id, questionBank: bank.getName(), - id: v.id, - questionId: q.id, - variantId: v.id, - text: getQuestionPreview(q, v.id), - subjects: uniqueSubjects, - learningObjectives: q.learningObjectives.map((name) => ({ - name, - href: `/modules/${bank.getName()}/learning-objectives/${name}`, - })), - externalIds: v.externalIds, - href: `/modules/${bank.getName()}/questions/${q.id}?variantId=${v.id}`, + subjects: uniqueSubjects.join(", "), + learningObjectives: los, + externalIds: variant.externalIds.join(", "), + text: getQuestionPreview(question, variant.id), })); - }); + }, + ); + + return searchItems; + } - await QuestionSearch.searchIndex.addAllAsync(searchItems); - resultItems.forEach((r) => QuestionSearch.searchResults.set(r.id, r)); - })(); + protected override getSearchResultFilter(params: QuestionSearchParams) { + return (r: QuestionSearchResult | undefined): r is QuestionSearchResult => { + if (!r) { + return false; + } + + if (r.questionBank !== params.questionBank) { + return false; + } + + if (params.filters.subject !== "all") { + if (!r.subjects.includes(params.filters.subject)) return false; + } + + return true; + }; } } diff --git a/libs/providers/search/src/types/question-bank-search-provider.ts b/libs/providers/search/src/types/question-bank-search-provider.ts deleted file mode 100644 index e23bc3b1e..000000000 --- a/libs/providers/search/src/types/question-bank-search-provider.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { QuestionBank } from "@chair-flight/providers/question-bank"; - -type Filter = Array<{ id: string; text: string }>; - -export interface QuestionBankSearchProvider< - SearchResult extends { id: string }, - SearchParams extends { q: string }, - SearchFilters, -> { - search: (params: SearchParams) => Promise<{ - items: SearchResult[]; - totalResults: number; - nextCursor: number; - }>; - - retrieve: (ids: string[]) => Promise<{ - items: SearchResult[]; - }>; - - getFilters: (bank: QuestionBank) => Promise<{ - filters: Record; - }>; - - initialize: (bank: QuestionBank) => Promise; -} diff --git a/libs/trpc/server/src/routers/common/search-router.ts b/libs/trpc/server/src/routers/common/search-router.ts index dce6ef5c0..8a782442c 100644 --- a/libs/trpc/server/src/routers/common/search-router.ts +++ b/libs/trpc/server/src/routers/common/search-router.ts @@ -13,44 +13,40 @@ import { } from "../../common/providers"; import { publicProcedure, router } from "../../config/trpc"; -const initialize = async () => { - for (const bank of Object.values(questionBanks)) { - await Promise.all([ - learningObjectiveSearch.initialize(bank), - annexSearch.initialize(bank), - questionSearch.initialize(bank), - docSearch.initialize(bank), - ]); - } -}; - export const searchRouter = router({ initialize: publicProcedure.query(async () => { - await initialize(); + for (const bank of Object.values(questionBanks)) { + await Promise.all([ + learningObjectiveSearch.initialize(bank), + annexSearch.initialize(bank), + questionSearch.initialize(bank), + docSearch.initialize(bank), + ]); + } return "ok"; }), searchLearningObjectives: publicProcedure .input(learningObjectivesSearchParams) .query(async ({ input }) => { - await initialize(); - return await learningObjectiveSearch.search(input); + const bank = questionBanks[input.questionBank]; + return await learningObjectiveSearch.search(bank, input); }), searchAnnexes: publicProcedure .input(annexSearchParams) .query(async ({ input }) => { - await initialize(); - return await annexSearch.search(input); + const bank = questionBanks[input.questionBank]; + return await annexSearch.search(bank, input); }), searchQuestions: publicProcedure .input(questionSearchParams) .query(async ({ input }) => { - await initialize(); - return await questionSearch.search(input); + const bank = questionBanks[input.questionBank]; + return await questionSearch.search(bank, input); }), searchDocs: publicProcedure .input(docSearchParams) .query(async ({ input }) => { - await initialize(); - return await docSearch.search(input); + const bank = questionBanks[input.questionBank]; + return await docSearch.search(bank, input); }), }); diff --git a/libs/trpc/server/src/routers/containers/learning-objectives-router.ts b/libs/trpc/server/src/routers/containers/learning-objectives-router.ts index 4b462f837..6443edcbd 100644 --- a/libs/trpc/server/src/routers/containers/learning-objectives-router.ts +++ b/libs/trpc/server/src/routers/containers/learning-objectives-router.ts @@ -39,8 +39,7 @@ export const learningObjectivesContainersRouter = router({ .then((lo) => bank.getSome("questions", lo.nestedQuestions)) .then((qs) => qs.map((q) => Object.keys(q.variants)[0])); - await questionSearch.initialize(bank); - return await questionSearch.retrieve(resultIds); + return await questionSearch.retrieve(bank, resultIds); }), getLearningObjectiveTree: publicProcedure @@ -74,8 +73,7 @@ export const learningObjectivesContainersRouter = router({ tree.push(...lo.learningObjectives); } - await learningObjectiveSearch.initialize(bank); - return await learningObjectiveSearch.retrieve(tree.sort()); + return await learningObjectiveSearch.retrieve(bank, tree.sort()); }), getLearningObjectivesSearch: publicProcedure