mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
feat: add semantic search modes across cli api and tuis
This commit is contained in:
1 parent
ec407c611a
commit
f0c120f84d
16 files changed
+460
-49
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user