mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-21 05:45:37 +08:00
LazyToolboxSearch: search every store's whoosh index
The populator writes one whoosh index per store, but search only opened the default store's directory — tools served from a named store (per-conf store="...") listed and ran fine yet never appeared in /api/tools?q=. Search each configured store's index and merge hits by BM25 score (ToolWhooshIndex.search_scored); scores from different indexes aren't strictly comparable, but interleaving beats invisible tools, and resolve_search_hit drops ids foreign to this toolbox. Claude-Session: https://claude.ai/code/session_018L7ZmCv2ubKA3JNeSL8Pkr
This commit is contained in:
@@ -131,9 +131,9 @@ class LazyToolboxSearch(ToolBoxSearch):
|
||||
"""Drop-in for :class:`ToolBoxSearch` in lazy-toolbox mode.
|
||||
|
||||
The populator (``galaxy.tools.source_store.populator``) builds and owns
|
||||
the whoosh index; this class is a thin reader that opens it on each
|
||||
query. Per-panel-view fan-out is collapsed: ``search`` ignores
|
||||
``panel_view`` and reads the single populator-owned index.
|
||||
one whoosh index per store; this class is a thin reader that opens them
|
||||
on each query and merges hits by score. Per-panel-view fan-out is
|
||||
collapsed: ``search`` ignores ``panel_view``.
|
||||
|
||||
``build_index`` is a no-op (the populator's job). ``index_count`` is
|
||||
still incremented so :func:`galaxy.queue_worker.rebuild_toolbox_search_index`
|
||||
@@ -144,8 +144,8 @@ class LazyToolboxSearch(ToolBoxSearch):
|
||||
def __init__(self, config: GalaxyAppConfiguration) -> None:
|
||||
# Skip ToolBoxSearch.__init__ — it walks ``toolbox.panel_views()`` and
|
||||
# builds a ToolPanelViewSearch per view. Under lazy mode the
|
||||
# populator owns one whoosh index for the default store; per-view
|
||||
# filtering is a follow-up if needed.
|
||||
# populator owns the whoosh indexes; per-view filtering is a
|
||||
# follow-up if needed.
|
||||
self.config = config
|
||||
self.panel_searches: dict[str, ToolPanelViewSearch] = {}
|
||||
self.index_count = -1
|
||||
@@ -166,13 +166,30 @@ class LazyToolboxSearch(ToolBoxSearch):
|
||||
ToolWhooshIndex,
|
||||
)
|
||||
|
||||
index_dir = whoosh_dir_for_store(config.tool_search_index_dir, DEFAULT_STORE_NAME)
|
||||
if index_dir is None:
|
||||
if not config.tool_search_index_dir:
|
||||
# No index dir means whoosh search is off entirely.
|
||||
return []
|
||||
searcher = ToolWhooshIndex(index_dir=index_dir, tuning=ToolSearchTuning.from_config(config))
|
||||
# limit=None matches ToolPanelViewSearch below, which searches
|
||||
# unlimited — capping here would truncate uniform-score matches.
|
||||
return searcher.search(q, limit=None)
|
||||
# The populator writes one whoosh index per store — searching only
|
||||
# the default's would make every named-store tool invisible to
|
||||
# ``/api/tools?q=``. Search each configured store's index and merge
|
||||
# by score. Over-searching a catalog store no conf references is
|
||||
# harmless: ``resolve_search_hit`` drops ids not in this toolbox.
|
||||
store_names = [DEFAULT_STORE_NAME, *sorted(config.tool_source_stores or {})]
|
||||
tuning = ToolSearchTuning.from_config(config)
|
||||
scored: dict[str, float] = {}
|
||||
for store_name in store_names:
|
||||
index_dir = whoosh_dir_for_store(config.tool_search_index_dir, store_name)
|
||||
assert index_dir # tool_search_index_dir checked above
|
||||
searcher = ToolWhooshIndex(index_dir=index_dir, tuning=tuning)
|
||||
# limit=None matches ToolPanelViewSearch below, which searches
|
||||
# unlimited — capping here would truncate uniform-score matches.
|
||||
for tool_id, score in searcher.search_scored(q, limit=None):
|
||||
if tool_id not in scored or score > scored[tool_id]:
|
||||
scored[tool_id] = score
|
||||
# BM25 scores from different indexes aren't strictly comparable
|
||||
# (per-corpus statistics), but interleaving by score beats
|
||||
# concatenation; ties keep first-seen order (default store first).
|
||||
return [tool_id for tool_id, _score in sorted(scored.items(), key=lambda kv: -kv[1])]
|
||||
|
||||
|
||||
class ToolPanelViewSearch:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
from galaxy.config import GalaxyAppConfiguration
|
||||
from galaxy.tools.search import LazyToolboxSearch
|
||||
from galaxy.tools.source_store.index import (
|
||||
ToolIndex,
|
||||
ToolIndexEntry,
|
||||
)
|
||||
from galaxy.tools.source_store.populator import (
|
||||
DEFAULT_STORE_NAME,
|
||||
whoosh_dir_for_store,
|
||||
)
|
||||
from galaxy.tools.source_store.search import (
|
||||
ToolSearchTuning,
|
||||
ToolWhooshIndex,
|
||||
)
|
||||
|
||||
_TUNING = ToolSearchTuning(
|
||||
id_boost=9.0,
|
||||
name_boost=9.0,
|
||||
name_exact_multiplier=10.0,
|
||||
stub_boost=2.0,
|
||||
section_boost=1.0,
|
||||
description_boost=0.5,
|
||||
label_boost=1.0,
|
||||
ngram_minsize=3,
|
||||
ngram_maxsize=4,
|
||||
enable_ngram_search=False,
|
||||
ngram_factor=0.2,
|
||||
)
|
||||
|
||||
|
||||
def _build_store_index(index_root, store_name, entries):
|
||||
tool_index = ToolIndex()
|
||||
for entry in entries:
|
||||
tool_index.add_entry(entry)
|
||||
index_dir = whoosh_dir_for_store(index_root, store_name)
|
||||
assert index_dir
|
||||
whoosh = ToolWhooshIndex(index_dir=index_dir, tuning=_TUNING)
|
||||
assert whoosh.build(tool_index) == len(entries)
|
||||
|
||||
|
||||
def test_search_merges_hits_across_store_indexes(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(ToolSearchTuning, "from_config", classmethod(lambda cls, config: _TUNING))
|
||||
index_root = str(tmp_path)
|
||||
_build_store_index(
|
||||
index_root,
|
||||
DEFAULT_STORE_NAME,
|
||||
[ToolIndexEntry(id="local_mapper", name="Sequence mapper", version="1.0")],
|
||||
)
|
||||
_build_store_index(
|
||||
index_root,
|
||||
"cvmfs_main",
|
||||
[ToolIndexEntry(id="cvmfs_mapper", name="Sequence mapper deluxe", version="2.0")],
|
||||
)
|
||||
config = cast(
|
||||
GalaxyAppConfiguration,
|
||||
SimpleNamespace(
|
||||
tool_search_index_dir=index_root,
|
||||
tool_source_stores={"cvmfs_main": {"type": "sqlalchemy"}},
|
||||
),
|
||||
)
|
||||
hits = LazyToolboxSearch(config).search("mapper", panel_view="default", config=config)
|
||||
assert set(hits) == {"local_mapper", "cvmfs_mapper"}
|
||||
|
||||
|
||||
def test_search_without_index_dir_returns_empty():
|
||||
config = cast(GalaxyAppConfiguration, SimpleNamespace(tool_search_index_dir=None, tool_source_stores={}))
|
||||
assert LazyToolboxSearch(config).search("mapper", panel_view="default", config=config) == []
|
||||
Reference in New Issue
Block a user