diff --git a/backend/aethel_db/search.py b/backend/aethel_db/search.py index eb6c110..3cb9874 100644 --- a/backend/aethel_db/search.py +++ b/backend/aethel_db/search.py @@ -1,63 +1,18 @@ from __future__ import annotations -from typing import Iterable, Callable, Iterator -from aethel.frontend import Sample +from aethel.frontend import LexicalPhrase, LexicalItem +from aethel.mill.types import type_repr -# The following methods and classes have been extracted from aethel.scripts.search (not part of the published library), with some minor customisations / simplifications. +def match_type_with_phrase(phrase: LexicalPhrase, type_input: str) -> bool: + return type_input == type_repr(phrase.type) -def search(bank: Iterable[Sample], query: Callable[[Sample], bool]) -> Iterator[Sample]: - return filter(query, bank) +def match_word_with_phrase(phrase: LexicalPhrase, word_input: str) -> bool: + return any(match_word_with_item(item, word_input) for item in phrase.items) -def in_lemma(query_string: str) -> Query: - def f(sample: Sample) -> bool: - return any( - query_string.lower() in item.lemma.lower() - for phrase in sample.lexical_phrases - for item in phrase.items - ) - return Query(f) - - -def in_word(query_string: str) -> Query: - def f(sample: Sample) -> bool: - return any( - query_string.lower() in item.word.lower() - for phrase in sample.lexical_phrases - for item in phrase.items - ) - - return Query(f) - - -class Query: - def __init__(self, fn: Callable[[Sample], bool]): - self.fn = fn - - def __and__(self, other: Query) -> Query: - def f(sample: Sample) -> bool: - return self.fn(sample) and other.fn(sample) - - return Query(f) - - def __or__(self, other) -> Query: - def f(sample: Sample) -> bool: - return self.fn(sample) or other.fn(sample) - - return Query(f) - - def __invert__(self) -> Query: - def f(sample: Sample) -> bool: - return not self.fn(sample) - - return Query(f) - - def __xor__(self, other) -> Query: - def f(sample: Sample) -> bool: - return self.fn(sample) ^ other.fn(sample) - - return Query(f) - - def __call__(self, sample: Sample) -> bool: - return self.fn(sample) +def match_word_with_item(item: LexicalItem, word_input: str) -> bool: + return ( + word_input.lower() in item.lemma.lower() + or word_input.lower() in item.word.lower() + ) diff --git a/backend/aethel_db/views.py b/backend/aethel_db/views.py index a4a3a99..6d6a44b 100644 --- a/backend/aethel_db/views.py +++ b/backend/aethel_db/views.py @@ -5,15 +5,13 @@ from django.http import HttpRequest, JsonResponse from rest_framework import status from rest_framework.views import APIView -from aethel.frontend import LexicalItem from spindle.utils import serialize_phrases_with_infix_notation -from aethel_db.search import search, in_lemma, in_word +from aethel_db.search import ( + match_type_with_phrase, + match_word_with_phrase, +) from aethel.frontend import Sample - -from aethel.frontend import LexicalItem - from .models import dataset -from .search import search, in_lemma, in_word def aethel_status(): @@ -41,7 +39,9 @@ class AethelListItem: def serialize(self): out = asdict(self) - out['samples'] = sorted(out['samples'], key=lambda sample: len(sample['sentence'])) + out["samples"] = sorted( + out["samples"], key=lambda sample: len(sample["phrases"]) + ) return out @@ -51,22 +51,19 @@ class AethelListResponse: Response object for Aethel query view. """ - results: List[AethelListItem] = field(default_factory=list) + results: dict[tuple[str, str, str], AethelListItem] = field(default_factory=dict) error: Optional[str] = None def get_or_create_result(self, lemma: str, word: str, type: str) -> AethelListItem: """ Return an existing result with the same lemma, word, and type, or create a new one if it doesn't exist. """ - for result in self.results: - if result.lemma == lemma and result.type == type and result.word == word: - return result + key = (lemma, word, type) new_result = AethelListItem(lemma=lemma, word=word, type=type, samples=[]) - self.results.append(new_result) - return new_result + return self.results.setdefault(key, new_result) def json_response(self) -> JsonResponse: - results = [result.serialize() for result in self.results] + results = [result.serialize() for result in self.results.values()] return JsonResponse( { @@ -79,55 +76,47 @@ def json_response(self) -> JsonResponse: class AethelQueryView(APIView): def get(self, request: HttpRequest) -> JsonResponse: - query_input = self.request.query_params.get("query", None) - if query_input is None or len(query_input) < 3: - return AethelListResponse().json_response() + word_input = self.request.query_params.get("word", None) + type_input = self.request.query_params.get("type", None) - def item_contains_query_string(item: LexicalItem, query_input: str) -> bool: - """ - Checks if a LexicalItem contains a given input string in its word or its lemma. - """ - return ( - query_input.lower() in item.lemma.lower() - or query_input.lower() in item.word.lower() - ) + # We only search for strings of 3 or more characters. + if word_input is not None and len(word_input) < 3: + return AethelListResponse().json_response() response_object = AethelListResponse() - # First we select all relevant samples from the dataset that contain the query string. - query_result = search( - bank=dataset.samples, - query=in_word(query_input) | in_lemma(query_input), - ) - - for sample in query_result: + for sample in dataset.samples: for phrase_index, phrase in enumerate(sample.lexical_phrases): - for item in phrase.items: - if item_contains_query_string(item, query_input): - result = response_object.get_or_create_result( - lemma=item.lemma, word=item.word, type=str(phrase.type) + word_match = word_input and match_word_with_phrase(phrase, word_input) + type_match = type_input and match_type_with_phrase(phrase, type_input) + if not (word_match or type_match): + continue + + phrase_word = " ".join([item.word for item in phrase.items]) + phrase_lemma = " ".join([item.lemma for item in phrase.items]) + + result = response_object.get_or_create_result( + lemma=phrase_lemma, word=phrase_word, type=str(phrase.type) + ) + + # Check whether we have already added this sample for this result. + existing_sample = next( + (s for s in result.samples if s.name == sample.name), + None, + ) + + if existing_sample: + existing_sample.phrases[phrase_index].highlight = True + else: + new_sample = AethelListSample(name=sample.name, phrases=[]) + for index, sample_phrase in enumerate(sample.lexical_phrases): + highlighted = index == phrase_index + new_phrase = AethelSamplePhrase( + display=sample_phrase.string, + highlight=highlighted, ) - - # Check whether we have already added this sample for this result - existing_sample = next( - (s for s in result.samples if s.name == sample.name), - None, - ) - - if existing_sample: - existing_sample.phrases[phrase_index].highlight = True - else: - new_sample = AethelListSample(name=sample.name, phrases=[]) - for index, sample_phrase in enumerate( - sample.lexical_phrases - ): - highlighted = index == phrase_index - new_phrase = AethelSamplePhrase( - display=sample_phrase.string, - highlight=highlighted, - ) - new_sample.phrases.append(new_phrase) - result.samples.append(new_sample) + new_sample.phrases.append(new_phrase) + result.samples.append(new_sample) return response_object.json_response() diff --git a/frontend/src/app/aethel/aethel.component.spec.ts b/frontend/src/app/aethel/aethel.component.spec.ts index 431bb4b..6ebfb7c 100644 --- a/frontend/src/app/aethel/aethel.component.spec.ts +++ b/frontend/src/app/aethel/aethel.component.spec.ts @@ -6,10 +6,12 @@ import { ReactiveFormsModule } from "@angular/forms"; import { ActivatedRoute, Router, RouterModule } from "@angular/router"; import { routes } from "../routes"; import { of } from "rxjs"; +import { AethelApiService } from "../shared/services/aethel-api.service"; describe("AethelComponent", () => { let component: AethelComponent; let fixture: ComponentFixture; + let apiService: AethelApiService; let httpController: HttpTestingController; let route: ActivatedRoute; let router: Router; @@ -28,6 +30,7 @@ describe("AethelComponent", () => { route = TestBed.inject(ActivatedRoute); router = TestBed.inject(Router); fixture = TestBed.createComponent(AethelComponent); + apiService = TestBed.inject(AethelApiService); component = fixture.componentInstance; fixture.detectChanges(); }); @@ -43,17 +46,24 @@ describe("AethelComponent", () => { httpController.expectNone("/api/aethel"); }); - it("should request data when there is a query parameter on init", () => { - route.queryParams = of({ query: "test" }); + it("should insert data into the form when there is a 'word' query parameter", () => { + route.queryParams = of({ word: "test" }); component.ngOnInit(); expect(component.form.controls.aethelInput.value).toBe("test"); - httpController.expectOne("/api/aethel/?query=test"); + }); + + it("should pass query param data to the API service", () => { + apiService.input$.subscribe(input => { + expect(input.word).toBe("test3"); + }); + route.queryParams = of({ word: "test3" }); + component.ngOnInit(); }); it("should react to form submissions", () => { const navigatorSpy = spyOn(router, "navigateByUrl"); component.form.controls.aethelInput.setValue("test-two"); component.submit(); - expect(navigatorSpy).toHaveBeenCalledWith("/?query=test-two"); + expect(navigatorSpy).toHaveBeenCalledWith("/?word=test-two"); }); }); diff --git a/frontend/src/app/aethel/aethel.component.ts b/frontend/src/app/aethel/aethel.component.ts index 20ec49b..4f53b1c 100644 --- a/frontend/src/app/aethel/aethel.component.ts +++ b/frontend/src/app/aethel/aethel.component.ts @@ -63,14 +63,17 @@ export class AethelComponent implements OnInit { // Whenever the query parameter changes, we run a new query. this.route.queryParams .pipe( - map((queryParams) => queryParams["query"]), isNonNull(), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef), ) .subscribe((query) => { - this.form.controls.aethelInput.setValue(query); - this.apiService.input$.next(query); + const word = query['word']; + const type = query['type'] + if (word) { + this.form.controls.aethelInput.setValue(word); + } + this.apiService.input$.next({ word, type }); }); } @@ -91,7 +94,7 @@ export class AethelComponent implements OnInit { private updateUrl(query: string): void { // This does not actually refresh the page because it just adds parameters to the current route. // It just updates the URL in the browser, triggering a new query. - const url = this.router.createUrlTree([], { relativeTo: this.route, queryParams: { query } }).toString(); + const url = this.router.createUrlTree([], { relativeTo: this.route, queryParams: { word: query } }).toString(); this.router.navigateByUrl(url); } diff --git a/frontend/src/app/sample/sample.component.html b/frontend/src/app/sample/sample.component.html index 54f205d..10af818 100644 --- a/frontend/src/app/sample/sample.component.html +++ b/frontend/src/app/sample/sample.component.html @@ -24,7 +24,7 @@ # Phrase Type - + Search in Æthel @@ -38,10 +38,16 @@ - - @if (showButton(phrase.items)) { - + + } diff --git a/frontend/src/app/sample/sample.component.scss b/frontend/src/app/sample/sample.component.scss index e69de29..57e2824 100644 --- a/frontend/src/app/sample/sample.component.scss +++ b/frontend/src/app/sample/sample.component.scss @@ -0,0 +1,3 @@ +.search-button:not(:last-child) { + margin-right: .5rem; +} diff --git a/frontend/src/app/sample/sample.component.spec.ts b/frontend/src/app/sample/sample.component.spec.ts index fe14d3d..e21d797 100644 --- a/frontend/src/app/sample/sample.component.spec.ts +++ b/frontend/src/app/sample/sample.component.spec.ts @@ -16,6 +16,18 @@ import { By } from "@angular/platform-browser"; import { ProofPipe } from "../shared/pipes/proof.pipe"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +const fakePhrase: LexicalPhrase = { + type: "cheese->tosti", + items: [ + { + word: "cheeses", + lemma: "tostis", + pos: "TOSTI", + pt: "CHEESE", + }, + ], +}; + describe("SampleComponent", () => { let component: SampleComponent; let fixture: ComponentFixture; @@ -27,7 +39,11 @@ describe("SampleComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [SampleComponent, ProofPipe], - imports: [HttpClientTestingModule, FontAwesomeModule, RouterModule.forRoot(routes)], + imports: [ + HttpClientTestingModule, + FontAwesomeModule, + RouterModule.forRoot(routes), + ], providers: [ { provide: ActivatedRoute, @@ -53,20 +69,27 @@ describe("SampleComponent", () => { expect(component).toBeTruthy(); }); - it("should construct a valid route", () => { + it("should construct a valid route for word search", () => { const spy = spyOn(router, "navigate"); - const items: LexicalPhrase["items"] = [ - { - lemma: "test", - pos: "2", - pt: "2", - word: "testQuery", - }, - ]; + component.searchAethel(fakePhrase, 'word'); + expect(spy).toHaveBeenCalledOnceWith(["/aethel"], { + queryParams: { word: "cheeses" }, + }); + }); - component.routeToAethel(items); + it("should construct a valid route for type search", () => { + const spy = spyOn(router, "navigate"); + component.searchAethel(fakePhrase, 'type'); + expect(spy).toHaveBeenCalledOnceWith(["/aethel"], { + queryParams: { type: "cheese->tosti" }, + }); + }); + + it("should construct a valid route for word and type search", () => { + const spy = spyOn(router, "navigate"); + component.searchAethel(fakePhrase, 'word-and-type'); expect(spy).toHaveBeenCalledOnceWith(["/aethel"], { - queryParams: { query: "testQuery" }, + queryParams: { word: "cheeses", type: "cheese->tosti" }, }); }); diff --git a/frontend/src/app/sample/sample.component.ts b/frontend/src/app/sample/sample.component.ts index 7135a9c..3ab68c3 100644 --- a/frontend/src/app/sample/sample.component.ts +++ b/frontend/src/app/sample/sample.component.ts @@ -1,8 +1,8 @@ import { Component } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; +import { ActivatedRoute, Params, Router } from "@angular/router"; import { AethelApiService } from "../shared/services/aethel-api.service"; import { map } from "rxjs"; -import { LexicalPhrase } from "../shared/types"; +import { AethelMode, LexicalPhrase } from "../shared/types"; import { isNonNull } from "../shared/operators/IsNonNull"; import { faArrowLeft } from "@fortawesome/free-solid-svg-icons"; import { Location } from "@angular/common"; @@ -17,30 +17,27 @@ export class SampleComponent { private sample$ = this.apiService.sampleResult$(this.sampleName); public sampleResult$ = this.sample$.pipe( map((response) => response?.result), - isNonNull() + isNonNull(), ); public icons = { arrowLeft: faArrowLeft, - } + }; constructor( private route: ActivatedRoute, private apiService: AethelApiService, private router: Router, - private location: Location + private location: Location, ) {} - public routeToAethel(items: LexicalPhrase["items"]): void { - const combined = items.map((item) => item.word).join(" "); - this.router.navigate(["/aethel"], { - queryParams: { - query: combined, - }, - }); + public searchAethel(phrase: LexicalPhrase, mode: AethelMode): void { + const queryParams = this.formatQueryParams(phrase, mode); + this.router.navigate(["/aethel"], { queryParams }); } - public showButton(items: LexicalPhrase["items"]): boolean { + public showButtons(items: LexicalPhrase["items"]): boolean { + // Buttons are hidden if the phrase consists of too few characters. const combined = items.map((item) => item.word).join(" "); return combined.length > 2; } @@ -48,4 +45,17 @@ export class SampleComponent { public goBack(): void { this.location.back(); } + + private formatQueryParams(phrase: LexicalPhrase, mode: AethelMode): Params { + const queryParams: Params = {}; + if (mode === "word" || mode === "word-and-type") { + queryParams["word"] = phrase.items + .map((item) => item.word) + .join(" "); + } + if (mode === "type" || mode === "word-and-type") { + queryParams["type"] = phrase.type; + } + return queryParams; + } } diff --git a/frontend/src/app/shared/services/aethel-api.service.ts b/frontend/src/app/shared/services/aethel-api.service.ts index 00b409c..891ef0c 100644 --- a/frontend/src/app/shared/services/aethel-api.service.ts +++ b/frontend/src/app/shared/services/aethel-api.service.ts @@ -13,7 +13,7 @@ import { throttleTime, } from "rxjs"; import { environment } from "src/environments/environment"; -import { AethelDetail, AethelListReturn } from "../types"; +import { AethelDetail, AethelInput, AethelListReturn } from "../types"; import { ErrorHandlerService } from "./error-handler.service"; import { ParsePortDataService } from "./ParsePortDataService"; @@ -21,9 +21,9 @@ import { ParsePortDataService } from "./ParsePortDataService"; providedIn: "root", }) export class AethelApiService - implements ParsePortDataService + implements ParsePortDataService { - input$ = new Subject(); + input$ = new Subject(); throttledInput$ = this.input$.pipe( distinctUntilChanged(), @@ -37,7 +37,13 @@ export class AethelApiService "Content-Type": "application/json", }); - const params = new HttpParams().set("query", input); + let params = new HttpParams(); + if (input.word) { + params = params.set("word", input.word); + } + if (input.type) { + params = params.set("type", input.type); + } return this.http .get(`${environment.apiUrl}aethel/`, { diff --git a/frontend/src/app/shared/types.ts b/frontend/src/app/shared/types.ts index 6fc9864..99d3b9e 100644 --- a/frontend/src/app/shared/types.ts +++ b/frontend/src/app/shared/types.ts @@ -35,6 +35,14 @@ export interface SpindleReturn { lexical_phrases: LexicalPhrase[]; proof: Record | null; } + +export type AethelMode = "word" | "type" | "word-and-type"; + +export interface AethelInput { + word?: string; + type?: string; +} + export interface AethelListReturnItem { lemma: string; word: string;