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

+6 -5
View File
@@ -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
+20 -12
View File
@@ -118,10 +118,11 @@ Add settings + search affordances:
Palette commands:
1. `semantic-status`
2. `semantic-build`
3. `semantic-rebuild`
4. `semantic-search <query>`
5. `search-mode <keyword|hybrid|semantic>`
2. `semantic-enable` / `semantic-disable`
3. `semantic-build`
4. `semantic-rebuild`
5. `semantic-search <query>`
6. `search-mode <keyword|hybrid|semantic>`
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 <true|false> --mode <keyword|hybrid|semantic>`
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.
+2 -2
View File
@@ -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.
+15
View File
@@ -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 <keyword|hybrid|semantic>` 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.
+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)