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

+17 -1
View File
@@ -1136,6 +1136,7 @@ def handle_semantic_index_menu(password_manager: PasswordManager) -> None:
print(color_text("4. Build", "menu"))
print(color_text("5. Rebuild", "menu"))
print(color_text("6. Search", "menu"))
print(color_text("7. Set search mode", "menu"))
choice = input("Select an option or press Enter to go back: ").strip()
try:
if choice == "1":
@@ -1145,7 +1146,8 @@ def handle_semantic_index_menu(password_manager: PasswordManager) -> None:
"Status: "
f"enabled={bool(payload.get('enabled', False))}, "
f"built={bool(payload.get('built', False))}, "
f"records={int(payload.get('records', 0))}",
f"records={int(payload.get('records', 0))}, "
f"mode={str(payload.get('mode', 'keyword'))}",
"green",
)
)
@@ -1210,6 +1212,20 @@ def handle_semantic_index_menu(password_manager: PasswordManager) -> None:
)
)
pause()
elif choice == "7":
mode = input("Mode (keyword/hybrid/semantic): ").strip().lower()
if mode not in {"keyword", "hybrid", "semantic"}:
print(colored("Invalid mode.", "red"))
pause()
continue
payload = service.set_mode(mode)
print(
colored(
f"Semantic search mode set to {payload.get('mode', mode)}.",
"green",
)
)
pause()
elif not choice:
break
else:
+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 = [
+29 -9
View File
@@ -1725,11 +1725,18 @@ async def test_semantic_endpoints(client, monkeypatch):
class DummySemanticService:
def __init__(self, _pm):
self.enabled = True
self.last_enabled = None
self.last_search = None
self.mode = "keyword"
def status(self):
return {"enabled": True, "built": True, "records": 4}
return {
"enabled": bool(self.enabled),
"built": True,
"records": 4,
"mode": self.mode,
}
def build(self):
return {"enabled": True, "built": True, "records": 4}
@@ -1739,10 +1746,22 @@ async def test_semantic_endpoints(client, monkeypatch):
def set_enabled(self, enabled: bool):
self.last_enabled = bool(enabled)
self.enabled = bool(enabled)
return {"enabled": bool(enabled)}
def search(self, query: str, *, k: int = 10, kind: str | None = None):
self.last_search = (query, k, kind)
def set_mode(self, mode: str):
self.mode = str(mode)
return self.status()
def search(
self,
query: str,
*,
k: int = 10,
kind: str | None = None,
mode: str | None = None,
):
self.last_search = (query, k, kind, mode)
return [{"entry_id": 10, "kind": kind or "document", "score": 0.5}]
service = DummySemanticService(None)
@@ -1763,21 +1782,22 @@ async def test_semantic_endpoints(client, monkeypatch):
configured = await cl.post(
"/api/v1/semantic/config",
json={"enabled": False},
json={"enabled": False, "mode": "hybrid"},
headers=headers,
)
assert configured.status_code == 200
assert configured.json() == {"enabled": False}
assert configured.json() == {"enabled": False, "mode": "hybrid"}
assert service.last_enabled is False
assert service.mode == "hybrid"
searched = await cl.post(
"/api/v1/semantic/search",
json={"query": "relay notes", "k": 6, "kind": "document"},
json={"query": "relay notes", "k": 6, "kind": "document", "mode": "semantic"},
headers=headers,
)
assert searched.status_code == 200
assert searched.json()["results"][0]["entry_id"] == 10
assert service.last_search == ("relay notes", 6, "document")
assert service.last_search == ("relay notes", 6, "document", "semantic")
@pytest.mark.anyio
@@ -1790,9 +1810,9 @@ async def test_semantic_search_requires_query(client):
@pytest.mark.anyio
async def test_semantic_config_requires_enabled_flag(client):
async def test_semantic_config_requires_enabled_or_mode(client):
cl, token = client
headers = {"Authorization": f"Bearer {token}"}
res = await cl.post("/api/v1/semantic/config", json={}, headers=headers)
assert res.status_code == 400
assert res.json() == {"detail": "Missing enabled"}
assert res.json() == {"detail": "Missing enabled or mode"}
+35 -3
View File
@@ -9,19 +9,33 @@ runner = CliRunner()
class _DummySemanticService:
def __init__(self) -> None:
self.mode = "keyword"
def status(self):
return {"enabled": True, "built": True, "records": 2}
return {"enabled": True, "built": True, "records": 2, "mode": self.mode}
def set_enabled(self, enabled: bool):
return {"enabled": bool(enabled), "built": True, "records": 2}
def set_mode(self, mode: str):
self.mode = str(mode)
return self.status()
def build(self):
return {"enabled": True, "built": True, "records": 3}
def rebuild(self):
return {"enabled": True, "built": True, "records": 3}
def search(self, query: str, *, k: int = 10, kind: str | None = None):
def search(
self,
query: str,
*,
k: int = 10,
kind: str | None = None,
mode: str | None = None,
):
if not query:
return []
return [
@@ -70,7 +84,25 @@ def test_semantic_cli_commands(monkeypatch):
result = runner.invoke(
app,
["semantic", "search", "relay", "--k", "3", "--kind", "document", "--json"],
[
"semantic",
"search",
"relay",
"--k",
"3",
"--kind",
"document",
"--mode",
"hybrid",
"--json",
],
)
assert result.exit_code == 0
assert '"entry_id": 7' in result.stdout
result = runner.invoke(
app,
["semantic", "config", "--enabled", "true", "--mode", "semantic"],
)
assert result.exit_code == 0
assert '"mode": "semantic"' in result.stdout
+18
View File
@@ -49,6 +49,7 @@ def test_semantic_index_build_and_search(tmp_path: Path) -> None:
class _DummyConfig:
def __init__(self) -> None:
self.enabled = False
self.mode = "keyword"
def set_semantic_index_enabled(self, enabled: bool) -> None:
self.enabled = bool(enabled)
@@ -56,6 +57,12 @@ class _DummyConfig:
def get_semantic_index_enabled(self) -> bool:
return bool(self.enabled)
def set_semantic_search_mode(self, mode: str) -> None:
self.mode = str(mode)
def get_semantic_search_mode(self) -> str:
return str(self.mode)
class _DummyEntryManager:
def __init__(self) -> None:
@@ -106,3 +113,14 @@ def test_semantic_index_service_build_and_status(tmp_path: Path) -> None:
results = service.search("agent", k=3)
assert len(results) >= 1
assert results[0]["entry_id"] == 1
service.set_mode("semantic")
semantic_only = service.search("agent", k=3, mode="semantic")
assert len(semantic_only) >= 1
service.set_mode("keyword")
keyword_only = service.search("agent", k=3, mode="keyword")
assert len(keyword_only) >= 1
hybrid = service.search("agent", k=3, mode="hybrid")
assert len(hybrid) >= 1
+9 -3
View File
@@ -375,9 +375,10 @@ def test_semantic_submenu_search_flow(monkeypatch, capsys):
class DummySemanticService:
def __init__(self, _pm):
self.last_query = None
self.mode = "keyword"
def status(self):
return {"enabled": False, "built": False, "records": 0}
return {"enabled": False, "built": False, "records": 0, "mode": self.mode}
def set_enabled(self, enabled: bool):
return {"enabled": bool(enabled), "records": 0}
@@ -388,6 +389,10 @@ def test_semantic_submenu_search_flow(monkeypatch, capsys):
def rebuild(self):
return {"enabled": True, "built": True, "records": 2}
def set_mode(self, mode: str):
self.mode = str(mode)
return {"mode": self.mode}
def search(self, query: str, *, k: int = 10, kind: str | None = None):
self.last_query = query
return [{"entry_id": 1, "kind": "document", "label": "Doc", "score": 0.5}]
@@ -397,11 +402,12 @@ def test_semantic_submenu_search_flow(monkeypatch, capsys):
pm, _, _ = setup_pm(tmp_path, monkeypatch)
monkeypatch.setattr(main, "SemanticIndexService", DummySemanticService)
monkeypatch.setattr(main, "pause", lambda: None)
inputs = iter(["1", "2", "4", "6", "relay docs", ""])
inputs = iter(["1", "7", "hybrid", "2", "4", "6", "relay docs", ""])
with patch("builtins.input", side_effect=lambda *_: next(inputs)):
main.handle_semantic_index_menu(pm)
out = capsys.readouterr().out
assert "Status: enabled=False, built=False, records=0" in out
assert "Status: enabled=False, built=False, records=0, mode=keyword" in out
assert "Semantic search mode set to hybrid." in out
assert "Semantic index enabled" in out
assert "Semantic index built with 2 records" in out
assert "Semantic Matches:" in out
+5
View File
@@ -561,6 +561,8 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
app._run_palette_command("setting-kdf-iterations 100000")
app._run_palette_command("setting-kdf-mode pbkdf2")
app._run_palette_command("semantic-status")
app._run_palette_command("search-mode")
app._run_palette_command("search-mode nope")
app._run_palette_command("semantic-enable now")
app._run_palette_command("semantic-disable now")
app._run_palette_command("semantic-build now")
@@ -607,6 +609,9 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None:
app._run_palette_command("lock")
app._run_palette_command("unlock hunter2")
app._run_palette_command("search Doc")
app._run_palette_command("search-mode hybrid")
app._run_palette_command("search-mode semantic")
app._run_palette_command("search-mode keyword")
app._run_palette_command("semantic-status")
app._run_palette_command("semantic-search notes")
app.action_focus_left()
+35 -1
View File
@@ -567,6 +567,7 @@ class FakeSemanticService:
self.enabled = False
self.built = False
self.records = 0
self.mode = "keyword"
self.last_query: str | None = None
def status(self):
@@ -574,6 +575,7 @@ class FakeSemanticService:
"enabled": bool(self.enabled),
"built": bool(self.built),
"records": int(self.records),
"mode": str(self.mode),
}
def set_enabled(self, enabled: bool):
@@ -590,8 +592,21 @@ class FakeSemanticService:
self.records = 2
return self.status()
def search(self, query: str, *, k: int = 10, kind: str | None = None):
def set_mode(self, mode: str):
self.mode = str(mode)
return self.status()
def search(
self,
query: str,
*,
k: int = 10,
kind: str | None = None,
mode: str | None = None,
):
self.last_query = str(query)
if mode:
self.mode = str(mode)
if not query.strip():
return []
return [
@@ -1669,6 +1684,11 @@ async def test_tui2_textual_profiles_and_settings_palette_commands() -> None:
assert semantic.enabled is True
assert "Semantic index enabled" in _status_text(app)
app._run_palette_command("search-mode hybrid")
await pilot.pause()
assert semantic.mode == "hybrid"
assert "Search mode set to hybrid" in _status_text(app)
app._run_palette_command("semantic-build")
await pilot.pause()
assert semantic.built is True
@@ -1916,6 +1936,10 @@ async def test_tui2_textual_profiles_and_settings_palette_validation() -> None:
await pilot.pause()
assert "Semantic service unavailable" in _status_text(app)
app._run_palette_command("search-mode semantic")
await pilot.pause()
assert "Semantic service unavailable" in _status_text(app)
app._run_palette_command("semantic-search")
await pilot.pause()
assert "Semantic service unavailable" in _status_text(app)
@@ -1974,6 +1998,16 @@ async def test_tui2_textual_profiles_and_settings_palette_validation() -> None:
await pilot.pause()
assert "Usage: semantic-enable" in _status_text(app)
app._run_palette_command("search-mode")
await pilot.pause()
assert "Usage: search-mode <keyword|hybrid|semantic>" in _status_text(app)
app._run_palette_command("search-mode nope")
await pilot.pause()
assert "search-mode must be one of: keyword, hybrid, semantic" in _status_text(
app
)
app._run_palette_command("semantic-search")
await pilot.pause()
assert "Usage: semantic-search <query>" in _status_text(app)