velune-cli 0.9.0__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 (279) hide show
  1. velune/__init__.py +5 -0
  2. velune/__main__.py +6 -0
  3. velune/cli/__init__.py +5 -0
  4. velune/cli/app.py +208 -0
  5. velune/cli/autocomplete.py +80 -0
  6. velune/cli/banner.py +60 -0
  7. velune/cli/commands/__init__.py +32 -0
  8. velune/cli/commands/ask.py +175 -0
  9. velune/cli/commands/base.py +16 -0
  10. velune/cli/commands/chat.py +228 -0
  11. velune/cli/commands/config.py +224 -0
  12. velune/cli/commands/daemon.py +88 -0
  13. velune/cli/commands/doctor.py +721 -0
  14. velune/cli/commands/init.py +170 -0
  15. velune/cli/commands/mcp.py +82 -0
  16. velune/cli/commands/memory.py +293 -0
  17. velune/cli/commands/models.py +683 -0
  18. velune/cli/commands/preflight.py +95 -0
  19. velune/cli/commands/run.py +270 -0
  20. velune/cli/commands/setup.py +184 -0
  21. velune/cli/commands/workspace.py +249 -0
  22. velune/cli/context.py +36 -0
  23. velune/cli/councilmodel_ui.py +199 -0
  24. velune/cli/display/council_view.py +254 -0
  25. velune/cli/display/memory_view.py +126 -0
  26. velune/cli/display/panels.py +35 -0
  27. velune/cli/display/progress.py +25 -0
  28. velune/cli/display/themes.py +25 -0
  29. velune/cli/main.py +15 -0
  30. velune/cli/model_selector.py +51 -0
  31. velune/cli/modes.py +86 -0
  32. velune/cli/pull_ui.py +123 -0
  33. velune/cli/registry.py +80 -0
  34. velune/cli/rendering/__init__.py +5 -0
  35. velune/cli/rendering/error_panel.py +79 -0
  36. velune/cli/rendering/markdown.py +63 -0
  37. velune/cli/repl.py +1855 -0
  38. velune/cli/session_manager.py +71 -0
  39. velune/cli/slash_commands.py +37 -0
  40. velune/cli/theme.py +8 -0
  41. velune/cognition/__init__.py +23 -0
  42. velune/cognition/agents/__init__.py +7 -0
  43. velune/cognition/agents/coder.py +209 -0
  44. velune/cognition/agents/planner.py +156 -0
  45. velune/cognition/agents/reviewer.py +195 -0
  46. velune/cognition/arbitrator.py +220 -0
  47. velune/cognition/architecture.py +415 -0
  48. velune/cognition/budget.py +65 -0
  49. velune/cognition/council/__init__.py +47 -0
  50. velune/cognition/council/base.py +217 -0
  51. velune/cognition/council/challenger.py +74 -0
  52. velune/cognition/council/coder.py +79 -0
  53. velune/cognition/council/critic_agent.py +43 -0
  54. velune/cognition/council/critic_configs.py +111 -0
  55. velune/cognition/council/critics.py +41 -0
  56. velune/cognition/council/debate.py +46 -0
  57. velune/cognition/council/factory.py +140 -0
  58. velune/cognition/council/messages.py +56 -0
  59. velune/cognition/council/planner.py +124 -0
  60. velune/cognition/council/reviewer.py +74 -0
  61. velune/cognition/council/synthesizer.py +67 -0
  62. velune/cognition/council/tiers.py +188 -0
  63. velune/cognition/council_orchestrator.py +282 -0
  64. velune/cognition/firewall.py +354 -0
  65. velune/cognition/module.py +46 -0
  66. velune/cognition/orchestrator.py +1205 -0
  67. velune/cognition/personality.py +238 -0
  68. velune/cognition/state.py +104 -0
  69. velune/cognition/style_resolver.py +64 -0
  70. velune/cognition/verification.py +205 -0
  71. velune/context/__init__.py +28 -0
  72. velune/context/assembler.py +240 -0
  73. velune/context/budget.py +97 -0
  74. velune/context/extractive.py +95 -0
  75. velune/context/prompt_adaptation.py +480 -0
  76. velune/context/sections.py +99 -0
  77. velune/context/token_counter.py +134 -0
  78. velune/context/utilization.py +33 -0
  79. velune/context/window.py +63 -0
  80. velune/core/__init__.py +89 -0
  81. velune/core/background.py +5 -0
  82. velune/core/config/__init__.py +37 -0
  83. velune/core/errors/__init__.py +90 -0
  84. velune/core/errors/catalog.py +188 -0
  85. velune/core/errors/execution.py +31 -0
  86. velune/core/errors/memory.py +25 -0
  87. velune/core/errors/orchestration.py +31 -0
  88. velune/core/errors/provider.py +37 -0
  89. velune/core/event_loop.py +35 -0
  90. velune/core/logging.py +83 -0
  91. velune/core/paths.py +165 -0
  92. velune/core/runtime.py +113 -0
  93. velune/core/startup_profiler.py +56 -0
  94. velune/core/task_registry.py +117 -0
  95. velune/core/trace.py +83 -0
  96. velune/core/types/__init__.py +48 -0
  97. velune/core/types/agent.py +53 -0
  98. velune/core/types/context.py +42 -0
  99. velune/core/types/inference.py +38 -0
  100. velune/core/types/memory.py +42 -0
  101. velune/core/types/model.py +70 -0
  102. velune/core/types/provider.py +62 -0
  103. velune/core/types/repository.py +38 -0
  104. velune/core/types/task.py +61 -0
  105. velune/core/types/workspace.py +28 -0
  106. velune/daemon/client.py +13 -0
  107. velune/daemon/server.py +127 -0
  108. velune/daemon/transport.py +179 -0
  109. velune/events.py +204 -0
  110. velune/execution/__init__.py +22 -0
  111. velune/execution/benchmarker.py +315 -0
  112. velune/execution/cancellation.py +53 -0
  113. velune/execution/checkpointer.py +130 -0
  114. velune/execution/command_spec.py +165 -0
  115. velune/execution/diff_preview.py +197 -0
  116. velune/execution/executor.py +181 -0
  117. velune/execution/module.py +18 -0
  118. velune/execution/multi_diff.py +67 -0
  119. velune/execution/path_guard.py +74 -0
  120. velune/execution/planner.py +91 -0
  121. velune/execution/rollback.py +89 -0
  122. velune/execution/sandbox.py +268 -0
  123. velune/execution/validator.py +115 -0
  124. velune/hardware/__init__.py +1 -0
  125. velune/hardware/detector.py +192 -0
  126. velune/kernel/__init__.py +55 -0
  127. velune/kernel/bootstrap.py +125 -0
  128. velune/kernel/config.py +426 -0
  129. velune/kernel/entrypoint.py +78 -0
  130. velune/kernel/health.py +54 -0
  131. velune/kernel/lifecycle.py +143 -0
  132. velune/kernel/module.py +17 -0
  133. velune/kernel/modules.py +23 -0
  134. velune/kernel/registry.py +96 -0
  135. velune/kernel/schemas.py +28 -0
  136. velune/main.py +9 -0
  137. velune/mcp/__init__.py +9 -0
  138. velune/mcp/client.py +115 -0
  139. velune/mcp/config.py +19 -0
  140. velune/mcp/server.py +624 -0
  141. velune/memory/__init__.py +32 -0
  142. velune/memory/compaction.py +506 -0
  143. velune/memory/embedding_pipeline.py +241 -0
  144. velune/memory/lifecycle.py +680 -0
  145. velune/memory/module.py +218 -0
  146. velune/memory/prioritizer.py +67 -0
  147. velune/memory/storage/episodic_schema.sql +53 -0
  148. velune/memory/storage/lancedb_store.py +282 -0
  149. velune/memory/storage/sqlite_manager.py +369 -0
  150. velune/memory/storage/sqlite_pool.py +149 -0
  151. velune/memory/tiers/episodic.py +588 -0
  152. velune/memory/tiers/graph.py +378 -0
  153. velune/memory/tiers/lineage.py +416 -0
  154. velune/memory/tiers/semantic.py +475 -0
  155. velune/memory/tiers/working.py +168 -0
  156. velune/memory/vitality.py +132 -0
  157. velune/models/__init__.py +15 -0
  158. velune/models/family.py +76 -0
  159. velune/models/module.py +20 -0
  160. velune/models/probes.py +192 -0
  161. velune/models/profile_cache.py +84 -0
  162. velune/models/profiler.py +108 -0
  163. velune/models/registry.py +251 -0
  164. velune/models/scorer.py +233 -0
  165. velune/models/specializations.py +205 -0
  166. velune/orchestration/__init__.py +19 -0
  167. velune/orchestration/engine.py +239 -0
  168. velune/orchestration/module.py +15 -0
  169. velune/orchestration/role_assignments.py +82 -0
  170. velune/orchestration/schemas.py +98 -0
  171. velune/plugins/__init__.py +20 -0
  172. velune/plugins/hooks.py +50 -0
  173. velune/plugins/loader.py +161 -0
  174. velune/plugins/registry.py +56 -0
  175. velune/plugins/schemas.py +21 -0
  176. velune/providers/__init__.py +23 -0
  177. velune/providers/adapters/anthropic.py +257 -0
  178. velune/providers/adapters/fireworks.py +115 -0
  179. velune/providers/adapters/google.py +234 -0
  180. velune/providers/adapters/groq.py +151 -0
  181. velune/providers/adapters/huggingface.py +210 -0
  182. velune/providers/adapters/llamacpp.py +208 -0
  183. velune/providers/adapters/lmstudio.py +175 -0
  184. velune/providers/adapters/ollama.py +233 -0
  185. velune/providers/adapters/openai.py +213 -0
  186. velune/providers/adapters/openrouter.py +81 -0
  187. velune/providers/adapters/together.py +134 -0
  188. velune/providers/adapters/xai.py +60 -0
  189. velune/providers/base.py +86 -0
  190. velune/providers/benchmarker.py +138 -0
  191. velune/providers/discovery/__init__.py +33 -0
  192. velune/providers/discovery/anthropic.py +79 -0
  193. velune/providers/discovery/benchmarks.py +44 -0
  194. velune/providers/discovery/classifier.py +69 -0
  195. velune/providers/discovery/fireworks.py +95 -0
  196. velune/providers/discovery/gguf.py +88 -0
  197. velune/providers/discovery/google.py +95 -0
  198. velune/providers/discovery/gpu.py +117 -0
  199. velune/providers/discovery/groq.py +21 -0
  200. velune/providers/discovery/huggingface.py +67 -0
  201. velune/providers/discovery/lmstudio.py +80 -0
  202. velune/providers/discovery/ollama.py +162 -0
  203. velune/providers/discovery/openai.py +96 -0
  204. velune/providers/discovery/openrouter.py +113 -0
  205. velune/providers/discovery/scanner.py +115 -0
  206. velune/providers/discovery/together.py +114 -0
  207. velune/providers/discovery/xai.py +57 -0
  208. velune/providers/health.py +67 -0
  209. velune/providers/health_monitor.py +169 -0
  210. velune/providers/keystore.py +142 -0
  211. velune/providers/local_paths.py +49 -0
  212. velune/providers/local_resolver.py +229 -0
  213. velune/providers/module.py +51 -0
  214. velune/providers/ollama_manager.py +193 -0
  215. velune/providers/registry.py +220 -0
  216. velune/providers/router.py +255 -0
  217. velune/providers/task_classifier.py +288 -0
  218. velune/py.typed +0 -0
  219. velune/repository/__init__.py +33 -0
  220. velune/repository/analyzer.py +127 -0
  221. velune/repository/ast_parser.py +822 -0
  222. velune/repository/blast_radius.py +298 -0
  223. velune/repository/boundary_classifier.py +295 -0
  224. velune/repository/cognition.py +316 -0
  225. velune/repository/grapher.py +179 -0
  226. velune/repository/import_graph.py +263 -0
  227. velune/repository/incremental_indexer.py +275 -0
  228. velune/repository/index_state.py +96 -0
  229. velune/repository/indexer.py +243 -0
  230. velune/repository/module.py +17 -0
  231. velune/repository/parser.py +474 -0
  232. velune/repository/project_type.py +300 -0
  233. velune/repository/rename_journal.py +287 -0
  234. velune/repository/scanner.py +193 -0
  235. velune/repository/schemas.py +102 -0
  236. velune/repository/symbol_registry.py +365 -0
  237. velune/repository/tracker.py +252 -0
  238. velune/retrieval/__init__.py +27 -0
  239. velune/retrieval/cache.py +110 -0
  240. velune/retrieval/fast_path.py +391 -0
  241. velune/retrieval/graph.py +124 -0
  242. velune/retrieval/hybrid.py +271 -0
  243. velune/retrieval/keyword.py +131 -0
  244. velune/retrieval/module.py +26 -0
  245. velune/retrieval/pipeline.py +303 -0
  246. velune/retrieval/reranker.py +102 -0
  247. velune/retrieval/schemas.py +59 -0
  248. velune/retrieval/slow_path.py +364 -0
  249. velune/retrieval/vector.py +203 -0
  250. velune/telemetry/__init__.py +59 -0
  251. velune/telemetry/cognition.py +267 -0
  252. velune/telemetry/cost_estimator.py +92 -0
  253. velune/telemetry/debug.py +304 -0
  254. velune/telemetry/doctor.py +244 -0
  255. velune/telemetry/logging.py +286 -0
  256. velune/telemetry/spans.py +277 -0
  257. velune/telemetry/token_tracker.py +140 -0
  258. velune/telemetry/usage_tracker.py +340 -0
  259. velune/tools/__init__.py +41 -0
  260. velune/tools/base/registry.py +87 -0
  261. velune/tools/base/tool.py +63 -0
  262. velune/tools/code/navigate.py +116 -0
  263. velune/tools/code/search.py +123 -0
  264. velune/tools/filesystem/read.py +75 -0
  265. velune/tools/filesystem/search.py +136 -0
  266. velune/tools/filesystem/write.py +163 -0
  267. velune/tools/git/history.py +177 -0
  268. velune/tools/git/operations.py +122 -0
  269. velune/tools/git/state.py +121 -0
  270. velune/tools/module.py +81 -0
  271. velune/tools/terminal/execute.py +72 -0
  272. velune/tools/terminal/history.py +47 -0
  273. velune/tools/web/fetch.py +55 -0
  274. velune/tools/web/validator.py +122 -0
  275. velune_cli-0.9.0.dist-info/METADATA +518 -0
  276. velune_cli-0.9.0.dist-info/RECORD +279 -0
  277. velune_cli-0.9.0.dist-info/WHEEL +4 -0
  278. velune_cli-0.9.0.dist-info/entry_points.txt +2 -0
  279. velune_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,271 @@
1
+ import logging
2
+ import os
3
+ from typing import Any
4
+
5
+ logger = logging.getLogger("velune.retrieval.hybrid")
6
+
7
+
8
+ from velune.retrieval.graph import GraphRetriever
9
+ from velune.retrieval.keyword import BM25Retriever
10
+ from velune.retrieval.reranker import CrossEncoderReranker
11
+ from velune.retrieval.schemas import RetrievalHit, RetrievalQuery, RetrievalResult
12
+ from velune.retrieval.vector import VectorRetriever
13
+
14
+
15
+ class HybridRetriever:
16
+ """Orchestrates fusion retrieval, combining Lexical, Vector, and Graph traversals.
17
+
18
+ Primary interface: await retrieve(). search() is sync-only.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ location: str = ":memory:",
24
+ client: Any | None = None,
25
+ client_provider: Any | None = None,
26
+ ) -> None:
27
+ self.vector_retriever = VectorRetriever(
28
+ location=location, client=client, client_provider=client_provider
29
+ )
30
+ self.lexical_retriever = BM25Retriever()
31
+ self.graph_retriever = GraphRetriever()
32
+ self.reranker = CrossEncoderReranker()
33
+
34
+ def add_documents(self, docs: list[Any]) -> None:
35
+ """Adds and indexes documents in both vector and lexical subsystems.
36
+
37
+ All documents must have a pre-computed embedding.
38
+ """
39
+ # Index in Lexical (BM25)
40
+ self.lexical_retriever.add_documents(docs)
41
+
42
+ # Index in Vector (Qdrant)
43
+ for doc in docs:
44
+ # Require embedding to be pre-computed (make embedding field required, not optional)
45
+ if not doc.embedding:
46
+ raise ValueError(
47
+ f"Document {doc.id} must have a pre-computed embedding. "
48
+ "All callers of add_documents() must pre-compute embeddings using await before calling."
49
+ )
50
+ self.vector_retriever.upsert(doc)
51
+
52
+ async def retrieve(self, query: RetrievalQuery) -> RetrievalResult:
53
+ """Performs full hybrid retrieval, merges candidate pools, and reranks."""
54
+ lexical_hits: list[RetrievalHit] = []
55
+ vector_hits: list[RetrievalHit] = []
56
+ graph_hits: list[RetrievalHit] = []
57
+
58
+ # 1. Execute Lexical search (BM25)
59
+ if query.lexical_weight > 0.0:
60
+ try:
61
+ lexical_hits = self.lexical_retriever.retrieve(
62
+ query.text, top_k=query.top_k, namespace=query.namespace
63
+ )
64
+ except Exception:
65
+ pass
66
+
67
+ # 2. Execute Vector search (Qdrant)
68
+ if query.vector_weight > 0.0:
69
+ try:
70
+ # Generate embedding for the query
71
+ emb = await self._generate_embedding_async(query.text)
72
+ if emb is not None: # Only do vector search if real embedding
73
+ vector_hits = self.vector_retriever.retrieve(
74
+ emb, top_k=query.top_k, namespace=query.namespace
75
+ )
76
+ else:
77
+ logger.info("Vector retrieval skipped — no embedding available")
78
+ except Exception:
79
+ pass
80
+
81
+ # 3. Execute Graph traversal search
82
+ # If we have hits from lexical or vector search, traverse neighboring file links
83
+ if query.graph_weight > 0.0:
84
+ seed_nodes = []
85
+ # Gather file path candidates
86
+ for hit in lexical_hits[:3] + vector_hits[:3]:
87
+ path = hit.document.metadata.get("path")
88
+ if path:
89
+ seed_nodes.append(path)
90
+ name = hit.document.metadata.get("name")
91
+ if name:
92
+ seed_nodes.append(name)
93
+
94
+ for node in set(seed_nodes):
95
+ try:
96
+ gh = self.graph_retriever.retrieve(node, depth=1, top_k=5)
97
+ graph_hits.extend(gh)
98
+ except Exception:
99
+ pass
100
+
101
+ # 4. Fusion and Deduplication
102
+ merged_hits_map: dict[str, RetrievalHit] = {}
103
+
104
+ # Helper to blend weights into score
105
+ def merge_hit(hit: RetrievalHit, weight: float) -> None:
106
+ doc_id = hit.document.id
107
+ weighted_score = hit.score * weight
108
+
109
+ if doc_id in merged_hits_map:
110
+ # Combine scores from multiple search strategies
111
+ existing = merged_hits_map[doc_id]
112
+ existing.score += weighted_score
113
+ else:
114
+ hit.score = weighted_score
115
+ merged_hits_map[doc_id] = hit
116
+
117
+ for h in lexical_hits:
118
+ merge_hit(h, query.lexical_weight)
119
+ for h in vector_hits:
120
+ merge_hit(h, query.vector_weight)
121
+ for h in graph_hits:
122
+ merge_hit(h, query.graph_weight)
123
+
124
+ all_hits = list(merged_hits_map.values())
125
+
126
+ # 5. Rerank final combined candidates
127
+ reranked_hits = self.reranker.rerank(all_hits, query.text)
128
+
129
+ return RetrievalResult(
130
+ query=query, hits=reranked_hits[: query.top_k], strategy="hybrid-fusion-reranked"
131
+ )
132
+
133
+ def search_sync(self, query: RetrievalQuery) -> RetrievalResult:
134
+ """Synchronous retrieval. DEPRECATED: use await retrieve() in async contexts.
135
+
136
+ Raises:
137
+ RuntimeError: If called from within a running event loop.
138
+ """
139
+ import asyncio
140
+ import warnings
141
+
142
+ warnings.warn(
143
+ "HybridRetriever.search_sync() is deprecated and will be removed in a future version. "
144
+ "Use 'await retriever.retrieve(query)' instead.",
145
+ DeprecationWarning,
146
+ stacklevel=2,
147
+ )
148
+ try:
149
+ asyncio.get_running_loop()
150
+ raise RuntimeError(
151
+ "HybridRetriever.search_sync() cannot be called from an async context. "
152
+ "Use 'await retriever.retrieve(query)' instead."
153
+ )
154
+ except RuntimeError as e:
155
+ if "cannot be called" in str(e):
156
+ raise
157
+ # No running loop — safe to delegate to run_async().
158
+
159
+ from velune.kernel.entrypoint import run_async
160
+
161
+ return run_async(self.retrieve(query))
162
+
163
+ def search(self, query: RetrievalQuery) -> RetrievalResult:
164
+ """Synchronous interface. Do NOT call from within a running event loop.
165
+
166
+ Raises:
167
+ RuntimeError: If called from within a running event loop.
168
+ """
169
+ import asyncio
170
+
171
+ try:
172
+ asyncio.get_running_loop()
173
+ raise RuntimeError(
174
+ "HybridRetriever.search() cannot be called from an async context. "
175
+ "Use 'await retriever.retrieve()' instead."
176
+ )
177
+ except RuntimeError as e:
178
+ if "cannot be called" in str(e):
179
+ raise
180
+ # No running loop — safe to proceed.
181
+
182
+ from velune.kernel.entrypoint import run_async
183
+
184
+ return run_async(self.retrieve(query))
185
+
186
+ async def check_embedding_available(self) -> bool:
187
+ """Returns True if a real embedding provider is available."""
188
+ try:
189
+ test_emb = await self._generate_embedding_async("test")
190
+ return test_emb is not None
191
+ except Exception:
192
+ return False
193
+
194
+ def _deterministic_fallback_embedding(self, text: str) -> list[float]:
195
+ """Sophisticated deterministic fallback embedding vector."""
196
+ res = [0.0] * 1536
197
+ for idx, char in enumerate(text[:300]):
198
+ res[idx % 1536] += ord(char) / 256.0
199
+ return res
200
+
201
+ async def _generate_embedding_async(self, text: str) -> list[float] | None:
202
+ """Generates embedding asynchronously using the registered ModelProvider, or falls back to a deterministic vector.
203
+
204
+ INTERNAL ONLY.
205
+ """
206
+ try:
207
+ from velune.kernel.registry import get_container
208
+
209
+ container = get_container()
210
+ if container.has("runtime.provider_registry"):
211
+ provider_registry = container.get("runtime.provider_registry")
212
+ config = (
213
+ container.get("runtime.config") if container.has("runtime.config") else None
214
+ )
215
+
216
+ provider_name = "openai"
217
+ if config and hasattr(config, "providers") and config.providers:
218
+ provider_name = config.providers.default_provider
219
+
220
+ provider = provider_registry.get(provider_name)
221
+ if provider:
222
+ try:
223
+ caps = provider.get_capabilities()
224
+ if not caps.supports_embeddings:
225
+ logger.info(
226
+ "Provider %s does not support embeddings. Skipping vector embedding generation.",
227
+ provider_name,
228
+ )
229
+ return None
230
+ except Exception as e:
231
+ logger.warning(
232
+ "Could not query capabilities for provider %s: %s", provider_name, e
233
+ )
234
+
235
+ model_id = "text-embedding-3-small"
236
+ if provider_name == "ollama":
237
+ model_id = "nomic-embed-text"
238
+
239
+ res = await provider.embed([text], model_id=model_id)
240
+ emb = res[0] if res else None
241
+ if emb:
242
+ logger.debug(
243
+ "Generated embedding: dim=%d, provider=%s", len(emb), provider_name
244
+ )
245
+ return emb
246
+ except Exception as e:
247
+ import logging
248
+
249
+ logging.getLogger("velune.retrieval.hybrid").warning(
250
+ "Failed to generate embedding using ModelProvider: %s. Falling back to deterministic embedding.",
251
+ e,
252
+ )
253
+
254
+ # No provider available
255
+ allow_fallback = (
256
+ os.environ.get("VELUNE_ALLOW_FALLBACK_EMBEDDING", "false").lower() == "true"
257
+ )
258
+ if allow_fallback:
259
+ logger.warning(
260
+ "Using character-frequency fallback embedding. "
261
+ "Semantic retrieval results will be degraded. "
262
+ "Install an embedding model (e.g., ollama pull nomic-embed-text) "
263
+ "or set OPENAI_API_KEY to enable real embeddings."
264
+ )
265
+ return self._deterministic_fallback_embedding(text)
266
+
267
+ logger.warning(
268
+ "No embedding provider available. Vector retrieval disabled. "
269
+ "Set VELUNE_ALLOW_FALLBACK_EMBEDDING=true to enable degraded mode."
270
+ )
271
+ return None
@@ -0,0 +1,131 @@
1
+ """BM25 Lexical retrieval layer for exact keyword matches."""
2
+
3
+ import logging
4
+ import re
5
+ import threading
6
+ import time
7
+
8
+ from rank_bm25 import BM25Okapi
9
+
10
+ from velune.retrieval.schemas import RetrievalDocument, RetrievalHit, RetrievalSource
11
+
12
+ logger = logging.getLogger("velune.retrieval.keyword")
13
+
14
+
15
+ class BM25Retriever:
16
+ """Retrieves context using BM25 exact keyword lexical indexing."""
17
+
18
+ def __init__(self) -> None:
19
+ self.documents: list[RetrievalDocument] = []
20
+ self.corpus: list[list[str]] = []
21
+ self.bm25: BM25Okapi | None = None
22
+ self._dirty: bool = False # True when corpus needs rebuild
23
+ self._rebuild_lock = threading.Lock()
24
+
25
+ @property
26
+ def index_size(self) -> int:
27
+ return len(self.documents)
28
+
29
+ def add_documents(self, docs: list[RetrievalDocument]) -> None:
30
+ """Appends new documents to the lexical index corpus and marks the index as dirty."""
31
+ for doc in docs:
32
+ self.documents.append(doc)
33
+ self.corpus.append(self._tokenize(doc.content))
34
+ self._dirty = True
35
+ self.bm25 = None # Invalidate current index
36
+ logger.debug("BM25 index marked dirty: %d total documents", len(self.documents))
37
+
38
+ def add_documents_batch(self, docs: list[RetrievalDocument]) -> None:
39
+ """More efficient batch add — tokenizes new documents and appends them to corpus."""
40
+ new_tokens = [self._tokenize(doc.content) for doc in docs]
41
+ self.documents.extend(docs)
42
+ self.corpus.extend(new_tokens)
43
+ self._dirty = True
44
+ self.bm25 = None
45
+ logger.debug("BM25 index marked dirty: %d total documents", len(self.documents))
46
+
47
+ def _ensure_index(self) -> None:
48
+ """Rebuild BM25 index if dirty. Thread-safe."""
49
+ if not self._dirty or not self.corpus:
50
+ return
51
+ with self._rebuild_lock:
52
+ if not self._dirty: # Double-check after acquiring lock
53
+ return
54
+ start_time = time.time()
55
+ self.bm25 = BM25Okapi(self.corpus)
56
+ self._dirty = False
57
+ elapsed = time.time() - start_time
58
+ logger.debug("BM25 index rebuilt: %d documents, %.3fs", len(self.documents), elapsed)
59
+
60
+ def retrieve(
61
+ self, query: str, top_k: int = 10, namespace: str | None = None
62
+ ) -> list[RetrievalHit]:
63
+ """Queries the BM25 lexical index and scores candidates."""
64
+ if not self.documents:
65
+ return []
66
+
67
+ if self._dirty:
68
+ self._ensure_index()
69
+
70
+ if not self.bm25:
71
+ return []
72
+
73
+ tokens = self._tokenize(query)
74
+ scores = self.bm25.get_scores(tokens)
75
+
76
+ # Pair scores with documents and index ranks
77
+ hits: list[RetrievalHit] = []
78
+ for i, score in enumerate(scores):
79
+ doc = self.documents[i]
80
+
81
+ # Match namespace filter if provided
82
+ if namespace and doc.namespace != namespace:
83
+ continue
84
+
85
+ if score > 0.0: # Only capture positive keyword matches
86
+ hits.append(
87
+ RetrievalHit(
88
+ document=doc, score=float(score), source=RetrievalSource.LEXICAL, rank=0
89
+ )
90
+ )
91
+
92
+ # Sort and return top candidates
93
+ hits.sort(key=lambda x: x.score, reverse=True)
94
+
95
+ # Trim list and apply proper sequential ranks
96
+ final_hits = hits[:top_k]
97
+ for idx, h in enumerate(final_hits):
98
+ h.rank = idx + 1
99
+
100
+ return final_hits
101
+
102
+ def _tokenize(self, text: str) -> list[str]:
103
+ """Simplistic and quick alphanumeric tokenization ignoring standard case mappings."""
104
+ # Lowercase and split on non-alphanumeric boundaries
105
+ words = re.findall(r"\w+", text.lower())
106
+
107
+ # Remove extremely common short English stop words to filter noise
108
+ stop_words = {
109
+ "a",
110
+ "an",
111
+ "the",
112
+ "and",
113
+ "or",
114
+ "but",
115
+ "if",
116
+ "then",
117
+ "else",
118
+ "to",
119
+ "of",
120
+ "in",
121
+ "for",
122
+ "on",
123
+ "with",
124
+ "at",
125
+ "by",
126
+ "from",
127
+ "is",
128
+ "this",
129
+ "that",
130
+ }
131
+ return [w for w in words if w not in stop_words and len(w) > 1]
@@ -0,0 +1,26 @@
1
+ from velune.kernel.bootstrap import RuntimeEnvironment, SubsystemModule
2
+
3
+
4
+ def _create_hybrid_retriever(env: RuntimeEnvironment):
5
+ from velune.core.paths import qdrant_store_path
6
+ from velune.retrieval.hybrid import HybridRetriever
7
+
8
+ vector_path = str(qdrant_store_path(env.workspace))
9
+ semantic_tier = env.container.get("runtime.semantic_memory")
10
+ # Share the semantic tier's single Qdrant connection, but resolve it lazily
11
+ # via a provider so wiring retrieval at bootstrap does not open the store.
12
+ return HybridRetriever(
13
+ location=vector_path,
14
+ client_provider=lambda: semantic_tier.client,
15
+ )
16
+
17
+
18
+ RETRIEVAL_MODULES = [
19
+ SubsystemModule(
20
+ name="retrieval",
21
+ factory=_create_hybrid_retriever,
22
+ container_key="runtime.retrieval",
23
+ lifecycle_key="retrieval",
24
+ dependencies=["runtime.semantic_memory"],
25
+ )
26
+ ]