codebook / potato /search /service.py
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
3.76 kB
"""
Search service (universal).
Resolves the `search:` config block, builds the configured backend, and
holds a process singleton so the index is built once on server start and
reused per request. Mirrors the init/get/clear pattern of the other
managers.
"""
from __future__ import annotations
import logging
import threading
from typing import Any, Dict, Iterable, Optional, Tuple
from .backend import SearchBackend
from .fts5 import FTS5Backend
logger = logging.getLogger(__name__)
_SEARCH: Optional[SearchBackend] = None
_LOCK = threading.Lock()
_DEFAULTS = {
"enabled": True, # universal — on by default
"backend": "fts5",
"max_instances": 100000,
"annotator_claim": False, # annotator search-and-claim is opt-in
}
def search_settings(config: Dict[str, Any]) -> Dict[str, Any]:
"""Resolved search settings with defaults applied."""
raw = config.get("search")
raw = raw if isinstance(raw, dict) else {}
s = dict(_DEFAULTS)
for k in _DEFAULTS:
if k in raw:
s[k] = raw[k]
return s
def _build(config: Dict[str, Any]) -> Optional[SearchBackend]:
s = search_settings(config)
if not s["enabled"]:
return None
task_dir = config.get("task_dir", ".")
if s["backend"] == "fts5":
be = FTS5Backend(task_dir)
if be.available():
return be
logger.warning("FTS5 not available in this SQLite build; "
"search disabled.")
return None
logger.warning(f"Unknown search backend {s['backend']!r}; search disabled.")
return None
def init_search(
config: Dict[str, Any],
rows: Optional[Iterable[Tuple[str, str]]] = None,
) -> Optional[SearchBackend]:
"""Build the backend singleton and (optionally) index *rows*.
Returns None when search is disabled/unavailable. Calling twice keeps
the existing singleton (but re-indexes if rows are provided)."""
global _SEARCH
with _LOCK:
if _SEARCH is None:
_SEARCH = _build(config)
if _SEARCH is not None and rows is not None:
try:
_SEARCH.index(rows)
except Exception as e:
logger.error(f"Search index build failed: {e}")
return _SEARCH
def _rows_from_item_state(config: Dict[str, Any]):
"""Yield (instance_id, text) for every loaded instance, using the
config's text_key. Bounded by search.max_instances."""
from potato.item_state_management import get_item_state_manager
text_key = (config.get("item_properties") or {}).get("text_key", "text")
cap = search_settings(config)["max_instances"]
ism = get_item_state_manager()
for i, iid in enumerate(ism.get_instance_ids()):
if i >= cap:
logger.warning(
f"search.max_instances ({cap}) reached; not indexing the rest")
break
data = ism.get_item(iid).get_data()
if isinstance(data, dict):
text = data.get(text_key) or ism.get_item(iid).get_text()
else:
text = str(data)
yield str(iid), text if isinstance(text, str) else str(text)
def init_search_from_item_state(
config: Dict[str, Any]
) -> Optional[SearchBackend]:
"""Server-start entry point: build the backend and index all loaded
instances. No-op when search is disabled/unavailable."""
settings = search_settings(config)
if not settings["enabled"]:
logger.info("Search disabled in config")
return None
return init_search(config, rows=_rows_from_item_state(config))
def get_search() -> Optional[SearchBackend]:
return _SEARCH
def clear_search() -> None:
"""Reset the singleton. Tests only."""
global _SEARCH
with _LOCK:
_SEARCH = None