gobby 0.2.5__py3-none-any.whl → 0.2.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +13 -4
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +395 -0
- gobby/agents/runner.py +8 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +385 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/install.py +4 -4
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +15 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +8 -8
- gobby/cli/installers/shared.py +175 -13
- gobby/cli/sessions.py +1 -1
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +12 -5
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +69 -91
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +9 -41
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +188 -2
- gobby/hooks/hook_manager.py +50 -4
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/skill_manager.py +130 -0
- gobby/hooks/webhooks.py +1 -1
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +98 -35
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +56 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -8
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/agents.py +31 -731
- gobby/mcp_proxy/tools/clones.py +518 -0
- gobby/mcp_proxy/tools/memory.py +3 -26
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
- gobby/mcp_proxy/tools/skills/__init__.py +616 -0
- gobby/mcp_proxy/tools/spawn_agent.py +417 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +0 -338
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +73 -285
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- gobby/runner.py +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +24 -12
- gobby/servers/routes/admin.py +294 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +1 -1
- gobby/servers/routes/mcp/tools.py +48 -1317
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +2 -0
- gobby/sessions/transcripts/claude.py +79 -10
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +286 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +162 -201
- gobby/storage/sessions.py +116 -7
- gobby/storage/skills.py +782 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +57 -7
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +40 -5
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +46 -35
- gobby/tools/summarizer.py +91 -10
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1135
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +93 -1
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +269 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
- gobby/workflows/engine.py +13 -2
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/loader.py +19 -6
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +154 -0
- gobby/workflows/safe_evaluator.py +183 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +111 -1
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1292
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- gobby/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/session_messages.py +0 -1056
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- gobby/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Search backend abstractions.
|
|
2
|
+
|
|
3
|
+
This module provides the protocol and implementations for search backends
|
|
4
|
+
used by UnifiedSearcher:
|
|
5
|
+
|
|
6
|
+
- AsyncSearchBackend: Protocol for async search backends
|
|
7
|
+
- TFIDFBackend: TF-IDF based search (always available)
|
|
8
|
+
- EmbeddingBackend: Embedding-based search (requires API)
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from gobby.search.backends import AsyncSearchBackend, TFIDFBackend
|
|
12
|
+
|
|
13
|
+
backend: AsyncSearchBackend = TFIDFBackend()
|
|
14
|
+
await backend.fit_async([("id1", "content1"), ("id2", "content2")])
|
|
15
|
+
results = await backend.search_async("query", top_k=10)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, Protocol, runtime_checkable
|
|
21
|
+
|
|
22
|
+
# Re-export sync TFIDFSearcher for backwards compatibility
|
|
23
|
+
from gobby.search.tfidf import TFIDFSearcher
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AsyncSearchBackend",
|
|
27
|
+
"TFIDFBackend",
|
|
28
|
+
"EmbeddingBackend",
|
|
29
|
+
"TFIDFSearcher",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@runtime_checkable
|
|
34
|
+
class AsyncSearchBackend(Protocol):
|
|
35
|
+
"""Protocol for async search backends.
|
|
36
|
+
|
|
37
|
+
All search backends must implement this interface. The protocol
|
|
38
|
+
uses async methods to support embedding-based backends that need
|
|
39
|
+
to call external APIs.
|
|
40
|
+
|
|
41
|
+
Methods:
|
|
42
|
+
fit_async: Build/rebuild the search index
|
|
43
|
+
search_async: Find relevant items for a query
|
|
44
|
+
needs_refit: Check if index needs rebuilding
|
|
45
|
+
get_stats: Get backend statistics
|
|
46
|
+
clear: Clear the search index
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
async def fit_async(self, items: list[tuple[str, str]]) -> None:
|
|
50
|
+
"""Build or rebuild the search index.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
items: List of (item_id, content) tuples to index
|
|
54
|
+
"""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
async def search_async(
|
|
58
|
+
self,
|
|
59
|
+
query: str,
|
|
60
|
+
top_k: int = 10,
|
|
61
|
+
) -> list[tuple[str, float]]:
|
|
62
|
+
"""Search for items matching the query.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
query: Search query text
|
|
66
|
+
top_k: Maximum number of results to return
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
List of (item_id, similarity_score) tuples, sorted by
|
|
70
|
+
relevance (highest similarity first).
|
|
71
|
+
"""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
def needs_refit(self) -> bool:
|
|
75
|
+
"""Check if the search index needs rebuilding.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if fit_async() should be called before search_async()
|
|
79
|
+
"""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
def get_stats(self) -> dict[str, Any]:
|
|
83
|
+
"""Get statistics about the search index.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Dict with backend-specific statistics
|
|
87
|
+
"""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
def clear(self) -> None:
|
|
91
|
+
"""Clear the search index."""
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TFIDFBackend:
|
|
96
|
+
"""Async wrapper around TFIDFSearcher.
|
|
97
|
+
|
|
98
|
+
Provides the AsyncSearchBackend interface for TF-IDF search.
|
|
99
|
+
This is a thin wrapper that delegates to the sync TFIDFSearcher.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
ngram_range: tuple[int, int] = (1, 2),
|
|
105
|
+
max_features: int = 10000,
|
|
106
|
+
min_df: int = 1,
|
|
107
|
+
stop_words: str | None = "english",
|
|
108
|
+
refit_threshold: int = 10,
|
|
109
|
+
):
|
|
110
|
+
"""Initialize TF-IDF backend.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
ngram_range: Min/max n-gram sizes for tokenization
|
|
114
|
+
max_features: Maximum vocabulary size
|
|
115
|
+
min_df: Minimum document frequency for inclusion
|
|
116
|
+
stop_words: Language for stop words (None to disable)
|
|
117
|
+
refit_threshold: Number of updates before automatic refit
|
|
118
|
+
"""
|
|
119
|
+
self._searcher = TFIDFSearcher(
|
|
120
|
+
ngram_range=ngram_range,
|
|
121
|
+
max_features=max_features,
|
|
122
|
+
min_df=min_df,
|
|
123
|
+
stop_words=stop_words,
|
|
124
|
+
refit_threshold=refit_threshold,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def fit_async(self, items: list[tuple[str, str]]) -> None:
|
|
128
|
+
"""Build or rebuild the search index."""
|
|
129
|
+
self._searcher.fit(items)
|
|
130
|
+
|
|
131
|
+
async def search_async(
|
|
132
|
+
self,
|
|
133
|
+
query: str,
|
|
134
|
+
top_k: int = 10,
|
|
135
|
+
) -> list[tuple[str, float]]:
|
|
136
|
+
"""Search for items matching the query."""
|
|
137
|
+
return self._searcher.search(query, top_k)
|
|
138
|
+
|
|
139
|
+
def needs_refit(self) -> bool:
|
|
140
|
+
"""Check if the search index needs rebuilding."""
|
|
141
|
+
return self._searcher.needs_refit()
|
|
142
|
+
|
|
143
|
+
def get_stats(self) -> dict[str, Any]:
|
|
144
|
+
"""Get statistics about the search index."""
|
|
145
|
+
stats = self._searcher.get_stats()
|
|
146
|
+
stats["backend_type"] = "tfidf"
|
|
147
|
+
return stats
|
|
148
|
+
|
|
149
|
+
def clear(self) -> None:
|
|
150
|
+
"""Clear the search index."""
|
|
151
|
+
self._searcher.clear()
|
|
152
|
+
|
|
153
|
+
def mark_update(self) -> None:
|
|
154
|
+
"""Mark that an item update occurred."""
|
|
155
|
+
self._searcher.mark_update()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Import EmbeddingBackend - needs to be at end to avoid circular imports
|
|
159
|
+
from gobby.search.backends.embedding import EmbeddingBackend # noqa: E402
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Embedding-based search backend.
|
|
2
|
+
|
|
3
|
+
This module provides embedding-based semantic search using cosine similarity.
|
|
4
|
+
It stores embeddings in memory and uses LiteLLM for embedding generation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import math
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from gobby.search.models import SearchConfig
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
|
|
20
|
+
"""Compute cosine similarity between two vectors.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
vec1: First vector
|
|
24
|
+
vec2: Second vector
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Cosine similarity score between -1 and 1
|
|
28
|
+
"""
|
|
29
|
+
if len(vec1) != len(vec2):
|
|
30
|
+
return 0.0
|
|
31
|
+
|
|
32
|
+
dot_product = sum(a * b for a, b in zip(vec1, vec2, strict=True))
|
|
33
|
+
norm1 = math.sqrt(sum(a * a for a in vec1))
|
|
34
|
+
norm2 = math.sqrt(sum(b * b for b in vec2))
|
|
35
|
+
|
|
36
|
+
if norm1 == 0 or norm2 == 0:
|
|
37
|
+
return 0.0
|
|
38
|
+
|
|
39
|
+
return dot_product / (norm1 * norm2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class EmbeddingBackend:
|
|
43
|
+
"""Embedding-based search backend using LiteLLM.
|
|
44
|
+
|
|
45
|
+
This backend generates embeddings for indexed items and uses
|
|
46
|
+
cosine similarity for search. Embeddings are stored in memory.
|
|
47
|
+
|
|
48
|
+
Supports all providers supported by LiteLLM:
|
|
49
|
+
- OpenAI (text-embedding-3-small)
|
|
50
|
+
- Ollama (openai/nomic-embed-text with api_base)
|
|
51
|
+
- Azure, Gemini, Mistral, etc.
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
backend = EmbeddingBackend(
|
|
55
|
+
model="text-embedding-3-small",
|
|
56
|
+
api_key="sk-..."
|
|
57
|
+
)
|
|
58
|
+
await backend.fit_async([("id1", "hello"), ("id2", "world")])
|
|
59
|
+
results = await backend.search_async("greeting", top_k=5)
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
model: str = "text-embedding-3-small",
|
|
65
|
+
api_base: str | None = None,
|
|
66
|
+
api_key: str | None = None,
|
|
67
|
+
):
|
|
68
|
+
"""Initialize embedding backend.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
model: LiteLLM model string
|
|
72
|
+
api_base: Optional API base URL for custom endpoints
|
|
73
|
+
api_key: Optional API key (uses env var if not set)
|
|
74
|
+
"""
|
|
75
|
+
self._model = model
|
|
76
|
+
self._api_base = api_base
|
|
77
|
+
self._api_key = api_key
|
|
78
|
+
|
|
79
|
+
# Item storage
|
|
80
|
+
self._item_ids: list[str] = []
|
|
81
|
+
self._item_embeddings: list[list[float]] = []
|
|
82
|
+
self._item_contents: dict[str, str] = {} # For reindexing
|
|
83
|
+
self._fitted = False
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_config(cls, config: SearchConfig) -> EmbeddingBackend:
|
|
87
|
+
"""Create an EmbeddingBackend from a SearchConfig.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
config: SearchConfig with model and API settings
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Configured EmbeddingBackend instance
|
|
94
|
+
"""
|
|
95
|
+
return cls(
|
|
96
|
+
model=config.embedding_model,
|
|
97
|
+
api_base=config.embedding_api_base,
|
|
98
|
+
api_key=config.embedding_api_key,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async def fit_async(self, items: list[tuple[str, str]]) -> None:
|
|
102
|
+
"""Build or rebuild the search index.
|
|
103
|
+
|
|
104
|
+
Generates embeddings for all items and stores them in memory.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
items: List of (item_id, content) tuples to index
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
RuntimeError: If embedding generation fails
|
|
111
|
+
"""
|
|
112
|
+
if not items:
|
|
113
|
+
self._item_ids = []
|
|
114
|
+
self._item_embeddings = []
|
|
115
|
+
self._item_contents = {}
|
|
116
|
+
self._fitted = False
|
|
117
|
+
logger.debug("Embedding index cleared (no items)")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
from gobby.search.embeddings import generate_embeddings
|
|
121
|
+
|
|
122
|
+
# Store contents for potential reindexing
|
|
123
|
+
self._item_ids = [item_id for item_id, _ in items]
|
|
124
|
+
self._item_contents = dict(items)
|
|
125
|
+
contents = [content for _, content in items]
|
|
126
|
+
|
|
127
|
+
# Generate embeddings in batch
|
|
128
|
+
try:
|
|
129
|
+
self._item_embeddings = await generate_embeddings(
|
|
130
|
+
texts=contents,
|
|
131
|
+
model=self._model,
|
|
132
|
+
api_base=self._api_base,
|
|
133
|
+
api_key=self._api_key,
|
|
134
|
+
)
|
|
135
|
+
self._fitted = True
|
|
136
|
+
logger.info(f"Embedding index built with {len(items)} items")
|
|
137
|
+
except Exception as e:
|
|
138
|
+
# Clear stale state to prevent inconsistent data
|
|
139
|
+
self._item_ids = []
|
|
140
|
+
self._item_contents = {}
|
|
141
|
+
self._item_embeddings = []
|
|
142
|
+
self._fitted = False
|
|
143
|
+
logger.error(f"Failed to build embedding index: {e}")
|
|
144
|
+
raise
|
|
145
|
+
|
|
146
|
+
async def search_async(
|
|
147
|
+
self,
|
|
148
|
+
query: str,
|
|
149
|
+
top_k: int = 10,
|
|
150
|
+
) -> list[tuple[str, float]]:
|
|
151
|
+
"""Search for items matching the query.
|
|
152
|
+
|
|
153
|
+
Generates an embedding for the query and finds items with
|
|
154
|
+
highest cosine similarity.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
query: Search query text
|
|
158
|
+
top_k: Maximum number of results to return
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
List of (item_id, similarity_score) tuples, sorted by
|
|
162
|
+
similarity descending.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
RuntimeError: If embedding generation fails
|
|
166
|
+
"""
|
|
167
|
+
if not self._fitted or not self._item_embeddings:
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
from gobby.search.embeddings import generate_embedding
|
|
171
|
+
|
|
172
|
+
# Generate query embedding
|
|
173
|
+
try:
|
|
174
|
+
query_embedding = await generate_embedding(
|
|
175
|
+
text=query,
|
|
176
|
+
model=self._model,
|
|
177
|
+
api_base=self._api_base,
|
|
178
|
+
api_key=self._api_key,
|
|
179
|
+
)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
logger.error(f"Failed to embed query: {e}")
|
|
182
|
+
raise
|
|
183
|
+
|
|
184
|
+
# Compute similarities
|
|
185
|
+
similarities: list[tuple[str, float]] = []
|
|
186
|
+
for item_id, item_embedding in zip(self._item_ids, self._item_embeddings, strict=True):
|
|
187
|
+
similarity = _cosine_similarity(query_embedding, item_embedding)
|
|
188
|
+
if similarity > 0:
|
|
189
|
+
similarities.append((item_id, similarity))
|
|
190
|
+
|
|
191
|
+
# Sort by similarity descending
|
|
192
|
+
similarities.sort(key=lambda x: x[1], reverse=True)
|
|
193
|
+
|
|
194
|
+
return similarities[:top_k]
|
|
195
|
+
|
|
196
|
+
def needs_refit(self) -> bool:
|
|
197
|
+
"""Check if the search index needs rebuilding."""
|
|
198
|
+
return not self._fitted
|
|
199
|
+
|
|
200
|
+
def get_stats(self) -> dict[str, Any]:
|
|
201
|
+
"""Get statistics about the search index."""
|
|
202
|
+
return {
|
|
203
|
+
"backend_type": "embedding",
|
|
204
|
+
"fitted": self._fitted,
|
|
205
|
+
"item_count": len(self._item_ids),
|
|
206
|
+
"model": self._model,
|
|
207
|
+
"has_api_base": self._api_base is not None,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
def clear(self) -> None:
|
|
211
|
+
"""Clear the search index."""
|
|
212
|
+
self._item_ids = []
|
|
213
|
+
self._item_embeddings = []
|
|
214
|
+
self._item_contents = {}
|
|
215
|
+
self._fitted = False
|
|
216
|
+
|
|
217
|
+
def get_item_contents(self) -> dict[str, str]:
|
|
218
|
+
"""Get stored item contents.
|
|
219
|
+
|
|
220
|
+
Useful for reindexing into a different backend (e.g., TF-IDF fallback).
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Dict mapping item_id to content
|
|
224
|
+
"""
|
|
225
|
+
return self._item_contents.copy()
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""LiteLLM-based embedding generation.
|
|
2
|
+
|
|
3
|
+
This module provides a unified interface for generating embeddings using
|
|
4
|
+
LiteLLM, which supports multiple providers through a single API:
|
|
5
|
+
|
|
6
|
+
| Provider | Model Format | Config |
|
|
7
|
+
|------------|--------------------------------|--------------------------------|
|
|
8
|
+
| OpenAI | text-embedding-3-small | OPENAI_API_KEY |
|
|
9
|
+
| Ollama | openai/nomic-embed-text | api_base=http://localhost:11434/v1 |
|
|
10
|
+
| Azure | azure/azure-embedding-model | api_base, api_key, api_version |
|
|
11
|
+
| Vertex AI | vertex_ai/text-embedding-004 | GCP credentials |
|
|
12
|
+
| Gemini | gemini/text-embedding-004 | GEMINI_API_KEY |
|
|
13
|
+
| Mistral | mistral/mistral-embed | MISTRAL_API_KEY |
|
|
14
|
+
|
|
15
|
+
Example usage:
|
|
16
|
+
from gobby.search.embeddings import generate_embeddings, is_embedding_available
|
|
17
|
+
|
|
18
|
+
if is_embedding_available("text-embedding-3-small"):
|
|
19
|
+
embeddings = await generate_embeddings(
|
|
20
|
+
texts=["hello world", "foo bar"],
|
|
21
|
+
model="text-embedding-3-small"
|
|
22
|
+
)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from gobby.search.models import SearchConfig
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def generate_embeddings(
|
|
38
|
+
texts: list[str],
|
|
39
|
+
model: str = "text-embedding-3-small",
|
|
40
|
+
api_base: str | None = None,
|
|
41
|
+
api_key: str | None = None,
|
|
42
|
+
) -> list[list[float]]:
|
|
43
|
+
"""Generate embeddings using LiteLLM.
|
|
44
|
+
|
|
45
|
+
Supports OpenAI, Ollama, Azure, Gemini, Mistral and other providers
|
|
46
|
+
through LiteLLM's unified API.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
texts: List of texts to embed
|
|
50
|
+
model: LiteLLM model string (e.g., "text-embedding-3-small",
|
|
51
|
+
"openai/nomic-embed-text" for Ollama)
|
|
52
|
+
api_base: Optional API base URL for custom endpoints (e.g., Ollama)
|
|
53
|
+
api_key: Optional API key (uses environment variable if not set)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
List of embedding vectors (one per input text). Returns an empty
|
|
57
|
+
list if the input texts list is empty.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
RuntimeError: If LiteLLM is not installed or embedding fails
|
|
61
|
+
"""
|
|
62
|
+
if not texts:
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
import litellm
|
|
67
|
+
from litellm.exceptions import (
|
|
68
|
+
AuthenticationError,
|
|
69
|
+
ContextWindowExceededError,
|
|
70
|
+
NotFoundError,
|
|
71
|
+
RateLimitError,
|
|
72
|
+
)
|
|
73
|
+
except ImportError as e:
|
|
74
|
+
raise RuntimeError("litellm package not installed. Run: uv add litellm") from e
|
|
75
|
+
|
|
76
|
+
# Build kwargs for LiteLLM
|
|
77
|
+
kwargs: dict[str, str | list[str]] = {
|
|
78
|
+
"model": model,
|
|
79
|
+
"input": texts,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if api_key:
|
|
83
|
+
kwargs["api_key"] = api_key
|
|
84
|
+
|
|
85
|
+
if api_base:
|
|
86
|
+
kwargs["api_base"] = api_base
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
response = await litellm.aembedding(**kwargs)
|
|
90
|
+
embeddings: list[list[float]] = [item["embedding"] for item in response.data]
|
|
91
|
+
logger.debug(f"Generated {len(embeddings)} embeddings via LiteLLM ({model})")
|
|
92
|
+
return embeddings
|
|
93
|
+
except AuthenticationError as e:
|
|
94
|
+
logger.error(f"LiteLLM authentication failed: {e}")
|
|
95
|
+
raise RuntimeError(f"Authentication failed: {e}") from e
|
|
96
|
+
except NotFoundError as e:
|
|
97
|
+
logger.error(f"LiteLLM model not found: {e}")
|
|
98
|
+
raise RuntimeError(f"Model not found: {e}") from e
|
|
99
|
+
except RateLimitError as e:
|
|
100
|
+
logger.error(f"LiteLLM rate limit exceeded: {e}")
|
|
101
|
+
raise RuntimeError(f"Rate limit exceeded: {e}") from e
|
|
102
|
+
except ContextWindowExceededError as e:
|
|
103
|
+
logger.error(f"LiteLLM context window exceeded: {e}")
|
|
104
|
+
raise RuntimeError(f"Context window exceeded: {e}") from e
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.error(f"Failed to generate embeddings with LiteLLM: {e}")
|
|
107
|
+
raise RuntimeError(f"Embedding generation failed: {e}") from e
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def generate_embedding(
|
|
111
|
+
text: str,
|
|
112
|
+
model: str = "text-embedding-3-small",
|
|
113
|
+
api_base: str | None = None,
|
|
114
|
+
api_key: str | None = None,
|
|
115
|
+
) -> list[float]:
|
|
116
|
+
"""Generate embedding for a single text.
|
|
117
|
+
|
|
118
|
+
Convenience wrapper around generate_embeddings for single texts.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
text: Text to embed
|
|
122
|
+
model: LiteLLM model string
|
|
123
|
+
api_base: Optional API base URL
|
|
124
|
+
api_key: Optional API key
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Embedding vector as list of floats
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
RuntimeError: If embedding generation fails
|
|
131
|
+
"""
|
|
132
|
+
embeddings = await generate_embeddings(
|
|
133
|
+
texts=[text],
|
|
134
|
+
model=model,
|
|
135
|
+
api_base=api_base,
|
|
136
|
+
api_key=api_key,
|
|
137
|
+
)
|
|
138
|
+
if not embeddings:
|
|
139
|
+
raise RuntimeError(
|
|
140
|
+
f"Embedding API returned empty result for model={model}, "
|
|
141
|
+
f"api_base={api_base}, api_key={'[set]' if api_key else '[not set]'}"
|
|
142
|
+
)
|
|
143
|
+
return embeddings[0]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def is_embedding_available(
|
|
147
|
+
model: str = "text-embedding-3-small",
|
|
148
|
+
api_key: str | None = None,
|
|
149
|
+
api_base: str | None = None,
|
|
150
|
+
) -> bool:
|
|
151
|
+
"""Check if embedding is available for the given model.
|
|
152
|
+
|
|
153
|
+
For local models (Ollama), assumes availability if api_base is set.
|
|
154
|
+
For cloud models, requires an API key.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
model: LiteLLM model string
|
|
158
|
+
api_key: Optional explicit API key
|
|
159
|
+
api_base: Optional API base URL
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
True if embeddings can be generated, False otherwise
|
|
163
|
+
"""
|
|
164
|
+
# Local models with api_base (Ollama, custom endpoints) are assumed available
|
|
165
|
+
if api_base:
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
# Check for Ollama-style models that use local endpoints
|
|
169
|
+
if model.startswith("ollama/"):
|
|
170
|
+
# Native Ollama models - assume available locally
|
|
171
|
+
# In practice, we'll catch connection errors at runtime
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
# openai/ prefix models require OpenAI API key
|
|
175
|
+
if model.startswith("openai/"):
|
|
176
|
+
effective_key = api_key or os.environ.get("OPENAI_API_KEY")
|
|
177
|
+
return effective_key is not None and len(effective_key) > 0
|
|
178
|
+
|
|
179
|
+
# Cloud models need API key
|
|
180
|
+
effective_key = api_key
|
|
181
|
+
|
|
182
|
+
# Check environment variables based on model prefix
|
|
183
|
+
if not effective_key:
|
|
184
|
+
if model.startswith("gemini/"):
|
|
185
|
+
effective_key = os.environ.get("GEMINI_API_KEY")
|
|
186
|
+
elif model.startswith("mistral/"):
|
|
187
|
+
effective_key = os.environ.get("MISTRAL_API_KEY")
|
|
188
|
+
elif model.startswith("azure/"):
|
|
189
|
+
effective_key = os.environ.get("AZURE_API_KEY")
|
|
190
|
+
elif model.startswith("vertex_ai/"):
|
|
191
|
+
# Vertex AI uses GCP credentials, check for project
|
|
192
|
+
effective_key = os.environ.get("VERTEXAI_PROJECT")
|
|
193
|
+
else:
|
|
194
|
+
# Default to OpenAI
|
|
195
|
+
effective_key = os.environ.get("OPENAI_API_KEY")
|
|
196
|
+
|
|
197
|
+
return effective_key is not None and len(effective_key) > 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def is_embedding_available_for_config(config: SearchConfig) -> bool:
|
|
201
|
+
"""Check if embedding is available for a SearchConfig.
|
|
202
|
+
|
|
203
|
+
Convenience wrapper that extracts config values.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
config: SearchConfig to check
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
True if embeddings can be generated, False otherwise
|
|
210
|
+
"""
|
|
211
|
+
return is_embedding_available(
|
|
212
|
+
model=config.embedding_model,
|
|
213
|
+
api_key=config.embedding_api_key,
|
|
214
|
+
api_base=config.embedding_api_base,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def generate_embeddings_for_config(
|
|
219
|
+
texts: list[str],
|
|
220
|
+
config: SearchConfig,
|
|
221
|
+
) -> list[list[float]]:
|
|
222
|
+
"""Generate embeddings using a SearchConfig.
|
|
223
|
+
|
|
224
|
+
Convenience wrapper that extracts config values.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
texts: List of texts to embed
|
|
228
|
+
config: SearchConfig with model and API settings
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
List of embedding vectors
|
|
232
|
+
"""
|
|
233
|
+
return await generate_embeddings(
|
|
234
|
+
texts=texts,
|
|
235
|
+
model=config.embedding_model,
|
|
236
|
+
api_base=config.embedding_api_base,
|
|
237
|
+
api_key=config.embedding_api_key,
|
|
238
|
+
)
|