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.
Files changed (244) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +13 -4
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/agents/definitions.py +11 -1
  10. gobby/agents/isolation.py +395 -0
  11. gobby/agents/runner.py +8 -0
  12. gobby/agents/sandbox.py +261 -0
  13. gobby/agents/spawn.py +42 -287
  14. gobby/agents/spawn_executor.py +385 -0
  15. gobby/agents/spawners/__init__.py +24 -0
  16. gobby/agents/spawners/command_builder.py +189 -0
  17. gobby/agents/spawners/embedded.py +21 -2
  18. gobby/agents/spawners/headless.py +21 -2
  19. gobby/agents/spawners/prompt_manager.py +125 -0
  20. gobby/cli/__init__.py +6 -0
  21. gobby/cli/clones.py +419 -0
  22. gobby/cli/conductor.py +266 -0
  23. gobby/cli/install.py +4 -4
  24. gobby/cli/installers/antigravity.py +3 -9
  25. gobby/cli/installers/claude.py +15 -9
  26. gobby/cli/installers/codex.py +2 -8
  27. gobby/cli/installers/gemini.py +8 -8
  28. gobby/cli/installers/shared.py +175 -13
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/skills.py +858 -0
  31. gobby/cli/tasks/ai.py +0 -440
  32. gobby/cli/tasks/crud.py +44 -6
  33. gobby/cli/tasks/main.py +0 -4
  34. gobby/cli/tui.py +2 -2
  35. gobby/cli/utils.py +12 -5
  36. gobby/clones/__init__.py +13 -0
  37. gobby/clones/git.py +547 -0
  38. gobby/conductor/__init__.py +16 -0
  39. gobby/conductor/alerts.py +135 -0
  40. gobby/conductor/loop.py +164 -0
  41. gobby/conductor/monitors/__init__.py +11 -0
  42. gobby/conductor/monitors/agents.py +116 -0
  43. gobby/conductor/monitors/tasks.py +155 -0
  44. gobby/conductor/pricing.py +234 -0
  45. gobby/conductor/token_tracker.py +160 -0
  46. gobby/config/__init__.py +12 -97
  47. gobby/config/app.py +69 -91
  48. gobby/config/extensions.py +2 -2
  49. gobby/config/features.py +7 -130
  50. gobby/config/search.py +110 -0
  51. gobby/config/servers.py +1 -1
  52. gobby/config/skills.py +43 -0
  53. gobby/config/tasks.py +9 -41
  54. gobby/hooks/__init__.py +0 -13
  55. gobby/hooks/event_handlers.py +188 -2
  56. gobby/hooks/hook_manager.py +50 -4
  57. gobby/hooks/plugins.py +1 -1
  58. gobby/hooks/skill_manager.py +130 -0
  59. gobby/hooks/webhooks.py +1 -1
  60. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  61. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  62. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  63. gobby/llm/claude.py +22 -34
  64. gobby/llm/claude_executor.py +46 -256
  65. gobby/llm/codex_executor.py +59 -291
  66. gobby/llm/executor.py +21 -0
  67. gobby/llm/gemini.py +134 -110
  68. gobby/llm/litellm_executor.py +143 -6
  69. gobby/llm/resolver.py +98 -35
  70. gobby/mcp_proxy/importer.py +62 -4
  71. gobby/mcp_proxy/instructions.py +56 -0
  72. gobby/mcp_proxy/models.py +15 -0
  73. gobby/mcp_proxy/registries.py +68 -8
  74. gobby/mcp_proxy/server.py +33 -3
  75. gobby/mcp_proxy/services/recommendation.py +43 -11
  76. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  77. gobby/mcp_proxy/stdio.py +2 -1
  78. gobby/mcp_proxy/tools/__init__.py +0 -2
  79. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  80. gobby/mcp_proxy/tools/agents.py +31 -731
  81. gobby/mcp_proxy/tools/clones.py +518 -0
  82. gobby/mcp_proxy/tools/memory.py +3 -26
  83. gobby/mcp_proxy/tools/metrics.py +65 -1
  84. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  85. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  86. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  87. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  88. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  89. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  90. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  91. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  92. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  93. gobby/mcp_proxy/tools/skills/__init__.py +616 -0
  94. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  95. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  96. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  97. gobby/mcp_proxy/tools/task_sync.py +1 -1
  98. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  99. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  100. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  101. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  102. gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
  103. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  104. gobby/mcp_proxy/tools/workflows.py +1 -1
  105. gobby/mcp_proxy/tools/worktrees.py +0 -338
  106. gobby/memory/backends/__init__.py +6 -1
  107. gobby/memory/backends/mem0.py +6 -1
  108. gobby/memory/extractor.py +477 -0
  109. gobby/memory/ingestion/__init__.py +5 -0
  110. gobby/memory/ingestion/multimodal.py +221 -0
  111. gobby/memory/manager.py +73 -285
  112. gobby/memory/search/__init__.py +10 -0
  113. gobby/memory/search/coordinator.py +248 -0
  114. gobby/memory/services/__init__.py +5 -0
  115. gobby/memory/services/crossref.py +142 -0
  116. gobby/prompts/loader.py +5 -2
  117. gobby/runner.py +37 -16
  118. gobby/search/__init__.py +48 -6
  119. gobby/search/backends/__init__.py +159 -0
  120. gobby/search/backends/embedding.py +225 -0
  121. gobby/search/embeddings.py +238 -0
  122. gobby/search/models.py +148 -0
  123. gobby/search/unified.py +496 -0
  124. gobby/servers/http.py +24 -12
  125. gobby/servers/routes/admin.py +294 -0
  126. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  127. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  128. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  129. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  130. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  131. gobby/servers/routes/mcp/hooks.py +1 -1
  132. gobby/servers/routes/mcp/tools.py +48 -1317
  133. gobby/servers/websocket.py +2 -2
  134. gobby/sessions/analyzer.py +2 -0
  135. gobby/sessions/lifecycle.py +1 -1
  136. gobby/sessions/processor.py +10 -0
  137. gobby/sessions/transcripts/base.py +2 -0
  138. gobby/sessions/transcripts/claude.py +79 -10
  139. gobby/skills/__init__.py +91 -0
  140. gobby/skills/loader.py +685 -0
  141. gobby/skills/manager.py +384 -0
  142. gobby/skills/parser.py +286 -0
  143. gobby/skills/search.py +463 -0
  144. gobby/skills/sync.py +119 -0
  145. gobby/skills/updater.py +385 -0
  146. gobby/skills/validator.py +368 -0
  147. gobby/storage/clones.py +378 -0
  148. gobby/storage/database.py +1 -1
  149. gobby/storage/memories.py +43 -13
  150. gobby/storage/migrations.py +162 -201
  151. gobby/storage/sessions.py +116 -7
  152. gobby/storage/skills.py +782 -0
  153. gobby/storage/tasks/_crud.py +4 -4
  154. gobby/storage/tasks/_lifecycle.py +57 -7
  155. gobby/storage/tasks/_manager.py +14 -5
  156. gobby/storage/tasks/_models.py +8 -3
  157. gobby/sync/memories.py +40 -5
  158. gobby/sync/tasks.py +83 -6
  159. gobby/tasks/__init__.py +1 -2
  160. gobby/tasks/external_validator.py +1 -1
  161. gobby/tasks/validation.py +46 -35
  162. gobby/tools/summarizer.py +91 -10
  163. gobby/tui/api_client.py +4 -7
  164. gobby/tui/app.py +5 -3
  165. gobby/tui/screens/orchestrator.py +1 -2
  166. gobby/tui/screens/tasks.py +2 -4
  167. gobby/tui/ws_client.py +1 -1
  168. gobby/utils/daemon_client.py +2 -2
  169. gobby/utils/project_context.py +2 -3
  170. gobby/utils/status.py +13 -0
  171. gobby/workflows/actions.py +221 -1135
  172. gobby/workflows/artifact_actions.py +31 -0
  173. gobby/workflows/autonomous_actions.py +11 -0
  174. gobby/workflows/context_actions.py +93 -1
  175. gobby/workflows/detection_helpers.py +115 -31
  176. gobby/workflows/enforcement/__init__.py +47 -0
  177. gobby/workflows/enforcement/blocking.py +269 -0
  178. gobby/workflows/enforcement/commit_policy.py +283 -0
  179. gobby/workflows/enforcement/handlers.py +269 -0
  180. gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
  181. gobby/workflows/engine.py +13 -2
  182. gobby/workflows/git_utils.py +106 -0
  183. gobby/workflows/lifecycle_evaluator.py +29 -1
  184. gobby/workflows/llm_actions.py +30 -0
  185. gobby/workflows/loader.py +19 -6
  186. gobby/workflows/mcp_actions.py +20 -1
  187. gobby/workflows/memory_actions.py +154 -0
  188. gobby/workflows/safe_evaluator.py +183 -0
  189. gobby/workflows/session_actions.py +44 -0
  190. gobby/workflows/state_actions.py +60 -1
  191. gobby/workflows/stop_signal_actions.py +55 -0
  192. gobby/workflows/summary_actions.py +111 -1
  193. gobby/workflows/task_sync_actions.py +347 -0
  194. gobby/workflows/todo_actions.py +34 -1
  195. gobby/workflows/webhook_actions.py +185 -0
  196. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
  197. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
  198. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  199. gobby/adapters/codex.py +0 -1292
  200. gobby/install/claude/commands/gobby/bug.md +0 -51
  201. gobby/install/claude/commands/gobby/chore.md +0 -51
  202. gobby/install/claude/commands/gobby/epic.md +0 -52
  203. gobby/install/claude/commands/gobby/eval.md +0 -235
  204. gobby/install/claude/commands/gobby/feat.md +0 -49
  205. gobby/install/claude/commands/gobby/nit.md +0 -52
  206. gobby/install/claude/commands/gobby/ref.md +0 -52
  207. gobby/install/codex/prompts/forget.md +0 -7
  208. gobby/install/codex/prompts/memories.md +0 -7
  209. gobby/install/codex/prompts/recall.md +0 -7
  210. gobby/install/codex/prompts/remember.md +0 -13
  211. gobby/llm/gemini_executor.py +0 -339
  212. gobby/mcp_proxy/tools/session_messages.py +0 -1056
  213. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  214. gobby/prompts/defaults/expansion/system.md +0 -119
  215. gobby/prompts/defaults/expansion/user.md +0 -48
  216. gobby/prompts/defaults/external_validation/agent.md +0 -72
  217. gobby/prompts/defaults/external_validation/external.md +0 -63
  218. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  219. gobby/prompts/defaults/external_validation/system.md +0 -6
  220. gobby/prompts/defaults/features/import_mcp.md +0 -22
  221. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  222. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  223. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  224. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  225. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  226. gobby/prompts/defaults/features/server_description.md +0 -20
  227. gobby/prompts/defaults/features/server_description_system.md +0 -6
  228. gobby/prompts/defaults/features/task_description.md +0 -31
  229. gobby/prompts/defaults/features/task_description_system.md +0 -6
  230. gobby/prompts/defaults/features/tool_summary.md +0 -17
  231. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  232. gobby/prompts/defaults/research/step.md +0 -58
  233. gobby/prompts/defaults/validation/criteria.md +0 -47
  234. gobby/prompts/defaults/validation/validate.md +0 -38
  235. gobby/storage/migrations_legacy.py +0 -1359
  236. gobby/tasks/context.py +0 -747
  237. gobby/tasks/criteria.py +0 -342
  238. gobby/tasks/expansion.py +0 -626
  239. gobby/tasks/prompts/expand.py +0 -327
  240. gobby/tasks/research.py +0 -421
  241. gobby/tasks/tdd.py +0 -352
  242. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  243. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  244. {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
+ )