feat: add semantic search modes across cli api and tuis

This commit is contained in:
thePR0M3TH3AN committed 2026-03-02 12:28:24 -05:00
1 parent ec407c611a
commit f0c120f84d
16 files changed
+460 -49

No files matched your search

+24 -5
View File
@@ -1451,7 +1451,13 @@ def semantic_search(
k = int(data.get("k", 10))
kind_raw = data.get("kind")
kind = str(kind_raw).strip() if kind_raw is not None else None
results = _semantic_service(request).search(query, k=max(1, k), kind=kind)
mode_raw = data.get("mode")
mode = str(mode_raw).strip().lower() if mode_raw is not None else None
if mode is not None and mode not in {"keyword", "hybrid", "semantic"}:
raise HTTPException(status_code=400, detail="Invalid mode")
results = _semantic_service(request).search(
query, k=max(1, k), kind=kind, mode=mode
)
return {"results": results}
@@ -1463,10 +1469,23 @@ def semantic_config(
) -> dict[str, Any]:
_check_token(request, authorization)
_require_unlocked(request)
if "enabled" not in data:
raise HTTPException(status_code=400, detail="Missing enabled")
payload = _semantic_service(request).set_enabled(bool(data.get("enabled")))
return {"enabled": bool(payload.get("enabled", False))}
has_enabled = "enabled" in data
has_mode = "mode" in data
if not has_enabled and not has_mode:
raise HTTPException(status_code=400, detail="Missing enabled or mode")
service = _semantic_service(request)
if has_enabled:
service.set_enabled(bool(data.get("enabled")))
if has_mode:
mode = str(data.get("mode", "")).strip().lower()
if mode not in {"keyword", "hybrid", "semantic"}:
raise HTTPException(status_code=400, detail="Invalid mode")
service.set_mode(mode)
payload = service.status()
return {
"enabled": bool(payload.get("enabled", False)),
"mode": str(payload.get("mode", "keyword")),
}
@app.get("/api/v1/fingerprint")
+37 -1
View File
@@ -11,6 +11,7 @@ from seedpass.core.api import SemanticIndexService
from seedpass.core.entry_types import ALL_ENTRY_TYPES
app = typer.Typer(help="Manage local semantic vector index and search")
SEARCH_MODES = ["keyword", "hybrid", "semantic"]
def _get_semantic_service(ctx: typer.Context) -> SemanticIndexService:
@@ -76,11 +77,17 @@ def semantic_search(
help="Restrict to entry kind",
click_type=click.Choice(ALL_ENTRY_TYPES),
),
mode: Optional[str] = typer.Option(
None,
"--mode",
help="Search mode override",
click_type=click.Choice(SEARCH_MODES),
),
as_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
) -> None:
"""Run semantic search over locally indexed KB content."""
service = _get_semantic_service(ctx)
results = service.search(query, k=max(1, int(k)), kind=kind)
results = service.search(query, k=max(1, int(k)), kind=kind, mode=mode)
if as_json:
typer.echo(json.dumps(results, indent=2, sort_keys=True))
return
@@ -96,3 +103,32 @@ def semantic_search(
typer.echo(f"{entry_id}: [{entry_kind}] {label} (score={score:.3f})")
if excerpt:
typer.echo(f" {excerpt}")
@app.command("config")
def semantic_config(
ctx: typer.Context,
enabled: Optional[str] = typer.Option(
None,
"--enabled",
help="Enable or disable semantic index for this profile",
click_type=click.Choice(["true", "false"]),
),
mode: Optional[str] = typer.Option(
None,
"--mode",
help="Default semantic search mode",
click_type=click.Choice(SEARCH_MODES),
),
) -> None:
"""Configure semantic index behavior for the active profile."""
if enabled is None and mode is None:
typer.echo("Provide at least one option: --enabled or --mode")
raise typer.Exit(code=1)
service = _get_semantic_service(ctx)
if enabled is not None:
service.set_enabled(enabled == "true")
if mode is not None:
service.set_mode(mode)
payload = service.status()
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
+143 -2
View File
@@ -783,6 +783,7 @@ class ConfigService:
"set_quick_unlock",
lambda v: v.lower() in ("1", "true", "yes", "y", "on"),
),
"semantic_search_mode": ("set_semantic_search_mode", lambda v: v),
}
entry = mapping.get(key)
if entry is None:
@@ -835,6 +836,14 @@ class ConfigService:
with self._lock:
return self._manager.config_manager.get_semantic_index_enabled()
def set_semantic_search_mode(self, mode: str) -> None:
with self._lock:
self._manager.config_manager.set_semantic_search_mode(mode)
def get_semantic_search_mode(self) -> str:
with self._lock:
return self._manager.config_manager.get_semantic_search_mode()
class SemanticIndexService:
"""Thread-safe wrapper around local semantic index operations."""
@@ -877,6 +886,7 @@ class SemanticIndexService:
payload["enabled"] = bool(
self._manager.config_manager.get_semantic_index_enabled()
)
payload["mode"] = self._manager.config_manager.get_semantic_search_mode()
return payload
def set_enabled(self, enabled: bool) -> Dict[str, Any]:
@@ -886,8 +896,24 @@ class SemanticIndexService:
idx.set_enabled(bool(enabled))
payload = idx.status()
payload["enabled"] = bool(enabled)
payload["mode"] = self._manager.config_manager.get_semantic_search_mode()
return payload
def set_mode(self, mode: str) -> Dict[str, Any]:
with self._lock:
self._manager.config_manager.set_semantic_search_mode(mode)
idx = self._index()
payload = idx.status()
payload["enabled"] = bool(
self._manager.config_manager.get_semantic_index_enabled()
)
payload["mode"] = self._manager.config_manager.get_semantic_search_mode()
return payload
def get_mode(self) -> str:
with self._lock:
return self._manager.config_manager.get_semantic_search_mode()
def build(self) -> Dict[str, Any]:
with self._lock:
idx = self._index()
@@ -901,11 +927,126 @@ class SemanticIndexService:
return idx.rebuild(self._all_entries())
def search(
self, query: str, *, k: int = 10, kind: str | None = None
self,
query: str,
*,
k: int = 10,
kind: str | None = None,
mode: str | None = None,
) -> list[Dict[str, Any]]:
with self._lock:
active_mode = (
str(mode or self._manager.config_manager.get_semantic_search_mode())
.strip()
.lower()
)
if active_mode not in {"keyword", "hybrid", "semantic"}:
active_mode = "keyword"
if active_mode == "keyword":
return self._lexical_search(query, k=k, kind=kind)
idx = self._index()
return idx.search(query, k=k, kind=kind)
semantic_rows = idx.search(query, k=k, kind=kind)
if active_mode == "semantic":
return semantic_rows
lexical_rows = self._lexical_search(query, k=k, kind=kind)
return self._merge_hybrid(semantic_rows, lexical_rows, k=max(1, int(k)))
def _lexical_search(
self, query: str, *, k: int = 10, kind: str | None = None
) -> list[Dict[str, Any]]:
em = getattr(self._manager, "entry_manager", None)
if em is None:
return []
kinds = [kind] if kind else None
rows = em.search_entries(
str(query),
kinds=kinds,
include_archived=True,
archived_only=False,
)
out: list[Dict[str, Any]] = []
max_rows = max(1, int(k))
for idx, row in enumerate(rows[:max_rows], start=1):
try:
entry_id = int(row[0])
except Exception:
continue
label = str(row[1]) if len(row) > 1 else ""
kind_value = ""
if len(row) > 5:
kind_cell = row[5]
kind_value = str(getattr(kind_cell, "value", kind_cell))
if not kind_value:
kind_value = str(kind or "")
entry = em.retrieve_entry(entry_id)
excerpt = ""
if isinstance(entry, dict):
for field in ("notes", "content", "value", "username", "url"):
text = str(entry.get(field, "")).strip()
if text:
excerpt = text[:220]
break
# lexical rank-based score in (0,1]
score = 1.0 - ((idx - 1) / max(1, max_rows))
out.append(
{
"entry_id": entry_id,
"kind": kind_value,
"label": label,
"score": round(score, 6),
"excerpt": excerpt,
}
)
return out
@staticmethod
def _merge_hybrid(
semantic_rows: list[Dict[str, Any]],
lexical_rows: list[Dict[str, Any]],
*,
k: int,
) -> list[Dict[str, Any]]:
merged: dict[int, Dict[str, Any]] = {}
for row in semantic_rows:
entry_id = int(row.get("entry_id", 0))
if entry_id <= 0:
continue
merged[entry_id] = {
"entry_id": entry_id,
"kind": str(row.get("kind", "")),
"label": str(row.get("label", "")),
"excerpt": str(row.get("excerpt", "")),
"score": float(row.get("score", 0.0)) * 0.7,
}
for row in lexical_rows:
entry_id = int(row.get("entry_id", 0))
if entry_id <= 0:
continue
lex_boost = float(row.get("score", 0.0)) * 0.3
current = merged.get(entry_id)
if current is None:
merged[entry_id] = {
"entry_id": entry_id,
"kind": str(row.get("kind", "")),
"label": str(row.get("label", "")),
"excerpt": str(row.get("excerpt", "")),
"score": lex_boost,
}
continue
current["score"] = float(current.get("score", 0.0)) + lex_boost
if not str(current.get("excerpt", "")).strip():
current["excerpt"] = str(row.get("excerpt", ""))
if not str(current.get("label", "")).strip():
current["label"] = str(row.get("label", ""))
if not str(current.get("kind", "")).strip():
current["kind"] = str(row.get("kind", ""))
items = list(merged.values())
items.sort(
key=lambda r: (-float(r.get("score", 0.0)), int(r.get("entry_id", 0)))
)
for row in items:
row["score"] = round(float(row.get("score", 0.0)), 6)
return items[: max(1, int(k))]
class UtilityService:
+21
View File
@@ -95,6 +95,7 @@ class ConfigManager:
"exclude_ambiguous": False,
"verbose_timing": False,
"semantic_index_enabled": False,
"semantic_search_mode": "keyword",
"secret_class_partitions": dict(self.SECRET_CLASS_PARTITIONS_DEFAULT),
}
try:
@@ -128,6 +129,7 @@ class ConfigManager:
data.setdefault("exclude_ambiguous", False)
data.setdefault("verbose_timing", False)
data.setdefault("semantic_index_enabled", False)
data.setdefault("semantic_search_mode", "keyword")
data.setdefault(
"secret_class_partitions",
dict(self.SECRET_CLASS_PARTITIONS_DEFAULT),
@@ -294,6 +296,25 @@ class ConfigManager:
config = self.load_config(require_pin=False)
return bool(config.get("semantic_index_enabled", False))
def set_semantic_search_mode(self, mode: str) -> None:
"""Persist semantic search mode for this profile."""
normalized = str(mode).strip().lower()
if normalized not in {"keyword", "hybrid", "semantic"}:
raise ValueError(
"semantic_search_mode must be keyword, hybrid, or semantic"
)
config = self.load_config(require_pin=False)
config["semantic_search_mode"] = normalized
self.save_config(config)
def get_semantic_search_mode(self) -> str:
"""Return semantic search mode for this profile."""
config = self.load_config(require_pin=False)
normalized = str(config.get("semantic_search_mode", "keyword")).strip().lower()
if normalized not in {"keyword", "hybrid", "semantic"}:
return "keyword"
return normalized
def set_clipboard_clear_delay(self, delay: int) -> None:
"""Persist clipboard clear timeout in seconds."""
if delay <= 0:
+44 -5
View File
@@ -344,7 +344,7 @@ def launch_tui2(
"setting-secret <on|off> [delay] | setting-offline <on|off> | "
"setting-quick-unlock <on|off> | setting-timeout <seconds> | "
"setting-kdf-iterations <n> | setting-kdf-mode <mode> | "
"semantic-status | semantic-enable | semantic-disable | semantic-build | semantic-rebuild | semantic-search <query> | "
"search-mode <keyword|hybrid|semantic> | semantic-status | semantic-enable | semantic-disable | semantic-build | semantic-rebuild | semantic-search <query> | "
"relay-list | relay-add <url> | relay-rm <index> | "
"relay-reset | npub | nostr-reset-sync-state | nostr-fresh-namespace | "
"sync-now | sync-bg | "
@@ -417,6 +417,7 @@ def launch_tui2(
self._vault_service = None
self._semantic_service = None
self._semantic_state = "n/a"
self._semantic_mode = "keyword"
self._selected_entry_id: int | None = None
self._selected_entry: dict[str, Any] | None = None
self._last_query = ""
@@ -577,7 +578,7 @@ def launch_tui2(
f" | Archive: {self.archive_scope}"
f" | Session: {lock_state}"
f" | Density: {self._density_mode}"
f" | SEM: {self._semantic_state}"
f" | SEM: {self._semantic_state}/{self._semantic_mode}"
f" | Last Sync: {self._last_sync_text}"
)
self.query_one("#top-ribbon", Static).update(text)
@@ -595,6 +596,10 @@ def launch_tui2(
enabled = bool(payload.get("enabled", False))
built = bool(payload.get("built", False))
records = int(payload.get("records", 0))
mode = str(payload.get("mode", "keyword")).strip().lower()
self._semantic_mode = (
mode if mode in {"keyword", "hybrid", "semantic"} else "keyword"
)
if not enabled:
self._semantic_state = "off"
elif built:
@@ -705,7 +710,7 @@ def launch_tui2(
"Palette commands: help, help-commands, open, search, filter, "
"onboarding, quickstart, stats, session-status, lock/unlock, "
"density, profile-tree-next/prev/open, "
"semantic-status/build/rebuild/search, "
"search-mode, semantic-status/build/rebuild/search, "
"archive, restore, archive-filter, edit-doc/save-doc/cancel-edit, "
"link-add/link-rm/link-filter/link-next/link-prev/link-open, "
"add-*, notes/tags/fields, 2fa-*, profile-*, setting-*, relay-*, "
@@ -736,7 +741,7 @@ def launch_tui2(
"2FA: 2fa-board | 2fa-hide | 2fa-refresh | 2fa-copy <entry_id>",
"Profiles: profiles-list | profile-switch | profile-add | profile-remove | profile-rename | profile-tree-next | profile-tree-prev | profile-tree-open",
"Settings: setting-secret | setting-offline | setting-quick-unlock | setting-timeout | setting-kdf-iterations | setting-kdf-mode",
"Semantic: semantic-status | semantic-enable | semantic-disable | semantic-build | semantic-rebuild | semantic-search <query>",
"Semantic: search-mode <keyword|hybrid|semantic> | semantic-status | semantic-enable | semantic-disable | semantic-build | semantic-rebuild | semantic-search <query>",
"Nostr: relay-list | relay-add | relay-rm | relay-reset | npub | nostr-reset-sync-state | nostr-fresh-namespace",
"Sync/Utility: sync-now | sync-bg | checksum-verify | checksum-update | db-export | db-import | totp-export | parent-seed-backup",
"Sessions: managed-load [entry_id] | managed-exit | session-status | lock | unlock <password>",
@@ -3378,6 +3383,38 @@ def launch_tui2(
)
return
if cmd == "search-mode":
if self._semantic_service is None:
self._set_status("Semantic service unavailable")
return
if len(args) != 1:
self._set_status("Usage: search-mode <keyword|hybrid|semantic>")
return
mode = args[0].strip().lower()
if mode not in {"keyword", "hybrid", "semantic"}:
self._set_status(
"search-mode must be one of: keyword, hybrid, semantic"
)
return
setter = getattr(self._semantic_service, "set_mode", None)
if not callable(setter):
self._set_status("Semantic service does not support search mode")
return
try:
setter(mode)
self._refresh_semantic_state()
self._update_top_ribbon()
self._clear_failure()
self._set_status(f"Search mode set to {mode}")
except Exception as exc:
self._record_failure(
"search-mode failed",
exc,
retry=lambda: self._run_palette_command(raw),
hint="Press 'x' to retry.",
)
return
if cmd in {"semantic-enable", "semantic-disable"}:
if self._semantic_service is None:
self._set_status("Semantic service unavailable")
@@ -3458,7 +3495,9 @@ def launch_tui2(
self._set_status("Usage: semantic-search <query>")
return
try:
results = list(searcher(query, k=10, kind=None) or [])
results = list(
searcher(query, k=10, kind=None, mode=self._semantic_mode) or []
)
self._refresh_semantic_state()
self._update_top_ribbon()
lines = [