From f0c120f84df73decc090db048417ab7fd5a99bf9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:28:24 -0500 Subject: [PATCH] feat: add semantic search modes across cli api and tuis --- docs/dev_control_center.md | 11 +- docs/semantic_vector_index_plan.md | 32 ++-- docs/tui_v2_ui_refresh_plan.md | 4 +- memory-update.md | 15 ++ src/main.py | 18 ++- src/seedpass/api.py | 29 +++- src/seedpass/cli/semantic.py | 38 ++++- src/seedpass/core/api.py | 145 +++++++++++++++++- src/seedpass/core/config_manager.py | 21 +++ src/seedpass/tui_v2/app.py | 49 +++++- src/tests/test_api_new_endpoints.py | 38 +++-- src/tests/test_cli_semantic.py | 38 ++++- src/tests/test_semantic_index.py | 18 +++ src/tests/test_settings_menu.py | 12 +- src/tests/test_tui_v2_action_matrix.py | 5 + src/tests/test_tui_v2_textual_interactions.py | 36 ++++- 16 files changed, 460 insertions(+), 49 deletions(-) diff --git a/docs/dev_control_center.md b/docs/dev_control_center.md index 61e2519d..3932eb99 100644 --- a/docs/dev_control_center.md +++ b/docs/dev_control_center.md @@ -74,7 +74,8 @@ Use these docs as inputs, but treat this file as the decision layer: - Current implementation status: - Phase A complete (local derived index + profile-scoped manifest/records + config flag) - Phase B baseline complete (CLI + API surfaces + initial tests) - - Phase C pending (legacy TUI + TUI v2 semantic workflow integration) + - Phase C complete baseline (legacy + TUI v2 semantic workflows and mode toggles) + - Next: incremental mutation hooks + hybrid quality hardening ## 4) Recommended Next Steps (Priority Order) @@ -83,9 +84,9 @@ Use these docs as inputs, but treat this file as the decision layer: : onboarding + stats/ops + lock/session affordances now landed; focus now is mockup-aligned UI refresh completion. 2. Complete Phase 5 TUI v2 UI refresh queue: : final visual polish pass (profile tree navigation/select is now landed). -3. Begin semantic index Phase C (legacy TUI + TUI v2 integration with status/build/search controls). -4. Add semantic incremental update hooks on entry/link/tag mutations. -5. Raise TUI v2 critical-path coverage beyond current gate floor (target >82% for `src/seedpass/tui_v2/app.py`). +3. Add semantic incremental update hooks on entry/link/tag mutations. +4. Raise TUI v2 critical-path coverage beyond current gate floor (target >82% for `src/seedpass/tui_v2/app.py`). +5. Expand semantic hybrid quality validation and KB stress evidence capture. 6. Advance Nostr resilience validation with optional real-relay soak lane after deterministic suites. ## 5) Next Slice Definition (Recommended Immediate Work) @@ -163,7 +164,7 @@ Progress update (2026-03-02): Current UI refresh remaining queue: 1. Final visual polish pass against mockups (spacing, headers, action strip clarity). -2. Semantic indicator slot in top ribbon/action strip (for upcoming Phase C semantic integration). +2. Tune semantic indicator wording/placement in top ribbon/action strip for readability under narrow terminals. ## 6) Working Rule diff --git a/docs/semantic_vector_index_plan.md b/docs/semantic_vector_index_plan.md index c6c4046c..c709a7d4 100644 --- a/docs/semantic_vector_index_plan.md +++ b/docs/semantic_vector_index_plan.md @@ -118,10 +118,11 @@ Add settings + search affordances: Palette commands: 1. `semantic-status` -2. `semantic-build` -3. `semantic-rebuild` -4. `semantic-search ` -5. `search-mode ` +2. `semantic-enable` / `semantic-disable` +3. `semantic-build` +4. `semantic-rebuild` +5. `semantic-search ` +6. `search-mode ` UI surfaces: @@ -150,6 +151,7 @@ Proposed endpoints: 2. `POST /api/v1/semantic/build` 3. `POST /api/v1/semantic/rebuild` 4. `POST /api/v1/semantic/search` +5. `POST /api/v1/semantic/config` Request examples: @@ -247,9 +249,9 @@ Exit: ## Phase C: TUI integration -1. [ ] wire legacy TUI settings/search hooks -2. [ ] wire TUI v2 palette commands + ribbon/status indicators -3. [ ] add hybrid search mode toggles +1. [x] wire legacy TUI settings/search hooks +2. [x] wire TUI v2 palette commands + ribbon/status indicators +3. [x] add hybrid search mode toggles Exit: @@ -276,7 +278,7 @@ Exit: 1. Complete Phase C integration in both TUIs (`semantic-status/build/rebuild/search` + status indicator). 2. Add incremental update hooks on entry/link/tag mutations (avoid full rebuild for small edits). -3. Add hybrid ranking mode (`keyword|hybrid|semantic`) with explicit config + API contract. +3. Expand hybrid ranking quality and retrieval relevance validation on KB stress datasets. 4. Add audit logging and lock-policy tests for semantic operations in agent/API contexts. 5. Run KB scale benchmark with semantic index enabled and record latency/memory evidence. @@ -289,6 +291,7 @@ Implemented: 3. `SemanticIndexService` in core API layer. 4. CLI commands: - `seedpass semantic status|enable|disable|build|rebuild|search` + - `seedpass semantic config --enabled --mode ` 5. API endpoints: - `GET /api/v1/semantic/status` - `POST /api/v1/semantic/build` @@ -296,10 +299,15 @@ Implemented: - `POST /api/v1/semantic/search` - `POST /api/v1/semantic/config` 6. Initial tests for core service/index, CLI commands, and API endpoints. +7. Semantic search mode support: +- profile config key `semantic_search_mode` (`keyword|hybrid|semantic`) +- API `semantic/search` mode override +- API `semantic/config` mode updates +- TUI v2 `search-mode` command + ribbon mode indicator +- legacy TUI semantic submenu mode setter Not yet implemented: -1. Legacy TUI semantic controls. -2. TUI v2 semantic palette integration/ribbon indicator. -3. True embedding backend and vector DB adapter (current implementation is token-overlap baseline). -4. Incremental mutation hooks and hybrid ranking mode. +1. True embedding backend and vector DB adapter (current implementation is token-overlap baseline). +2. Incremental mutation hooks (entry/tag/link CRUD triggers). +3. Audit/logging policy hardening and lock-state behavior validation. diff --git a/docs/tui_v2_ui_refresh_plan.md b/docs/tui_v2_ui_refresh_plan.md index 2aa2439a..1590d74c 100644 --- a/docs/tui_v2_ui_refresh_plan.md +++ b/docs/tui_v2_ui_refresh_plan.md @@ -530,7 +530,7 @@ Validation: Next recommended slice: -1. Final visual alignment pass to mockups (spacing, headers, action strip hierarchy) and semantic indicator slot prep. +1. Final visual alignment pass to mockups (spacing, headers, action strip hierarchy) and semantic indicator readability polish. ## 14) Current Status Snapshot @@ -546,5 +546,5 @@ Completed: Remaining (active queue): 1. Final alignment pass to mockup spacing/rhythm and action-strip clarity. -2. Add semantic-state indicator slot in ribbon/action strip for upcoming semantic Phase C. +2. Semantic-state indicator readability pass in ribbon/action strip for upcoming dense-status scenarios. 3. Run focused TUI tests and coverage gate after each polish slice. diff --git a/memory-update.md b/memory-update.md index 49815951..c28eb4b0 100644 --- a/memory-update.md +++ b/memory-update.md @@ -1126,3 +1126,18 @@ - `test_tui_v2_textual_interactions.py` semantic command success/validation paths - `test_tui_v2_action_matrix.py` semantic command matrix coverage - Validation run: `102 passed` across targeted settings/TUI/semantic/CLI suites. + +## Memory Update (2026-03-02, semantic mode and interface expansion) +- Added profile-scoped semantic mode config (`semantic_search_mode`) with allowed values: `keyword|hybrid|semantic`. +- `SemanticIndexService.search()` now supports mode-aware retrieval: + - `keyword`: lexical fallback via `entry_manager.search_entries` + - `semantic`: local semantic index results + - `hybrid`: weighted merge of semantic + lexical candidates +- Added semantic mode controls across interfaces: + - CLI: `seedpass semantic config --mode ...` and `seedpass semantic search --mode ...` + - API: `/api/v1/semantic/config` accepts `enabled` and/or `mode`; `/api/v1/semantic/search` accepts optional `mode` + - TUI v2: `search-mode ` palette command + ribbon mode indicator + - Legacy TUI: Settings > Semantic Index > Set search mode +- Fixed deadlock in `SemanticIndexService.set_mode()` (lock re-entry via `status()` call while holding lock). +- Updated tests for semantic mode behavior across API/CLI/TUI/legacy settings and core service paths. +- Expanded targeted validation result: `231 passed` for semantic + API + core + settings + TUI + typer slices. diff --git a/src/main.py b/src/main.py index a737f6ba..82182603 100644 --- a/src/main.py +++ b/src/main.py @@ -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: diff --git a/src/seedpass/api.py b/src/seedpass/api.py index c02d6227..b922edd1 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -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") diff --git a/src/seedpass/cli/semantic.py b/src/seedpass/cli/semantic.py index c7173d92..99a44197 100644 --- a/src/seedpass/cli/semantic.py +++ b/src/seedpass/cli/semantic.py @@ -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)) diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index 4987c262..e10d9f9b 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -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: diff --git a/src/seedpass/core/config_manager.py b/src/seedpass/core/config_manager.py index 3c011035..3a67eb3d 100644 --- a/src/seedpass/core/config_manager.py +++ b/src/seedpass/core/config_manager.py @@ -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: diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py index 6277a23c..6dde0f5a 100644 --- a/src/seedpass/tui_v2/app.py +++ b/src/seedpass/tui_v2/app.py @@ -344,7 +344,7 @@ def launch_tui2( "setting-secret [delay] | setting-offline | " "setting-quick-unlock | setting-timeout | " "setting-kdf-iterations | setting-kdf-mode | " - "semantic-status | semantic-enable | semantic-disable | semantic-build | semantic-rebuild | semantic-search | " + "search-mode | semantic-status | semantic-enable | semantic-disable | semantic-build | semantic-rebuild | semantic-search | " "relay-list | relay-add | relay-rm | " "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 ", "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 ", + "Semantic: search-mode | semantic-status | semantic-enable | semantic-disable | semantic-build | semantic-rebuild | semantic-search ", "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 ", @@ -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 ") + 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 ") 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 = [ diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index b1eb4326..e5681c93 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -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"} diff --git a/src/tests/test_cli_semantic.py b/src/tests/test_cli_semantic.py index e8c96313..9e3ce851 100644 --- a/src/tests/test_cli_semantic.py +++ b/src/tests/test_cli_semantic.py @@ -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 diff --git a/src/tests/test_semantic_index.py b/src/tests/test_semantic_index.py index 14c57347..6aedc675 100644 --- a/src/tests/test_semantic_index.py +++ b/src/tests/test_semantic_index.py @@ -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 diff --git a/src/tests/test_settings_menu.py b/src/tests/test_settings_menu.py index 25d3ae90..24f6d7aa 100644 --- a/src/tests/test_settings_menu.py +++ b/src/tests/test_settings_menu.py @@ -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 diff --git a/src/tests/test_tui_v2_action_matrix.py b/src/tests/test_tui_v2_action_matrix.py index 5a2dee6d..df70e694 100644 --- a/src/tests/test_tui_v2_action_matrix.py +++ b/src/tests/test_tui_v2_action_matrix.py @@ -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() diff --git a/src/tests/test_tui_v2_textual_interactions.py b/src/tests/test_tui_v2_textual_interactions.py index 29b2e38b..62c8b4c8 100644 --- a/src/tests/test_tui_v2_textual_interactions.py +++ b/src/tests/test_tui_v2_textual_interactions.py @@ -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 " 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 " in _status_text(app)