Search by properties

This commit is contained in:
marc
2025-05-04 22:10:10 +02:00
parent 4911935cdf
commit 47d18400c3
13 changed files with 245 additions and 68 deletions

View File

@@ -197,28 +197,3 @@ def set_tema_new(
entry_id=entry_id, entry_id=entry_id,
tema_id=new_tema.id, tema_id=new_tema.id,
) )
# @router.get("/api/sessio/{session_id}/set/{set_id}/score")
# async def render(
# request: Request,
# _: auth.RequireLogin,
# session_id: int,
# set_id: int,
# ):
# set_entry = playlists_service.get_set(session_id=session_id, set_id=set_id)
# if not set_entry:
# raise HTTPException(status_code=404, detail="Could not find set!")
# set_entry = playlists_service.add_temes_to_set(set_entry)
# tune_set = lilypond_build.set_from_set(set_entry=set_entry)
# set_source = lilypond_source.set_source(tune_set=tune_set)
# pdf_result = await lilypond_render.render(
# source=set_source,
# output=lilypond_render.RenderOutput.PDF,
# )
# if output_filename := pdf_result.result:
# score_render_url = files.get_db_file_path(output_filename)
# # return temes.score_render(request=request, score_id=score_id, score_render_url=score_render_url)
# return HTMLResponse(content=score_render_url)
# else:
# return HTMLResponse()

View File

@@ -22,16 +22,16 @@ def page(
@router.get("/api/content/live") @router.get("/api/content/live")
def contingut( async def contingut(
request: Request, request: Request,
logged_in: auth.LoggedIn, logged_in: auth.LoggedIn,
): ):
return set_page.live(request, logged_in) return await set_page.live(request, logged_in)
@router.get("/api/content/live/set") @router.get("/api/content/live/set")
def get_set_page( async def get_set_page(
request: Request, request: Request,
logged_in: auth.LoggedIn, logged_in: auth.LoggedIn,
): ):
return set_page.live_set(request, logged_in) return await set_page.live_set(request, logged_in)

View File

@@ -1,3 +1,4 @@
import urllib.parse
from typing import Annotated from typing import Annotated
from fastapi import Request from fastapi import Request
@@ -13,13 +14,16 @@ def page(
request: Request, request: Request,
logged_in: auth.LoggedIn, logged_in: auth.LoggedIn,
query: Annotated[str, Param()] = "", query: Annotated[str, Param()] = "",
properties: Annotated[list[str] | None, Param()] = None,
): ):
properties = properties or []
content_url = f"/api/content/temes?{temes.build_temes_params(query=query, properties=properties)}"
return templates.TemplateResponse( return templates.TemplateResponse(
"index.html", "index.html",
{ {
"request": request, "request": request,
"page_title": "Folkugat", "page_title": "Folkugat",
"content": f"/api/content/temes?query={query}", "content": content_url,
"logged_in": logged_in, "logged_in": logged_in,
"animate": False, "animate": False,
} }
@@ -31,10 +35,31 @@ def content(
request: Request, request: Request,
logged_in: auth.LoggedIn, logged_in: auth.LoggedIn,
query: Annotated[str, Param()] = "", query: Annotated[str, Param()] = "",
properties: Annotated[list[str] | None, Param()] = None,
): ):
return temes.temes_pagina(request, logged_in, query) properties = properties or []
return temes.temes_pagina(
request=request,
logged_in=logged_in,
query=query,
properties=properties,
)
@router.get("/api/temes/busca") @router.get("/api/temes/busca")
def busca(request: Request, query: str, logged_in: auth.LoggedIn, limit: int = 10, offset: int = 0): def busca(
return temes.temes_busca(request, query=query, limit=limit, offset=offset, logged_in=logged_in) request: Request,
logged_in: auth.LoggedIn,
query: Annotated[str, Param()],
properties: Annotated[list[str] | None, Param()] = None,
limit: int = 10,
offset: int = 0,
):
return temes.temes_busca(
request=request,
query=query,
properties=properties or [],
limit=limit,
offset=offset,
logged_in=logged_in,
)

View File

@@ -649,6 +649,11 @@ video {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.my-4 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.mb-3 { .mb-3 {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
@@ -944,11 +949,6 @@ video {
padding: 1rem; padding: 1rem;
} }
.px-1 {
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.px-10 { .px-10 {
padding-left: 2.5rem; padding-left: 2.5rem;
padding-right: 2.5rem; padding-right: 2.5rem;

View File

@@ -11,6 +11,7 @@
hx-get="/api/temes/busca" hx-get="/api/temes/busca"
hx-trigger="revealed, keyup delay:500ms changed" hx-trigger="revealed, keyup delay:500ms changed"
hx-target="#search-results" hx-target="#search-results"
hx-vars="properties:{{ properties_str }}"
hx-swap="outerHTML"> hx-swap="outerHTML">
<div id="search-results" class="flex-col"></div> <div id="search-results" class="flex-col"></div>
</div> </div>

View File

@@ -18,10 +18,16 @@
<ul class="flex flex-wrap text-sm <ul class="flex flex-wrap text-sm
py-2 px-4"> py-2 px-4">
{% for property in tema.properties %} {% for property in tema.properties %}
<div class="bg-beige text-white rounded <button class="bg-beige text-white rounded
m-1 px-2"> m-1 px-2"
hx-get="/api/content/temes"
hx-target="#content"
hx-include="[name=query]"
hx-vars="properties:{{ add_property_str(property.value) }}"
hx-swap="innerHTML"
>
{{ property.value }} {{ property.value }}
</div> </button>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}

View File

@@ -12,6 +12,38 @@
<i>{{ query }}</i> <i>{{ query }}</i>
</button> </button>
{% endif %} {% endif %}
<ul class="flex flex-wrap justify-center my-4">
{% for property in properties %}
<li>
<button class="bg-beige rounded
text-white
m-1 px-2"
hx-get="/api/content/temes"
hx-target="#content"
hx-include="[name=query]"
hx-vars="properties:{{ remove_property_str(property) }}"
hx-swap="innerHTML"
>
{{ property }}
</button>
</li>
{% endfor %}
{% for property in property_results %}
<li>
<button class="border border-beige rounded
text-white
m-1 px-2"
hx-get="/api/content/temes"
hx-target="#content"
hx-vars="properties:{{ add_property_str(property) }}"
hx-swap="innerHTML"
>
{{ property }}
</button>
</li>
{% endfor %}
</ul>
<hr class="h-px mt-1 mb-3 bg-beige border-0">
<ul class="min-w-full w-full"> <ul class="min-w-full w-full">
{% for tema in temes %} {% for tema in temes %}
{% include "fragments/temes/result.html" %} {% include "fragments/temes/result.html" %}

View File

@@ -2,7 +2,9 @@ from collections.abc import Iterable
from typing import TypedDict from typing import TypedDict
from folkugat_web.dal.sql import Connection, get_connection from folkugat_web.dal.sql import Connection, get_connection
from folkugat_web.model import search as search_model
from folkugat_web.model import temes as model from folkugat_web.model import temes as model
from folkugat_web.services import ngrams
PropertyRowTuple = tuple[int, int, str, str] PropertyRowTuple = tuple[int, int, str, str]
@@ -86,7 +88,8 @@ def insert_property(property: model.Property, con: Connection | None = None) ->
cur = con.cursor() cur = con.cursor()
_ = cur.execute(query, data) _ = cur.execute(query, data)
row: PropertyRowTuple = cur.fetchone() row: PropertyRowTuple = cur.fetchone()
return row_to_property(row) evict_property_value_to_ngrams_cache()
return row_to_property(row)
def create_property(tema_id: int, field: model.PropertyField | None = None, con: Connection | None = None) -> model.Property: def create_property(tema_id: int, field: model.PropertyField | None = None, con: Connection | None = None) -> model.Property:
@@ -111,6 +114,7 @@ def update_property(property: model.Property, con: Connection | None = None):
with get_connection(con) as con: with get_connection(con) as con:
cur = con.cursor() cur = con.cursor()
_ = cur.execute(query, data) _ = cur.execute(query, data)
evict_property_value_to_ngrams_cache()
def delete_property(property_id: int, tema_id: int | None = None, con: Connection | None = None): def delete_property(property_id: int, tema_id: int | None = None, con: Connection | None = None):
@@ -125,3 +129,32 @@ def delete_property(property_id: int, tema_id: int | None = None, con: Connectio
with get_connection(con) as con: with get_connection(con) as con:
cur = con.cursor() cur = con.cursor()
_ = cur.execute(query, data) _ = cur.execute(query, data)
evict_property_value_to_ngrams_cache()
_property_value_to_ngrams_cache: dict[str, search_model.NGrams] | None = None
def evict_property_value_to_ngrams_cache():
global _property_value_to_ngrams_cache
_property_value_to_ngrams_cache = None
def get_property_value_to_ngrams(con: Connection | None = None) -> dict[str, search_model.NGrams]:
global _property_value_to_ngrams_cache
if _property_value_to_ngrams_cache is None:
_property_value_to_ngrams_cache = _get_property_value_to_ngrams(con)
return _property_value_to_ngrams_cache
def _get_property_value_to_ngrams(con: Connection | None = None) -> dict[str, search_model.NGrams]:
query = """
SELECT value
FROM tema_properties
"""
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query)
rows: list[tuple[str]] = cur.fetchall()
props = {prop[0] for prop in rows}
return {prop: ngrams.get_text_ngrams(prop.lower()) for prop in props}

View File

@@ -128,6 +128,7 @@ def busca_tema(
): ):
results = search_service.busca_temes( results = search_service.busca_temes(
query=query, query=query,
properties=[],
hidden=True, hidden=True,
limit=4, limit=4,
offset=0, offset=0,

View File

@@ -29,13 +29,14 @@ async def pagina(request: Request, session_id: int, set_id: int, logged_in: bool
) )
def live(request: Request, logged_in: bool): async def live(request: Request, logged_in: bool):
session = sessions_service.get_live_session() session = sessions_service.get_live_session()
set_ = None set_ = None
if session and session.id: if session and session.id:
playlist = playlists_service.get_playlist(session_id=session.id) playlist = playlists_service.get_playlist(session_id=session.id)
if playlist.sets: if playlist.sets:
set_ = playlists_service.add_temes_to_set(playlist.sets[-1]) set_ = playlists_service.add_temes_to_set(playlist.sets[-1])
set_ = await playlists_service.add_set_score_to_set(set_)
return templates.TemplateResponse( return templates.TemplateResponse(
"fragments/sessio/set/pagina.html", "fragments/sessio/set/pagina.html",
{ {
@@ -51,13 +52,14 @@ def live(request: Request, logged_in: bool):
) )
def live_set(request: Request, logged_in: bool): async def live_set(request: Request, logged_in: bool):
session = sessions_service.get_live_session() session = sessions_service.get_live_session()
set_ = None set_ = None
if session and session.id: if session and session.id:
playlist = playlists_service.get_playlist(session_id=session.id) playlist = playlists_service.get_playlist(session_id=session.id)
if playlist.sets: if playlist.sets:
set_ = playlists_service.add_temes_to_set(playlist.sets[-1]) set_ = playlists_service.add_temes_to_set(playlist.sets[-1])
set_ = await playlists_service.add_set_score_to_set(set_)
return templates.TemplateResponse( return templates.TemplateResponse(
"fragments/sessio/set/set_page.html", "fragments/sessio/set/set_page.html",
{ {

View File

@@ -1,9 +1,11 @@
import json
import urllib.parse
from fastapi import Request from fastapi import Request
from folkugat_web.model import temes as model from folkugat_web.model import temes as model
from folkugat_web.model.lilypond.processing import RenderError from folkugat_web.model.lilypond.processing import RenderError
from folkugat_web.model.pagines import Pages from folkugat_web.model.pagines import Pages
from folkugat_web.services import sessions as sessions_service from folkugat_web.services import sessions as sessions_service
from folkugat_web.services.temes import properties as properties_service
from folkugat_web.services.temes import query as temes_q from folkugat_web.services.temes import query as temes_q
from folkugat_web.services.temes import scores as scores_service from folkugat_web.services.temes import scores as scores_service
from folkugat_web.services.temes import search as temes_s from folkugat_web.services.temes import search as temes_s
@@ -11,22 +13,46 @@ from folkugat_web.templates import templates
from folkugat_web.utils import FnChain from folkugat_web.utils import FnChain
def temes_pagina(request: Request, logged_in: bool, query: str): def build_temes_params(query: str, properties: list[str]) -> str:
content_params = [
("query", query),
*[("properties", prop) for prop in properties or []]
]
return urllib.parse.urlencode(content_params)
def build_property_str(properties: list[str]) -> str:
return json.dumps(properties).replace("'", "\\'").replace('"', "'")
def temes_pagina(request: Request, logged_in: bool, query: str, properties: list[str]):
properties_str = build_property_str(properties)
return templates.TemplateResponse( return templates.TemplateResponse(
"fragments/temes/pagina.html", "fragments/temes/pagina.html",
{ {
"request": request, "request": request,
"logged_in": logged_in, "logged_in": logged_in,
"query": query, "query": query,
"properties": properties,
"properties_str": properties_str,
"property_results": [],
"Pages": Pages, "Pages": Pages,
"menu_selected_id": Pages.Temes, "menu_selected_id": Pages.Temes,
} }
) )
def temes_busca(request: Request, logged_in: bool, query: str, offset: int = 0, limit: int = 10): def temes_busca(
request: Request,
logged_in: bool,
query: str,
properties: list[str],
offset: int = 0,
limit: int = 10,
):
temes = temes_s.busca_temes( temes = temes_s.busca_temes(
query=query, query=query,
properties=properties,
hidden=logged_in, hidden=logged_in,
limit=limit + 1, limit=limit + 1,
offset=offset, offset=offset,
@@ -42,11 +68,26 @@ def temes_busca(request: Request, logged_in: bool, query: str, offset: int = 0,
temes = ( temes = (
FnChain.transform(temes) | FnChain.transform(temes) |
temes_q.temes_compute_stats | temes_q.temes_compute_stats |
properties_service.add_properties_to_temes |
scores_service.add_scores_to_temes | scores_service.add_scores_to_temes |
# No properties added because search already does that
list list
).result() ).result()
property_results = [
prop for prop in temes_s.busca_properties(query=query, limit=5)
if prop not in properties
]
temes_url = f"/temes?{build_temes_params(query, properties)}"
def _add_property_str(prop: str) -> str:
properties_dedup = list(set(properties + [prop]))
return build_property_str(properties_dedup)
def _remove_property_str(prop: str) -> str:
properties_clean = [p for p in properties if p.lower() != prop.lower()]
return build_property_str(properties_clean)
return templates.TemplateResponse( return templates.TemplateResponse(
"fragments/temes/results.html", "fragments/temes/results.html",
{ {
@@ -54,11 +95,16 @@ def temes_busca(request: Request, logged_in: bool, query: str, offset: int = 0,
"logged_in": logged_in, "logged_in": logged_in,
"temes": temes, "temes": temes,
"query": query, "query": query,
"properties": properties,
"property_results": property_results,
"prev_offset": prev_offset, "prev_offset": prev_offset,
"next_offset": next_offset, "next_offset": next_offset,
"LinkType": model.LinkType, "LinkType": model.LinkType,
"ContentType": model.ContentType, "ContentType": model.ContentType,
} "add_property_str": _add_property_str,
"remove_property_str": _remove_property_str,
},
headers={"HX-Replace-Url": temes_url},
) )

View File

@@ -1,7 +1,8 @@
import dataclasses import dataclasses
from collections.abc import Iterable from collections.abc import Iterable
from typing import Self from typing import Generic, Self, TypeVar
T = TypeVar("T")
NGrams = dict[int, list[str]] NGrams = dict[int, list[str]]
@@ -20,7 +21,7 @@ class SearchMatch:
@dataclasses.dataclass @dataclasses.dataclass
class QueryResult: class QueryResult(Generic[T]):
id: int result: T
distance: float distance: float
ngram: str ngram: str

View File

@@ -1,17 +1,21 @@
import time import time
from collections.abc import Iterable, Iterator from collections.abc import Iterable, Iterator
from sqlite3 import Connection from sqlite3 import Connection
from typing import Callable from typing import Callable, TypeVar
import Levenshtein import Levenshtein
from folkugat_web.config import search as config from folkugat_web.config import search as config
from folkugat_web.dal.sql import get_connection from folkugat_web.dal.sql import get_connection
from folkugat_web.dal.sql.temes import properties as properties_dal
from folkugat_web.dal.sql.temes import query as temes_q from folkugat_web.dal.sql.temes import query as temes_q
from folkugat_web.log import logger from folkugat_web.log import logger
from folkugat_web.model import search as search_model from folkugat_web.model import search as search_model
from folkugat_web.model import temes as model from folkugat_web.model import temes as model
from folkugat_web.services.temes import properties as properties_service
from folkugat_web.utils import FnChain from folkugat_web.utils import FnChain
T = TypeVar("T")
def get_query_word_similarity(query_word: str, text_ngrams: search_model.NGrams) -> search_model.SearchMatch: def get_query_word_similarity(query_word: str, text_ngrams: search_model.NGrams) -> search_model.SearchMatch:
n = len(query_word) n = len(query_word)
@@ -34,39 +38,41 @@ def get_query_similarity(query: str, ngrams: search_model.NGrams) -> search_mode
return search_model.SearchMatch.combine_matches(word_matches) return search_model.SearchMatch.combine_matches(word_matches)
def _build_results_fn(query: str) -> Callable[[Iterable[tuple[int, search_model.NGrams]]], def _build_results_fn(query: str) -> Callable[[Iterable[tuple[T, search_model.NGrams]]],
Iterator[search_model.QueryResult]]: Iterator[search_model.QueryResult[T]]]:
def build_result(entry: tuple[int, search_model.NGrams]) -> search_model.QueryResult: def build_result(entry: tuple[T, search_model.NGrams]) -> search_model.QueryResult[T]:
if len(query) == 0: if len(query) == 0:
return search_model.QueryResult( return search_model.QueryResult(
id=entry[0], result=entry[0],
distance=0, distance=0,
ngram="", ngram="",
) )
match = get_query_similarity(query, entry[1]) match = get_query_similarity(query, entry[1])
return search_model.QueryResult( return search_model.QueryResult(
id=entry[0], result=entry[0],
distance=match.distance, distance=match.distance,
ngram=match.ngram, ngram=match.ngram,
) )
def build_results(entries: Iterable[tuple[int, search_model.NGrams]]) -> Iterator[search_model.QueryResult]: def build_results(entries: Iterable[tuple[T, search_model.NGrams]]) -> Iterator[search_model.QueryResult[T]]:
return map(build_result, entries) return map(build_result, entries)
return build_results return build_results
def _filter_distance(qrs: Iterable[search_model.QueryResult]) -> Iterator[search_model.QueryResult]: def _filter_distance(qrs: Iterable[search_model.QueryResult[T]]) -> Iterator[search_model.QueryResult[T]]:
return filter(lambda qr: qr.distance <= config.SEARCH_DISTANCE_THRESHOLD, qrs) return filter(lambda qr: qr.distance <= config.SEARCH_DISTANCE_THRESHOLD, qrs)
def _sort_by_distance(qrs: Iterable[search_model.QueryResult]) -> list[search_model.QueryResult]: def _sort_by_distance(qrs: Iterable[search_model.QueryResult[T]]) -> list[search_model.QueryResult[T]]:
return sorted(qrs, key=lambda qr: qr.distance) return sorted(qrs, key=lambda qr: qr.distance)
def _query_results_to_temes(con: Connection) -> Callable[[Iterable[search_model.QueryResult]], Iterator[model.Tema]]: def _query_results_to_temes(
def fetch_temes(qrs: Iterable[search_model.QueryResult]) -> Iterator[model.Tema]: con: Connection
return filter(None, map(lambda qr: temes_q.get_tema_by_id(tema_id=qr.id, con=con), qrs)) ) -> Callable[[Iterable[search_model.QueryResult[int]]], Iterator[model.Tema]]:
def fetch_temes(qrs: Iterable[search_model.QueryResult[int]]) -> Iterator[model.Tema]:
return filter(None, map(lambda qr: temes_q.get_tema_by_id(tema_id=qr.result, con=con), qrs))
return fetch_temes return fetch_temes
@@ -76,13 +82,35 @@ def _filter_hidden(hidden: bool) -> Callable[[Iterable[model.Tema]], Iterator[mo
return filter_hidden return filter_hidden
def _apply_limit_offset(limit: int, offset: int) -> Callable[[Iterable[model.Tema]], list[model.Tema]]: def _filter_properties(properties: list[str]) -> Callable[[Iterable[model.Tema]], Iterator[model.Tema]]:
def apply_limit_offset(temes: Iterable[model.Tema]) -> list[model.Tema]: properties_set = set(prop.lower() for prop in properties)
def has_properties(tema: model.Tema) -> bool:
tema_properties = {prop.value.lower() for prop in tema.properties}
return all(prop in tema_properties for prop in properties_set)
def filter_properties(temes: Iterable[model.Tema]) -> Iterator[model.Tema]:
return filter(has_properties, temes)
return filter_properties
def _apply_limit_offset(limit: int, offset: int) -> Callable[[Iterable[T]], list[T]]:
def apply_limit_offset(temes: Iterable[T]) -> list[T]:
return list(temes)[offset:offset + limit] return list(temes)[offset:offset + limit]
return apply_limit_offset return apply_limit_offset
def busca_temes(query: str, hidden: bool = False, limit: int = 10, offset: int = 0) -> list[model.Tema]: def busca_temes(
query: str,
properties: list[str],
hidden: bool = False,
limit: int = 10,
offset: int = 0,
) -> list[model.Tema]:
"""
This function adds properties to Tema
"""
t0 = time.time() t0 = time.time()
with get_connection() as con: with get_connection() as con:
result = ( result = (
@@ -92,7 +120,34 @@ def busca_temes(query: str, hidden: bool = False, limit: int = 10, offset: int =
_sort_by_distance | _sort_by_distance |
_query_results_to_temes(con) | _query_results_to_temes(con) |
_filter_hidden(hidden) | _filter_hidden(hidden) |
properties_service.add_properties_to_temes |
_filter_properties(properties) |
_apply_limit_offset(limit=limit, offset=offset) _apply_limit_offset(limit=limit, offset=offset)
).result() ).result()
logger.info(f"Search time: { int((time.time() - t0) * 1000) } ms") logger.info(f"Temes search time: { int((time.time() - t0) * 1000) } ms")
return result
def _extract_properties(query_results: list[search_model.QueryResult[str]]) -> list[str]:
return [qr.result for qr in query_results]
def busca_properties(
query: str,
limit: int = 10,
offset: int = 0,
) -> list[str]:
if not query:
return []
t0 = time.time()
with get_connection() as con:
result = (
FnChain.transform(properties_dal.get_property_value_to_ngrams(con).items()) |
_build_results_fn(query) |
_filter_distance |
_sort_by_distance |
_apply_limit_offset(limit=limit, offset=offset) |
_extract_properties
).result()
logger.info(f"Properties search time: { int((time.time() - t0) * 1000) } ms")
return result return result